Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0768a63038 |
@@ -17,7 +17,7 @@ The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev
|
|||||||
./gradlew clean check jar
|
./gradlew clean check jar
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin JAR is written to `build/libs/`.
|
The plugin JAR is written to `build/libs/`. `check` includes native label packet tests against the SHA-256-verified Purpur 2618 runtime. The build downloads/patches that runtime under `build/label-runtime/`, loads its registries, and exercises codecs without starting a listening Minecraft server.
|
||||||
|
|
||||||
## Player commands
|
## Player commands
|
||||||
|
|
||||||
@@ -45,6 +45,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.
|
`/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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.net.URI
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
java
|
java
|
||||||
}
|
}
|
||||||
@@ -37,6 +40,59 @@ tasks.test {
|
|||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify client-only label packets against the declared runtime, without starting a server.
|
||||||
|
val labelRuntime = layout.buildDirectory.dir("label-runtime")
|
||||||
|
val downloadLabelRuntime = tasks.register("downloadLabelRuntime") {
|
||||||
|
val launcher = labelRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||||
|
outputs.file(launcher)
|
||||||
|
doLast {
|
||||||
|
val file = launcher.get().asFile
|
||||||
|
file.parentFile.mkdirs()
|
||||||
|
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
|
||||||
|
connection.connectTimeout = 30_000
|
||||||
|
connection.readTimeout = 120_000
|
||||||
|
val bytes = connection.getInputStream().use { it.readBytes() }
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||||
|
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
|
||||||
|
"Purpur 2618 checksum mismatch"
|
||||||
|
}
|
||||||
|
file.writeBytes(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val prepareLabelRuntime = tasks.register<JavaExec>("prepareLabelRuntime") {
|
||||||
|
dependsOn(downloadLabelRuntime)
|
||||||
|
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||||
|
classpath = files(labelRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||||
|
mainClass = "io.papermc.paperclip.Main"
|
||||||
|
jvmArgs("-Dpaperclip.patchonly=true")
|
||||||
|
workingDir(labelRuntime)
|
||||||
|
outputs.dir(labelRuntime.map { it.dir("versions") })
|
||||||
|
outputs.dir(labelRuntime.map { it.dir("libraries") })
|
||||||
|
}
|
||||||
|
val nativeTest = sourceSets.create("nativeTest")
|
||||||
|
dependencies {
|
||||||
|
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
|
||||||
|
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
|
||||||
|
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
|
||||||
|
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
|
||||||
|
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
|
||||||
|
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
|
||||||
|
}
|
||||||
|
val nativeRuntimeJars = files(fileTree(labelRuntime) {
|
||||||
|
include("versions/**/*.jar", "libraries/**/*.jar")
|
||||||
|
}).builtBy(prepareLabelRuntime)
|
||||||
|
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||||
|
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||||
|
val nativeLabelTest = tasks.register<Test>("nativeLabelTest") {
|
||||||
|
description = "Tests native label packet encoding without a listening server"
|
||||||
|
testClassesDirs = nativeTest.output.classesDirs
|
||||||
|
classpath = nativeTest.runtimeClasspath
|
||||||
|
useJUnitPlatform()
|
||||||
|
maxHeapSize = "1G"
|
||||||
|
workingDir(labelRuntime)
|
||||||
|
}
|
||||||
|
tasks.check { dependsOn(nativeLabelTest) }
|
||||||
|
|
||||||
val pluginVersion = version
|
val pluginVersion = version
|
||||||
|
|
||||||
tasks.processResources {
|
tasks.processResources {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Server-thread model: actual base bounds, stable anchors and per-visit notifications; no Bukkit or I/O. */
|
||||||
|
public final class BaseIdentification {
|
||||||
|
public record Base(UUID owner, String ownerName, BaseArea area) { }
|
||||||
|
public record Viewer(UUID id, UUID world, int x, int y, int z) { }
|
||||||
|
public record Label(UUID owner, String text, UUID world, double x, double y, double z, float viewRange) { }
|
||||||
|
public record Frame(Map<UUID, Label> labels, List<String> entries) { }
|
||||||
|
private record Origin(UUID world, int x, int y, int z) {
|
||||||
|
static Origin of(BaseLocation center) {
|
||||||
|
return new Origin(center.worldId(), center.x(), center.y(), center.z());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private record Prepared(Base base, Origin origin, String ownerName, Label label) { }
|
||||||
|
private record Visit(Origin origin, boolean announced) { }
|
||||||
|
private List<Base> bases = List.of();
|
||||||
|
private Map<UUID, List<Prepared>> byWorld = Map.of();
|
||||||
|
private final Map<UUID, Map<UUID, Visit>> visits = new HashMap<>();
|
||||||
|
|
||||||
|
public void setBases(Collection<Base> next) {
|
||||||
|
List<Base> sorted = next.stream().sorted(Comparator.comparing(base -> base.owner().toString())).toList();
|
||||||
|
if (sorted.equals(bases)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<Origin, Integer> ranks = new HashMap<>();
|
||||||
|
Map<UUID, List<Prepared>> prepared = new HashMap<>();
|
||||||
|
for (Base base : sorted) {
|
||||||
|
BaseArea area = base.area();
|
||||||
|
BaseLocation center = area.center();
|
||||||
|
Origin origin = Origin.of(center);
|
||||||
|
int rank = ranks.merge(origin, 1, Integer::sum) - 1;
|
||||||
|
double offset = 2.25 + rank * 0.35;
|
||||||
|
String name = base.ownerName() == null || base.ownerName().isBlank() ? base.owner().toString() : base.ownerName();
|
||||||
|
float viewRange = (float) Math.max(16, Math.hypot((double) area.radius() + 16,
|
||||||
|
(double) area.verticalRange() + offset + 1) / 64);
|
||||||
|
Label label = new Label(base.owner(), "Base: " + name, center.worldId(),
|
||||||
|
center.x() + 0.5, center.y() + offset, center.z() + 0.5, viewRange);
|
||||||
|
prepared.computeIfAbsent(center.worldId(), ignored -> new ArrayList<>())
|
||||||
|
.add(new Prepared(base, origin, name, label));
|
||||||
|
}
|
||||||
|
prepared.replaceAll((world, values) -> List.copyOf(values));
|
||||||
|
bases = sorted;
|
||||||
|
byWorld = Map.copyOf(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void forget(UUID viewer) { visits.remove(viewer); }
|
||||||
|
public void clear() { visits.clear(); bases = List.of(); byWorld = Map.of(); }
|
||||||
|
|
||||||
|
public Frame update(Viewer viewer) {
|
||||||
|
Map<UUID, Visit> previous = visits.getOrDefault(viewer.id(), Map.of());
|
||||||
|
Map<UUID, Visit> retained = new HashMap<>();
|
||||||
|
Map<UUID, Label> visible = new LinkedHashMap<>();
|
||||||
|
List<String> entries = new ArrayList<>();
|
||||||
|
for (Prepared prepared : byWorld.getOrDefault(viewer.world(), List.of())) {
|
||||||
|
Base base = prepared.base();
|
||||||
|
BaseArea area = base.area();
|
||||||
|
BaseLocation center = area.center();
|
||||||
|
if (base.owner().equals(viewer.id())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double distance = Math.hypot((double) viewer.x() - center.x(), (double) viewer.z() - center.z());
|
||||||
|
long vertical = Math.abs((long) viewer.y() - center.y());
|
||||||
|
if (distance > (double) area.radius() + 16 || vertical > (long) area.verticalRange() + 16) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Visit old = previous.get(base.owner());
|
||||||
|
boolean announced = old != null && old.origin().equals(prepared.origin()) && old.announced();
|
||||||
|
if (!announced && area.contains(viewer.world(), viewer.x(), viewer.y(), viewer.z())) {
|
||||||
|
entries.add("Entering " + prepared.ownerName() + "'s base");
|
||||||
|
announced = true;
|
||||||
|
}
|
||||||
|
retained.put(base.owner(), new Visit(prepared.origin(), announced));
|
||||||
|
if (vertical <= area.verticalRange()) {
|
||||||
|
visible.put(base.owner(), prepared.label());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (retained.isEmpty()) { visits.remove(viewer.id()); }
|
||||||
|
else { visits.put(viewer.id(), retained); }
|
||||||
|
return new Frame(Map.copyOf(visible), List.copyOf(entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||||
|
import org.bukkit.event.player.PlayerMoveEvent;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
|
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||||
|
|
||||||
|
/** Read-only state projection. Polling catches administration/settings changes; moves catch entries between polls. */
|
||||||
|
final class BaseIdentificationController implements Runnable, Listener, AutoCloseable {
|
||||||
|
private final Server server;
|
||||||
|
private final BaseStateManager states;
|
||||||
|
private final BaseBoundsService bounds;
|
||||||
|
private final BukkitBaseLabels labels;
|
||||||
|
private final Logger logger;
|
||||||
|
private final BaseIdentification identification = new BaseIdentification();
|
||||||
|
private final Set<UUID> tracked = new HashSet<>();
|
||||||
|
private final Set<UUID> failed = new HashSet<>();
|
||||||
|
private boolean closed;
|
||||||
|
|
||||||
|
BaseIdentificationController(Server server, BaseStateManager states, BaseBoundsService bounds,
|
||||||
|
BukkitBaseLabels labels, Logger logger) {
|
||||||
|
this.server = server;
|
||||||
|
this.states = states;
|
||||||
|
this.bounds = bounds;
|
||||||
|
this.labels = labels;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (closed) { return; }
|
||||||
|
identification.setBases(states.knownPlayers().values().stream().filter(state -> state.base().isPresent())
|
||||||
|
.map(state -> new BaseIdentification.Base(state.playerId(), state.latestName(), bounds.area(state))).toList());
|
||||||
|
Set<UUID> online = new HashSet<>();
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
online.add(player.getUniqueId());
|
||||||
|
update(player, player.getLocation());
|
||||||
|
}
|
||||||
|
for (UUID id : Set.copyOf(tracked)) {
|
||||||
|
if (!online.contains(id)) { forget(id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void update(Player player, Location location) {
|
||||||
|
if (closed || !player.isOnline() || location == null || location.getWorld() == null
|
||||||
|
|| !location.getWorld().equals(player.getWorld())) { return; }
|
||||||
|
UUID id = player.getUniqueId();
|
||||||
|
tracked.add(id);
|
||||||
|
var frame = identification.update(new BaseIdentification.Viewer(id, location.getWorld().getUID(),
|
||||||
|
location.getBlockX(), location.getBlockY(), location.getBlockZ()));
|
||||||
|
if (!frame.entries().isEmpty()) {
|
||||||
|
player.sendActionBar(Component.text(String.join(" • ", frame.entries())));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
labels.render(player, frame.labels());
|
||||||
|
failed.remove(id);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
// Retained partial packet delivery is retried on the next update; avoid repeated log spam.
|
||||||
|
if (failed.add(id)) { logger.log(Level.WARNING, "Could not update private base labels for " + id, exception); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onMove(PlayerMoveEvent event) {
|
||||||
|
if (event.isCancelled() || event.getTo() == null) { return; }
|
||||||
|
Location from = event.getFrom(), to = event.getTo();
|
||||||
|
if (from.getWorld().equals(to.getWorld()) && (from.getBlockX() != to.getBlockX()
|
||||||
|
|| from.getBlockY() != to.getBlockY() || from.getBlockZ() != to.getBlockZ())) {
|
||||||
|
update(event.getPlayer(), to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onTeleport(org.bukkit.event.player.PlayerTeleportEvent event) { onMove(event); }
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onQuit(PlayerQuitEvent event) { forget(event.getPlayer().getUniqueId()); }
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onWorldChange(PlayerChangedWorldEvent event) { forget(event.getPlayer().getUniqueId()); }
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onRespawn(PlayerRespawnEvent event) {
|
||||||
|
try { labels.clear(event.getPlayer()); }
|
||||||
|
catch (RuntimeException exception) { logger.log(Level.WARNING, "Could not clear respawning player's base labels", exception); }
|
||||||
|
forget(event.getPlayer().getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void forget(UUID id) {
|
||||||
|
labels.forget(id);
|
||||||
|
identification.forget(id);
|
||||||
|
tracked.remove(id);
|
||||||
|
failed.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (closed) { return; }
|
||||||
|
closed = true;
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
try { labels.clear(player); }
|
||||||
|
catch (RuntimeException exception) { logger.log(Level.WARNING, "Could not remove private base labels on disable", exception); }
|
||||||
|
}
|
||||||
|
labels.forgetAll();
|
||||||
|
identification.clear();
|
||||||
|
tracked.clear();
|
||||||
|
failed.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.TextDisplay;
|
||||||
|
|
||||||
|
/** Viewer-private, unspawned displays. All access is confined to the server thread. */
|
||||||
|
final class BukkitBaseLabels {
|
||||||
|
interface Packets {
|
||||||
|
int nextId(World world);
|
||||||
|
List<Object> spawn(TextDisplay display, BaseIdentification.Label label, int entityId);
|
||||||
|
Object remove(int entityId);
|
||||||
|
void send(Player viewer, Object packet);
|
||||||
|
}
|
||||||
|
private record Shown(BaseIdentification.Label label, int id, boolean complete) { }
|
||||||
|
private final Packets packets;
|
||||||
|
private final Map<UUID, Map<UUID, Shown>> viewers = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
BukkitBaseLabels(Packets packets) { this.packets = packets; }
|
||||||
|
|
||||||
|
void render(Player viewer, Map<UUID, BaseIdentification.Label> labels) {
|
||||||
|
World world = viewer.getWorld();
|
||||||
|
UUID worldId = world.getUID();
|
||||||
|
Map<UUID, Shown> shown = viewers.computeIfAbsent(viewer.getUniqueId(), ignored -> new java.util.HashMap<>());
|
||||||
|
var iterator = shown.entrySet().iterator();
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
var entry = iterator.next();
|
||||||
|
Shown old = entry.getValue();
|
||||||
|
if (!old.label().world().equals(worldId)) {
|
||||||
|
// A dimension change already cleared the client world. Never destroy old IDs in a new world.
|
||||||
|
iterator.remove();
|
||||||
|
} else if (!old.complete() || !old.label().equals(labels.get(entry.getKey()))) {
|
||||||
|
packets.send(viewer, packets.remove(old.id()));
|
||||||
|
iterator.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var label : labels.values()) {
|
||||||
|
if (!label.world().equals(worldId) || shown.containsKey(label.owner())) { continue; }
|
||||||
|
// createEntity does not spawn/register the entity or load its anchor chunk.
|
||||||
|
TextDisplay display = world.createEntity(new org.bukkit.Location(world, label.x(), label.y(), label.z()), TextDisplay.class);
|
||||||
|
display.setPersistent(false);
|
||||||
|
display.text(net.kyori.adventure.text.Component.text(label.text()));
|
||||||
|
display.setBillboard(org.bukkit.entity.Display.Billboard.CENTER);
|
||||||
|
display.setViewRange(label.viewRange());
|
||||||
|
display.setShadowed(true);
|
||||||
|
display.setLineWidth(512); // Keep UUID fallback names on one line at stacked anchors.
|
||||||
|
int id = packets.nextId(world);
|
||||||
|
List<Object> spawn = packets.spawn(display, label, id);
|
||||||
|
// Retain partial delivery so the next render/clear can remove it before retrying.
|
||||||
|
shown.put(label.owner(), new Shown(label, id, false));
|
||||||
|
for (Object packet : spawn) { packets.send(viewer, packet); }
|
||||||
|
shown.put(label.owner(), new Shown(label, id, true));
|
||||||
|
}
|
||||||
|
if (shown.isEmpty()) { viewers.remove(viewer.getUniqueId()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear(Player viewer) { render(viewer, Map.of()); }
|
||||||
|
void forget(UUID viewer) { viewers.remove(viewer); }
|
||||||
|
void forgetAll() { viewers.clear(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.TextDisplay;
|
||||||
|
|
||||||
|
/** Declared-runtime packet boundary; no reflection targets or packet types come from player input. */
|
||||||
|
final class NativeBaseLabelPackets implements BukkitBaseLabels.Packets {
|
||||||
|
private final Constructor<?> addEntity;
|
||||||
|
private final Constructor<?> metadata;
|
||||||
|
private final Constructor<?> removeEntities;
|
||||||
|
private final Object zeroVelocity;
|
||||||
|
private final Method entityHandle;
|
||||||
|
private final Method entityType;
|
||||||
|
private final Method entityData;
|
||||||
|
private final Method nonDefaultValues;
|
||||||
|
private final Method playerHandle;
|
||||||
|
private final Field connection;
|
||||||
|
private final Method sendPacket;
|
||||||
|
|
||||||
|
NativeBaseLabelPackets() {
|
||||||
|
try {
|
||||||
|
Class<?> entity = Class.forName("net.minecraft.world.entity.Entity");
|
||||||
|
Class<?> type = Class.forName("net.minecraft.world.entity.EntityType");
|
||||||
|
Class<?> vector = Class.forName("net.minecraft.world.phys.Vec3");
|
||||||
|
Class<?> packet = Class.forName("net.minecraft.network.protocol.Packet");
|
||||||
|
entityHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftEntity").getMethod("getHandle");
|
||||||
|
entityType = entity.getMethod("getType");
|
||||||
|
entityData = entity.getMethod("getEntityData");
|
||||||
|
nonDefaultValues = Class.forName("net.minecraft.network.syncher.SynchedEntityData").getMethod("getNonDefaultValues");
|
||||||
|
zeroVelocity = vector.getConstructor(double.class, double.class, double.class).newInstance(0.0, 0.0, 0.0);
|
||||||
|
addEntity = Class.forName("net.minecraft.network.protocol.game.ClientboundAddEntityPacket").getConstructor(
|
||||||
|
int.class, UUID.class, double.class, double.class, double.class, float.class, float.class,
|
||||||
|
type, int.class, vector, double.class);
|
||||||
|
metadata = Class.forName("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket")
|
||||||
|
.getConstructor(int.class, List.class);
|
||||||
|
removeEntities = Class.forName("net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket")
|
||||||
|
.getConstructor(int[].class);
|
||||||
|
playerHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
|
||||||
|
connection = Class.forName("net.minecraft.server.level.ServerPlayer").getField("connection");
|
||||||
|
sendPacket = Class.forName("net.minecraft.server.network.ServerCommonPacketListenerImpl").getMethod("send", packet);
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Client-only base labels are unsupported on this server version", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int nextId(org.bukkit.World world) {
|
||||||
|
return org.bukkit.Bukkit.getUnsafe().nextEntityId(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Object> spawn(TextDisplay entity, BaseIdentification.Label label, int entityId) {
|
||||||
|
try {
|
||||||
|
Object handle = entityHandle.invoke(entity);
|
||||||
|
Object values = nonDefaultValues.invoke(entityData.invoke(handle));
|
||||||
|
// Construct both packets before sending either, so an unsupported layout cannot leave a half-created label.
|
||||||
|
Object spawn = addEntity.newInstance(entityId, entity.getUniqueId(), label.x(), label.y(), label.z(),
|
||||||
|
0.0f, 0.0f, entityType.invoke(handle), 0, zeroVelocity, 0.0);
|
||||||
|
Object data = metadata.newInstance(entityId, values == null ? List.of() : values);
|
||||||
|
return List.of(spawn, data);
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not encode a client-only base label", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object remove(int entityId) {
|
||||||
|
try {
|
||||||
|
return removeEntities.newInstance((Object) new int[] {entityId});
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not encode base label removal", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(Player viewer, Object packet) {
|
||||||
|
try {
|
||||||
|
sendPacket.invoke(connection.get(playerHandle.invoke(viewer)), packet);
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new IllegalStateException("Could not send a private base label packet", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
private PocketBaseManager pocketBaseManager;
|
private PocketBaseManager pocketBaseManager;
|
||||||
private PocketBaseController pocketBaseController;
|
private PocketBaseController pocketBaseController;
|
||||||
private PocketBaseKeystoneService keystoneService;
|
private PocketBaseKeystoneService keystoneService;
|
||||||
|
private BaseIdentificationController identificationController;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -66,6 +67,14 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
TeleportProgressionService teleportProgressionService =
|
TeleportProgressionService teleportProgressionService =
|
||||||
new TeleportProgressionService(settingsProvider);
|
new TeleportProgressionService(settingsProvider);
|
||||||
BaseBoundsService boundsService = new BaseBoundsService(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(
|
progressListener = new BaseProgressListener(
|
||||||
this,
|
this,
|
||||||
stateManager,
|
stateManager,
|
||||||
@@ -156,6 +165,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (identificationController != null) {
|
||||||
|
identificationController.close();
|
||||||
|
}
|
||||||
if (progressListener != null) {
|
if (progressListener != null) {
|
||||||
progressListener.removeAllBossBars();
|
progressListener.removeAllBossBars();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.entity.EntityTypes;
|
||||||
|
import org.bukkit.craftbukkit.CraftRegistry;
|
||||||
|
import org.bukkit.craftbukkit.CraftServer;
|
||||||
|
import org.bukkit.craftbukkit.entity.CraftTextDisplay;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class NativeBaseLabelPacketsTest {
|
||||||
|
@BeforeAll
|
||||||
|
static void bootstrap() throws Exception {
|
||||||
|
NativeRuntime.bootstrap();
|
||||||
|
// Metadata encoding enters the server's item-obfuscation context even for text-only packets.
|
||||||
|
var global = mock(io.papermc.paper.configuration.GlobalConfiguration.class);
|
||||||
|
global.anticheat = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.class);
|
||||||
|
global.anticheat.obfuscation = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.Obfuscation.class);
|
||||||
|
global.anticheat.obfuscation.items = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.Obfuscation.Items.class);
|
||||||
|
var binding = mock(io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class);
|
||||||
|
var level = io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class.getField("level");
|
||||||
|
level.setAccessible(true);
|
||||||
|
level.set(binding, io.papermc.paper.util.sanitizer.ItemObfuscationSession.ObfuscationLevel.NONE);
|
||||||
|
global.anticheat.obfuscation.items.binding = binding;
|
||||||
|
var set = io.papermc.paper.configuration.GlobalConfiguration.class.getDeclaredMethod("set",
|
||||||
|
io.papermc.paper.configuration.GlobalConfiguration.class);
|
||||||
|
set.setAccessible(true);
|
||||||
|
set.invoke(null, global);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removalRoundTripsAndDeliveryUsesOnlyTheSelectedPlayerConnection() throws Exception {
|
||||||
|
var packets = new NativeBaseLabelPackets();
|
||||||
|
var remove = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.class,
|
||||||
|
packets.remove(42));
|
||||||
|
var buffer = new net.minecraft.network.FriendlyByteBuf(Unpooled.buffer());
|
||||||
|
try {
|
||||||
|
net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC.encode(buffer, remove);
|
||||||
|
var decoded = net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC.decode(buffer);
|
||||||
|
assertEquals(java.util.List.of(42), decoded.getEntityIds());
|
||||||
|
} finally { buffer.release(); }
|
||||||
|
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
|
||||||
|
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||||
|
handle.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
|
||||||
|
when(player.getHandle()).thenReturn(handle);
|
||||||
|
packets.send(player, remove);
|
||||||
|
verify(handle.connection).send(remove);
|
||||||
|
verifyNoMoreInteractions(handle.connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerLevel nativeLevel() throws Exception {
|
||||||
|
var level = mock(ServerLevel.class);
|
||||||
|
var configuration = mock(org.purpurmc.purpur.PurpurWorldConfig.class);
|
||||||
|
configuration.drowningAirTicks = 300;
|
||||||
|
var field = net.minecraft.world.level.Level.class.getField("purpurConfig");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(level, configuration);
|
||||||
|
var spigot = mock(org.spigotmc.SpigotWorldConfig.class);
|
||||||
|
spigot.miscActivationRange = 32;
|
||||||
|
var spigotField = net.minecraft.world.level.Level.class.getField("spigotConfig");
|
||||||
|
spigotField.setAccessible(true);
|
||||||
|
spigotField.set(level, spigot);
|
||||||
|
var paper = mock(io.papermc.paper.configuration.WorldConfiguration.class);
|
||||||
|
paper.entities = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.class);
|
||||||
|
paper.entities.spawning = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class);
|
||||||
|
paper.entities.spawning.despawnTime = new it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap<>();
|
||||||
|
when(level.paperConfig()).thenReturn(paper);
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicCreationAndIdReservationDoNotRegisterEntitiesOrLoadAnchorChunks() throws Exception {
|
||||||
|
var level = nativeLevel();
|
||||||
|
var world = mock(org.bukkit.craftbukkit.CraftWorld.class);
|
||||||
|
when(world.getHandle()).thenReturn(level);
|
||||||
|
when(level.getLevel()).thenReturn(level);
|
||||||
|
when(level.getMinecraftWorld()).thenReturn(level);
|
||||||
|
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
|
||||||
|
when(world.isNormalWorld()).thenReturn(true);
|
||||||
|
when(world.isEnabled(any(io.papermc.paper.world.flag.FeatureDependant.class))).thenReturn(true);
|
||||||
|
when(level.getCraftServer()).thenReturn(mock(CraftServer.class));
|
||||||
|
when(level.getWorld()).thenReturn(world);
|
||||||
|
doCallRealMethod().when(world).createEntity(any(org.bukkit.Location.class), eq(org.bukkit.entity.TextDisplay.class));
|
||||||
|
doCallRealMethod().when(world).createEntity(any(org.bukkit.Location.class), eq(org.bukkit.entity.TextDisplay.class), eq(true));
|
||||||
|
var display = world.createEntity(new org.bukkit.Location(world, 100.5, 66.25, -99.5), org.bukkit.entity.TextDisplay.class);
|
||||||
|
assertInstanceOf(CraftTextDisplay.class, display);
|
||||||
|
assertThrows(IllegalStateException.class, display::getEntityId);
|
||||||
|
var chunkSource = mock(net.minecraft.server.level.ServerChunkCache.class);
|
||||||
|
var field = ServerLevel.class.getDeclaredField("chunkSource");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(level, chunkSource);
|
||||||
|
when(level.getNextEntityId()).thenCallRealMethod();
|
||||||
|
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||||
|
bukkit.when(org.bukkit.Bukkit::getUnsafe).thenReturn(org.bukkit.craftbukkit.util.CraftMagicNumbers.INSTANCE);
|
||||||
|
var packets = new NativeBaseLabelPackets();
|
||||||
|
int first = packets.nextId(world), second = packets.nextId(world);
|
||||||
|
assertNotEquals(0, first);
|
||||||
|
assertNotEquals(first, second);
|
||||||
|
}
|
||||||
|
verify(chunkSource, atLeast(2)).hasEntityWithId(anyInt());
|
||||||
|
verifyNoMoreInteractions(chunkSource);
|
||||||
|
assertTrue(mockingDetails(level).getInvocations().stream().noneMatch(call ->
|
||||||
|
call.getMethod().getName().matches("(getChunk.*|loadChunk.*|add.*Entity.*)")),
|
||||||
|
"Public creation and ID allocation must not load chunks or add entities");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nativeSpawnAndMetadataRoundTripCarryCorrectTypeCoordinatesAndLiteralText() throws Exception {
|
||||||
|
var level = nativeLevel();
|
||||||
|
var nativeDisplay = new net.minecraft.world.entity.Display.TextDisplay(EntityTypes.TEXT_DISPLAY, level);
|
||||||
|
var display = new CraftTextDisplay(mock(CraftServer.class), nativeDisplay);
|
||||||
|
display.text(net.kyori.adventure.text.Component.text("Base: Alice"));
|
||||||
|
display.setBillboard(org.bukkit.entity.Display.Billboard.CENTER);
|
||||||
|
display.setViewRange(16);
|
||||||
|
display.setPersistent(false);
|
||||||
|
var description = new BaseIdentification.Label(UUID.randomUUID(), "Base: Alice", UUID.randomUUID(),
|
||||||
|
100.5, 66.25, -99.5, 16);
|
||||||
|
var packets = new NativeBaseLabelPackets().spawn(display, description, 42);
|
||||||
|
assertEquals(2, packets.size(), "A label needs native spawn and complete metadata packets");
|
||||||
|
var spawn = assertInstanceOf(ClientboundAddEntityPacket.class, packets.get(0));
|
||||||
|
var metadata = assertInstanceOf(ClientboundSetEntityDataPacket.class, packets.get(1));
|
||||||
|
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
|
||||||
|
try {
|
||||||
|
ClientboundAddEntityPacket.STREAM_CODEC.encode(buffer, spawn);
|
||||||
|
var decoded = ClientboundAddEntityPacket.STREAM_CODEC.decode(buffer);
|
||||||
|
assertEquals(EntityTypes.TEXT_DISPLAY, decoded.getType());
|
||||||
|
assertEquals(42, decoded.getId());
|
||||||
|
assertEquals(display.getUniqueId(), decoded.getUUID());
|
||||||
|
assertEquals(100.5, decoded.getX());
|
||||||
|
assertEquals(66.25, decoded.getY());
|
||||||
|
assertEquals(-99.5, decoded.getZ());
|
||||||
|
ClientboundSetEntityDataPacket.STREAM_CODEC.encode(buffer, metadata);
|
||||||
|
var decodedMetadata = ClientboundSetEntityDataPacket.STREAM_CODEC.decode(buffer);
|
||||||
|
assertEquals(42, decodedMetadata.id());
|
||||||
|
assertTrue(decodedMetadata.packedItems().stream().anyMatch(value ->
|
||||||
|
value.value() instanceof net.minecraft.network.chat.Component component
|
||||||
|
&& component.getString().equals("Base: Alice")));
|
||||||
|
} finally {
|
||||||
|
buffer.release();
|
||||||
|
}
|
||||||
|
verify(level, never()).addFreshEntity(nativeDisplay);
|
||||||
|
assertThrows(IllegalStateException.class, nativeDisplay::getId, "The entity remains unassigned/unspawned; only the packet has a reserved ID");
|
||||||
|
assertFalse(display.isPersistent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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 NativeRuntime() {}
|
||||||
|
|
||||||
|
static void bootstrap() throws Exception {
|
||||||
|
SharedConstants.tryDetectVersion();
|
||||||
|
Bootstrap.bootStrap();
|
||||||
|
var flags = FeatureFlags.VANILLA_SET;
|
||||||
|
var packs = ServerPacksSource.createVanillaTrustedRepository();
|
||||||
|
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
|
||||||
|
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
|
||||||
|
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||||
|
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||||
|
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||||
|
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||||
|
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||||
|
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||||
|
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||||
|
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||||
|
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||||
|
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||||
|
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||||
|
Class.forName(org.bukkit.Registry.class.getName());
|
||||||
|
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
|
||||||
|
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
|
||||||
|
Util.backgroundExecutor(), Runnable::run).join();
|
||||||
|
datapack.updateComponentsAndStaticRegistryTags();
|
||||||
|
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,58 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.command.PluginCommand;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
import org.bukkit.plugin.PluginManager;
|
||||||
|
import org.bukkit.scheduler.BukkitScheduler;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
class BaseIdentificationWiringTest {
|
||||||
|
@TempDir Path directory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enableRegistersAndSchedulesIdentificationAndDisableClosesIt() {
|
||||||
|
var plugin = mock(SpigotBasePlugin.class);
|
||||||
|
var server = mock(Server.class);
|
||||||
|
var manager = mock(PluginManager.class);
|
||||||
|
var scheduler = mock(BukkitScheduler.class);
|
||||||
|
when(plugin.getServer()).thenReturn(server);
|
||||||
|
when(plugin.getName()).thenReturn("SpigotBase");
|
||||||
|
when(plugin.namespace()).thenReturn("spigotbase");
|
||||||
|
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||||
|
when(plugin.getConfig()).thenReturn(new YamlConfiguration());
|
||||||
|
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||||
|
when(plugin.getCommand(anyString())).thenReturn(mock(PluginCommand.class));
|
||||||
|
when(server.getPluginManager()).thenReturn(manager);
|
||||||
|
when(server.getScheduler()).thenReturn(scheduler);
|
||||||
|
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||||
|
doCallRealMethod().when(plugin).onEnable();
|
||||||
|
doCallRealMethod().when(plugin).onDisable();
|
||||||
|
try (var validator = mockStatic(PluginSettingsValidator.class);
|
||||||
|
var keystones = mockConstruction(PocketBaseKeystoneService.class);
|
||||||
|
var 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());
|
||||||
|
verify(manager, never()).disablePlugin(plugin);
|
||||||
|
var tasks = ArgumentCaptor.forClass(Runnable.class);
|
||||||
|
verify(scheduler, atLeastOnce()).runTaskTimer(eq(plugin), tasks.capture(), anyLong(), anyLong());
|
||||||
|
var identification = tasks.getAllValues().stream().filter(BaseIdentificationController.class::isInstance).toList();
|
||||||
|
assertEquals(1, identification.size(), "Enable must install the actual label controller");
|
||||||
|
verify(manager).registerEvents((BaseIdentificationController) identification.getFirst(), plugin);
|
||||||
|
identification.getFirst().run();
|
||||||
|
plugin.onDisable();
|
||||||
|
assertEquals(1, packets.constructed().size());
|
||||||
|
verify(labels.constructed().getFirst()).forgetAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Display;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.TextDisplay;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class BukkitBaseLabelsTest {
|
||||||
|
private final World world = mock(World.class);
|
||||||
|
private final Player viewer = mock(Player.class);
|
||||||
|
private final TextDisplay display = mock(TextDisplay.class);
|
||||||
|
private final BukkitBaseLabels.Packets packets = mock(BukkitBaseLabels.Packets.class);
|
||||||
|
private final UUID owner = UUID.randomUUID();
|
||||||
|
private final BaseIdentification.Label label;
|
||||||
|
private final Object spawn = new Object(), metadata = new Object(), remove = new Object();
|
||||||
|
private final BukkitBaseLabels labels = new BukkitBaseLabels(packets);
|
||||||
|
|
||||||
|
BukkitBaseLabelsTest() {
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(viewer.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||||
|
when(viewer.getWorld()).thenReturn(world);
|
||||||
|
label = new BaseIdentification.Label(owner, "Base: Alice", worldId, 100.5, 66.25, -99.5, 16);
|
||||||
|
when(world.createEntity(any(Location.class), eq(TextDisplay.class))).thenReturn(display);
|
||||||
|
when(packets.nextId(world)).thenReturn(42, 43, 44);
|
||||||
|
when(packets.spawn(eq(display), any(), anyInt())).thenReturn(List.of(spawn, metadata));
|
||||||
|
when(packets.remove(anyInt())).thenReturn(remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsOnlyPrivateUnspawnedDisplaysAtTheAnchorAndDoesNotRepeatStableFrames() {
|
||||||
|
labels.render(viewer, Map.of(owner, label));
|
||||||
|
labels.render(viewer, Map.of(owner, label));
|
||||||
|
verify(world).createEntity(new Location(world, 100.5, 66.25, -99.5), TextDisplay.class);
|
||||||
|
verify(display).text(Component.text("Base: Alice"));
|
||||||
|
verify(display).setBillboard(Display.Billboard.CENTER);
|
||||||
|
verify(display).setViewRange(16);
|
||||||
|
verify(display).setLineWidth(512); // UUID fallback names must fit on one stacked label line.
|
||||||
|
verify(display).setPersistent(false);
|
||||||
|
verify(packets).nextId(world);
|
||||||
|
verify(packets).spawn(display, label, 42);
|
||||||
|
var order = inOrder(packets);
|
||||||
|
order.verify(packets).send(viewer, spawn);
|
||||||
|
order.verify(packets).send(viewer, metadata);
|
||||||
|
verify(world, atLeastOnce()).getUID();
|
||||||
|
verifyNoMoreInteractions(world); // No spawn, chunk lookup, chunk load or world entity registration.
|
||||||
|
verify(viewer, never()).getLocation(); // Anchor does not follow the visitor.
|
||||||
|
verify(display, never()).getEntityId(); // Unspawned displays have no assigned native ID.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removesOnExitReplacesChangedLabelsAndClearsOnDisable() {
|
||||||
|
labels.render(viewer, Map.of(owner, label));
|
||||||
|
var changed = new BaseIdentification.Label(owner, "Base: Renamed", label.world(), 200.5, 66.25, -99.5, 16);
|
||||||
|
labels.render(viewer, Map.of(owner, changed));
|
||||||
|
verify(packets).remove(42);
|
||||||
|
verify(packets).spawn(display, changed, 43);
|
||||||
|
labels.render(viewer, Map.of());
|
||||||
|
verify(packets).remove(43);
|
||||||
|
labels.render(viewer, Map.of(owner, label));
|
||||||
|
labels.clear(viewer);
|
||||||
|
verify(packets).remove(44);
|
||||||
|
clearInvocations(packets);
|
||||||
|
labels.clear(viewer);
|
||||||
|
verifyNoInteractions(packets);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void worldTransitionAndDisconnectForgetWithoutSendingOldIdsIntoNewConnections() {
|
||||||
|
labels.render(viewer, Map.of(owner, label));
|
||||||
|
var other = mock(World.class);
|
||||||
|
when(other.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(viewer.getWorld()).thenReturn(other);
|
||||||
|
clearInvocations(packets);
|
||||||
|
labels.render(viewer, Map.of(owner, label));
|
||||||
|
verifyNoInteractions(packets);
|
||||||
|
when(viewer.getWorld()).thenReturn(world);
|
||||||
|
labels.render(viewer, Map.of(owner, label));
|
||||||
|
labels.forget(viewer.getUniqueId());
|
||||||
|
clearInvocations(packets);
|
||||||
|
labels.clear(viewer);
|
||||||
|
verifyNoInteractions(packets);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedPartialSpawnIsRemovedBeforeRetryRatherThanLeakingOrDuplicating() {
|
||||||
|
doThrow(new IllegalStateException("closed channel")).doNothing().when(packets).send(viewer, metadata);
|
||||||
|
assertThrows(IllegalStateException.class, () -> labels.render(viewer, Map.of(owner, label)));
|
||||||
|
labels.render(viewer, Map.of(owner, label));
|
||||||
|
var order = inOrder(packets);
|
||||||
|
order.verify(packets).send(viewer, spawn);
|
||||||
|
order.verify(packets).send(viewer, metadata);
|
||||||
|
order.verify(packets).remove(42);
|
||||||
|
order.verify(packets).send(viewer, remove);
|
||||||
|
order.verify(packets).spawn(display, label, 43);
|
||||||
|
order.verify(packets).send(viewer, spawn);
|
||||||
|
order.verify(packets).send(viewer, metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user