From b2942177e062595b5309fff6af398b8465e82012 Mon Sep 17 00:00:00 2001 From: CrebsTheCoder Date: Tue, 10 Nov 2026 13:37:43 +0800 Subject: [PATCH 1/4] Improve packet field support, add Vector2 syntax and update checks --- README.md | 4 +- build.gradle.kts | 2 + .../java/org/crebs/skpackets/Metrics.java | 4 + .../java/org/crebs/skpackets/SkPackets.java | 8 + .../crebs/skpackets/api/general/Vector2.java | 16 + .../skpackets/api/update/ModrinthVersion.java | 41 +++ .../skpackets/api/update/UpdateChecker.java | 141 ++++++++ .../org/crebs/skpackets/api/util/LogUtil.java | 4 + .../api/util/field/BaseFieldRegistry.java | 6 + .../api/util/field/FieldAccessor.java | 14 +- .../skpackets/api/util/field/FieldSchema.java | 103 +++++- .../api/util/field/FieldSections.java | 21 +- .../skpackets/api/util/field/FieldValues.java | 36 +- .../skpackets/elements/general/Functions.java | 30 ++ .../elements/general/GeneralModule.java | 7 + .../skpackets/elements/general/Types.java | 31 +- .../general/expressions/ExprVector2.java | 46 +++ .../expressions/prop/ExprPacketField.java | 34 +- .../prop/ExprVector2Coordinate.java | 31 ++ .../ClientBoundPacketFieldRegistrar.java | 316 ------------------ ...trar.java => NbtPacketFieldRegistrar.java} | 2 +- .../general/field/PacketArguments.java | 156 +++++++++ .../general/field/PacketConstructor.java | 103 ++++++ .../general/field/PacketFieldAdapter.java | 17 + .../general/field/PacketFieldBinding.java | 93 ++++++ .../general/field/PacketFieldRegistrar.java | 113 +++++++ .../general/field/PacketFieldRegistry.java | 12 +- .../general/field/PacketFieldRule.java | 19 ++ .../general/field/PacketFieldRules.java | 61 ++++ .../general/field/PacketFieldSections.java | 121 +++++++ .../elements/general/field/PacketNumbers.java | 36 ++ .../elements/general/field/PacketRecord.java | 7 + .../elements/general/field/PacketRecords.java | 86 +++++ .../general/field/PacketReflection.java | 83 +++++ .../elements/general/field/PacketValue.java | 7 + .../general/field/PacketValueCodec.java | 25 ++ .../general/field/PacketValueCodecs.java | 188 +++++++++++ .../general/field/PacketValueTypes.java | 71 ++++ .../ServerBoundPacketFieldRegistrar.java | 135 -------- .../general/section/SecExprNewPacket.java | 49 +-- src/main/resources/config.yml | 5 + src/main/resources/plugin.yml | 5 + .../field/FieldSchemaDocumentationTest.java | 90 +++++ .../elements/AddonRegistrationTest.java | 56 ++-- .../prop/ExprPacketFieldParserTest.java | 256 ++++++++++++++ .../expressions/prop/ExprPacketFieldTest.java | 71 +++- .../general/field/PacketArgumentsTest.java | 118 +++++++ .../field/PacketCreationCoverageTest.java | 147 ++++++++ .../field/PacketFieldBehaviorTest.java | 123 +++++++ ...t.java => PacketFieldNullabilityTest.java} | 4 +- .../field/PacketFieldSectionsTest.java | 96 ++++++ .../PacketRegistrationIntegrationTest.java | 139 ++++++++ .../general/field/PacketWireTest.java | 149 +++++++++ .../field/PacketWrapperConstructionTest.java | 82 +++++ .../RequiredPacketFieldReadbackTest.java | 228 +++++++++++++ .../general/field/TypedTeamFieldsTest.java | 103 ++++++ .../general/section/SecExprNewPacketTest.java | 11 +- .../support/PacketEventsTestBootstrap.java | 5 + .../SkriptRegistrationTestSupport.java | 50 +++ 59 files changed, 3441 insertions(+), 576 deletions(-) create mode 100644 src/main/java/org/crebs/skpackets/Metrics.java create mode 100644 src/main/java/org/crebs/skpackets/api/general/Vector2.java create mode 100644 src/main/java/org/crebs/skpackets/api/update/ModrinthVersion.java create mode 100644 src/main/java/org/crebs/skpackets/api/update/UpdateChecker.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/Functions.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/expressions/ExprVector2.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/expressions/prop/ExprVector2Coordinate.java delete mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/ClientBoundPacketFieldRegistrar.java rename src/main/java/org/crebs/skpackets/elements/general/field/{SkBeePacketFieldRegistrar.java => NbtPacketFieldRegistrar.java} (98%) create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketArguments.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketConstructor.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldAdapter.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldBinding.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistrar.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRule.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRules.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldSections.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketNumbers.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketRecord.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketRecords.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketReflection.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketValue.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodec.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodecs.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/PacketValueTypes.java delete mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/ServerBoundPacketFieldRegistrar.java create mode 100644 src/main/resources/config.yml create mode 100644 src/test/java/org/crebs/skpackets/api/util/field/FieldSchemaDocumentationTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketFieldParserTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/PacketArgumentsTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/PacketCreationCoverageTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldBehaviorTest.java rename src/test/java/org/crebs/skpackets/elements/general/field/{ServerBoundPacketFieldRegistrarTest.java => PacketFieldNullabilityTest.java} (97%) create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldSectionsTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/PacketRegistrationIntegrationTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/PacketWireTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/PacketWrapperConstructionTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/RequiredPacketFieldReadbackTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/TypedTeamFieldsTest.java create mode 100644 src/test/java/org/crebs/skpackets/support/SkriptRegistrationTestSupport.java diff --git a/README.md b/README.md index a4a801c..04ad26b 100644 --- a/README.md +++ b/README.md @@ -150,11 +150,11 @@ command /change-camera: send "Look at a nearby mob first." to player stop set {_camera} to a new clientbound camera packet: - entity id: protocol id of {_target} + camera id: protocol id of {_target} send packet {_camera} to player wait 5 seconds set {_reset} to a new clientbound camera packet: - entity id: protocol id of player + camera id: protocol id of player send packet {_reset} to player ``` diff --git a/build.gradle.kts b/build.gradle.kts index 96bd873..e4a90e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,8 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.apache.logging.log4j:log4j-api:2.26.1") testImplementation("org.mockito:mockito-core:5.15.2") + testImplementation("io.netty:netty-buffer:4.2.16.Final") + testRuntimeOnly("org.apache.commons:commons-lang3:3.20.0") testImplementation("net.bytebuddy:byte-buddy:1.18.12") testRuntimeOnly("net.bytebuddy:byte-buddy-agent:1.18.12") mockitoAgent("org.mockito:mockito-core:5.15.2") { diff --git a/src/main/java/org/crebs/skpackets/Metrics.java b/src/main/java/org/crebs/skpackets/Metrics.java new file mode 100644 index 0000000..f190925 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/Metrics.java @@ -0,0 +1,4 @@ +package org.crebs.skpackets; + +public class Metrics { +} diff --git a/src/main/java/org/crebs/skpackets/SkPackets.java b/src/main/java/org/crebs/skpackets/SkPackets.java index 51b60a0..ae2d48c 100644 --- a/src/main/java/org/crebs/skpackets/SkPackets.java +++ b/src/main/java/org/crebs/skpackets/SkPackets.java @@ -43,6 +43,7 @@ public PacketTeamManager getPacketTeams() { private ElementRuntime elementRuntime; private Registration registration; + private org.crebs.skpackets.api.update.UpdateChecker updateChecker; public static SkPackets getInstance() { if (instance == null) { @@ -90,10 +91,17 @@ public void onEnable() { new TeamModule(registration)); registration.finalizeRegistration(); ElementCounts.log(registration, getServer().getConsoleSender()); + saveDefaultConfig(); + updateChecker = new org.crebs.skpackets.api.update.UpdateChecker(this); + updateChecker.start(); } @Override public void onDisable() { + if (updateChecker != null) { + updateChecker.close(); + updateChecker = null; + } if (skinRefresh != null) { skinRefresh.close(); skinRefresh = null; diff --git a/src/main/java/org/crebs/skpackets/api/general/Vector2.java b/src/main/java/org/crebs/skpackets/api/general/Vector2.java new file mode 100644 index 0000000..c03c903 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/api/general/Vector2.java @@ -0,0 +1,16 @@ +package org.crebs.skpackets.api.general; + +import org.jspecify.annotations.NonNull; + +public record Vector2(double x, double y) { + public Vector2 { + if (!Double.isFinite(x) || !Double.isFinite(y)) + throw new IllegalArgumentException("Vector2 coordinates must be finite numbers."); + } + + @Override public @NonNull String toString() { return "x: " + format(x) + ", y: " + format(y); } + + private static String format(double coordinate) { + return java.math.BigDecimal.valueOf(coordinate).stripTrailingZeros().toPlainString(); + } +} diff --git a/src/main/java/org/crebs/skpackets/api/update/ModrinthVersion.java b/src/main/java/org/crebs/skpackets/api/update/ModrinthVersion.java new file mode 100644 index 0000000..26ed7b6 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/api/update/ModrinthVersion.java @@ -0,0 +1,41 @@ +package org.crebs.skpackets.api.update; + +import ch.njol.skript.util.Version; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; + +public class ModrinthVersion { + + private final Version updateVersion; + private final String versionId; + private final List supportedVersions = new ArrayList<>(); + + public ModrinthVersion(JsonElement jsonElement) { + JsonObject json = jsonElement.getAsJsonObject(); + this.versionId = json.get("id").getAsString(); + this.updateVersion = new Version(json.get("version_number").getAsString().trim().replaceFirst("^[vV]", "")); + JsonArray gameVersions = json.getAsJsonArray("game_versions"); + gameVersions.forEach(version -> this.supportedVersions.add(new Version(version.getAsString()))); + } + + public Version getUpdateVersion() { + return this.updateVersion; + } + + public String getUpdateLink() { + return "https://modrinth.com/plugin/skpackets/version/" + this.versionId; + } + + public List getSupportedVersions() { + return this.supportedVersions; + } + + public boolean isServerSupported(Version serverVersion) { + return this.supportedVersions.contains(serverVersion); + } + +} diff --git a/src/main/java/org/crebs/skpackets/api/update/UpdateChecker.java b/src/main/java/org/crebs/skpackets/api/update/UpdateChecker.java new file mode 100644 index 0000000..d19389b --- /dev/null +++ b/src/main/java/org/crebs/skpackets/api/update/UpdateChecker.java @@ -0,0 +1,141 @@ +// Inspired by ShaneBeee SkBee implementation of update checking, https://raw.githubusercontent.com/ShaneBeee/SkBee/refs/heads/master/src/main/java/com/shanebeestudios/skbee/api/util/update/UpdateChecker.java + +package org.crebs.skpackets.api.update; + +import ch.njol.skript.util.Version; +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.IllegalPluginAccessException; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.crebs.skpackets.api.util.LogUtil; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public final class UpdateChecker implements Listener, AutoCloseable { + public static final String DOWNLOAD_URL = "https://modrinth.com/plugin/skpackets"; + public static final String PERMISSION = "skpackets.update.check"; + private final JavaPlugin plugin; + private final BukkitScheduler scheduler; + private final String currentVersion; + private final Set notified = new HashSet<>(); + private volatile boolean closed; + private volatile ModrinthVersion availableVersion; + private boolean started; + private boolean notifyOnJoin; + private BukkitTask checkTask; + + public UpdateChecker(JavaPlugin plugin) { + this.plugin = plugin; + this.scheduler = plugin.getServer().getScheduler(); + this.currentVersion = plugin.getPluginMeta().getVersion(); + } + + public void start() { + if (started || closed) return; + started = true; + if (!plugin.getConfig().getBoolean("update-checker.enabled", true)) { + plugin.getLogger().info("Update checker disabled."); + return; + } + notifyOnJoin = plugin.getConfig().getBoolean("update-checker.notify-on-join", true); + if (notifyOnJoin) plugin.getServer().getPluginManager().registerEvents(this, plugin); + checkTask = scheduler.runTaskAsynchronously(plugin, this::check); + } + + private void check() { + if (closed) return; + try { + ModrinthVersion latest = getLatestVersionFromModrinth(); + if (closed) return; + if (latest == null) { + plugin.getLogger().info("No published version was found on Modrinth for the update check."); + return; + } + Version version = latest.getUpdateVersion(); + int comparison = version.compareTo(new Version(currentVersion.trim().replaceFirst("^[vV]", ""))); + if (comparison <= 0) { + plugin.getLogger().info("No update needed (installed: " + currentVersion + ", latest: " + version + ")."); + return; + } + availableVersion = latest; + plugin.getLogger().info("Update available: " + version + " (installed: " + currentVersion + "). Download: " + latest.getUpdateLink()); + if (notifyOnJoin && !closed) { + try { + scheduler.runTask(plugin, () -> { + if (!closed) plugin.getServer().getOnlinePlayers().forEach(this::notifyPlayer); + }); + } catch (IllegalPluginAccessException exception) { + if (!closed) throw exception; + } + } + } catch (IOException | RuntimeException exception) { + if (!closed) plugin.getLogger().warning("Could not check for updates: " + exception.getMessage()); + } + } + + private ModrinthVersion getLatestVersionFromModrinth() throws IOException { + URI uri = URI.create("https://api.modrinth.com/v2/project/skpackets/version?include_changelog=false"); + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(10_000); + connection.setRequestProperty("User-Agent", "skPackets/" + currentVersion + " (" + DOWNLOAD_URL + ")"); + connection.setRequestProperty("Accept", "application/json"); + try { + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + throw new IOException("Modrinth returned HTTP " + connection.getResponseCode()); + } + try (Reader reader = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) { + JsonArray versions = JsonParser.parseReader(reader).getAsJsonArray(); + return versions.isEmpty() ? null : new ModrinthVersion(versions.get(0)); + } + } finally { + connection.disconnect(); + } + } + + @EventHandler public void onJoin(PlayerJoinEvent event) { + if (closed || !notifyOnJoin || availableVersion == null) return; + Player player = event.getPlayer(); + if (!player.hasPermission(PERMISSION)) return; + scheduler.runTaskLater(plugin, () -> notifyPlayer(player), 30L); + } + + @EventHandler public void onQuit(PlayerQuitEvent event) { + notified.remove(event.getPlayer().getUniqueId()); + } + + private void notifyPlayer(Player player) { + ModrinthVersion update = availableVersion; + if (closed || update == null || !player.isOnline() || !player.hasPermission(PERMISSION) + || !notified.add(player.getUniqueId())) return; + LogUtil.sendMessage(player, "Update available: %s (installed: %s)", update.getUpdateVersion(), currentVersion); + String link = update.getUpdateLink(); + LogUtil.sendMessage(player, Component.text("Download: ").append(Component.text(link, NamedTextColor.AQUA) + .clickEvent(ClickEvent.openUrl(link)))); + } + + @Override public void close() { + closed = true; + if (checkTask != null) checkTask.cancel(); + availableVersion = null; + notified.clear(); + } +} diff --git a/src/main/java/org/crebs/skpackets/api/util/LogUtil.java b/src/main/java/org/crebs/skpackets/api/util/LogUtil.java index 83266a4..ae09ca6 100644 --- a/src/main/java/org/crebs/skpackets/api/util/LogUtil.java +++ b/src/main/java/org/crebs/skpackets/api/util/LogUtil.java @@ -42,6 +42,10 @@ public static void sendMessage(CommandSender sender, String format, Object... ob sender.sendMessage(PREFIX.appendSpace().append(Component.text(log))); } + public static void sendMessage(CommandSender sender, Component message) { + sender.sendMessage(PREFIX.appendSpace().append(message)); + } + public static void info(String format, Object... objects) { String log = String.format(format, objects); Bukkit.getConsoleSender().sendMessage(PREFIX.appendSpace().append(Component.text(log))); diff --git a/src/main/java/org/crebs/skpackets/api/util/field/BaseFieldRegistry.java b/src/main/java/org/crebs/skpackets/api/util/field/BaseFieldRegistry.java index c14db0a..716481b 100644 --- a/src/main/java/org/crebs/skpackets/api/util/field/BaseFieldRegistry.java +++ b/src/main/java/org/crebs/skpackets/api/util/field/BaseFieldRegistry.java @@ -93,6 +93,12 @@ public Builder optionalField( return this; } + public Builder nativeType(java.lang.reflect.Type nativeType) { + int last = accessors.size() - 1; + accessors.set(last, accessors.get(last).withNativeType(nativeType)); + return this; + } + public Builder constructor( @Nullable Function, T> constructor) { this.constructor = constructor; diff --git a/src/main/java/org/crebs/skpackets/api/util/field/FieldAccessor.java b/src/main/java/org/crebs/skpackets/api/util/field/FieldAccessor.java index 25dc763..494e356 100644 --- a/src/main/java/org/crebs/skpackets/api/util/field/FieldAccessor.java +++ b/src/main/java/org/crebs/skpackets/api/util/field/FieldAccessor.java @@ -30,8 +30,18 @@ public record FieldAccessor( String[] aliases, Class expectedType, boolean isOptional, - Function getter, - @Nullable BiConsumer setter) { + @Nullable Function getter, + @Nullable BiConsumer setter, + java.lang.reflect.Type nativeType) { + + public FieldAccessor(String name, String[] aliases, Class expectedType, boolean isOptional, + @Nullable Function getter, @Nullable BiConsumer setter) { + this(name, aliases, expectedType, isOptional, getter, setter, expectedType); + } + + public FieldAccessor withNativeType(java.lang.reflect.Type type) { + return new FieldAccessor<>(name, aliases, expectedType, isOptional, getter, setter, type); + } public boolean matches(String input) { if (name.equalsIgnoreCase(input)) { diff --git a/src/main/java/org/crebs/skpackets/api/util/field/FieldSchema.java b/src/main/java/org/crebs/skpackets/api/util/field/FieldSchema.java index 250f884..eb55264 100644 --- a/src/main/java/org/crebs/skpackets/api/util/field/FieldSchema.java +++ b/src/main/java/org/crebs/skpackets/api/util/field/FieldSchema.java @@ -41,12 +41,19 @@ public record FieldSchema( public static String describe( Iterable> schemas, java.util.function.Function name) { - StringBuilder description = new StringBuilder("### Available Packets and their fields\n"); - for (var schema : schemas) { + StringBuilder description = new StringBuilder("### Available Packets and their fields\n\n"); + List> sorted = new ArrayList<>(); + schemas.forEach(sorted::add); + sorted.sort(java.util.Comparator.comparing(schema -> name.apply(schema.type()))); + for (var schema : sorted) { String fields = schema.getReadableFields(); - if (!fields.isEmpty()) { - description.append("* **").append(name.apply(schema.type())) - .append("** fields:\n").append(fields).append("\n"); + description.append("* **").append(name.apply(schema.type())).append("**"); + if (fields.isEmpty()) { + description.append(": no fields.\n\n"); + } else { + description.append(" fields:\n\n
\n View fields\n\n"); + for (String line : fields.split("\n")) description.append(" ").append(line).append("\n"); + description.append("\n
\n\n"); } } return description.toString(); @@ -55,19 +62,85 @@ public static String describe( public String getReadableFields() { List fieldStrings = new ArrayList<>(); for (FieldAccessor accessor : accessors) { - if (accessor.aliases().length > 0) { - fieldStrings.add( - accessor.name() + " (" + String.join(", ", accessor.aliases()) + ")"); - } else { - fieldStrings.add(accessor.name()); + List attributes = new ArrayList<>(); + attributes.add(accessor.isOptional() ? "optional" : "required"); + if (accessor.getter() == null) attributes.add("creation-only"); + String names = accessor.name() + (accessor.aliases().length == 0 ? "" : "|" + String.join("|", accessor.aliases())); + fieldStrings.add("- `" + names + ": " + typeName(accessor.expectedType()) + "` (" + + String.join("; ", attributes) + ")"); + Class valueType = accessor.expectedType().isArray() + ? accessor.expectedType().getComponentType() : accessor.expectedType(); + Class nativeType = org.crebs.skpackets.elements.general.field.PacketArguments.raw( + org.crebs.skpackets.elements.general.field.PacketRecords.elementType(accessor.nativeType())); + boolean textEnum = valueType == String.class && nativeType.isEnum(); + List literals = textEnum ? java.util.Arrays.stream(nativeType.getEnumConstants()) + .map(v -> "\"" + ((Enum)v).name().toLowerCase(java.util.Locale.ROOT).replace('_', ' ') + "\"").toList() + : org.crebs.skpackets.elements.general.field.PacketValueTypes.literalNames(valueType); + if (!literals.isEmpty()) { + fieldStrings.add(" - field values: " + literals.stream() + .map(value -> "`" + value + "`").collect(java.util.stream.Collectors.joining(", "))); + } else if (org.crebs.skpackets.elements.general.field.PacketRecords.shape(accessor.nativeType()) != null) { + var shape = org.crebs.skpackets.elements.general.field.PacketRecords.shape(accessor.nativeType()); + fieldStrings.add(" - Supports a nested " + (accessor.expectedType().isArray() ? "list of sections" : "section") + + ": " + shape.fields().stream().map(f -> f.name() + (f.isOptional() ? " (optional)" : " (required)")).collect(java.util.stream.Collectors.joining(", "))); } } + return String.join("\n", fieldStrings); + } - if (fieldStrings.isEmpty()) { - return ""; - } + private static String typeName(Class type) { + if (type.isArray()) return pluralTypeName(type.getComponentType()); + if (type == Boolean.class || type == boolean.class) return "boolean"; + if (Number.class.isAssignableFrom(type) || type.isPrimitive() && type != char.class && type != void.class) + return "number"; + if (type == String.class || type == Character.class || type == char.class) return "text"; + return switch (type.getName()) { + case "java.lang.Object" -> "object"; + case "java.lang.Class", "ch.njol.skript.classes.ClassInfo" -> "type"; + case "java.util.UUID" -> "uuid"; + case "org.bukkit.util.Vector" -> "vector"; + case "org.crebs.skpackets.api.general.Vector2" -> "vector2"; + case "org.crebs.skpackets.elements.general.field.PacketValue" -> "packet value"; + case "org.bukkit.NamespacedKey" -> "namespaced key"; + case "org.bukkit.Location" -> "location"; + case "org.bukkit.inventory.EquipmentSlot" -> "equipment slot"; + case "org.bukkit.inventory.ItemStack", "ch.njol.skript.aliases.ItemType" -> "item"; + case "org.bukkit.block.data.BlockData" -> "block data"; + case "org.bukkit.GameMode" -> "game mode"; + case "org.bukkit.Difficulty" -> "difficulty"; + case "org.bukkit.SoundCategory" -> "sound category"; + case "org.bukkit.boss.BarStyle" -> "boss bar style"; + case "org.bukkit.event.player.PlayerResourcePackStatusEvent$Status" -> "resource pack state"; + case "net.kyori.adventure.text.Component" -> "text component"; + case "ch.njol.skript.util.Direction" -> "direction"; + case "org.bukkit.inventory.MainHand" -> "main hand"; + case "org.bukkit.entity.Pose" -> "entity pose"; + case "org.skriptlang.skript.bukkit.entity.EntityData" -> "entity type"; + case "org.bukkit.potion.PotionEffectType" -> "potion effect type"; + case "ch.njol.skript.util.Color" -> "color"; + case "org.bukkit.block.sign.Side" -> "sign side"; + case "org.crebs.skpackets.api.entity.Skin" -> "skin"; + case "org.crebs.skpackets.api.entity.metadata.EntityMeta" -> "entity meta"; + case "me.tofaa.entitylib.wrapper.WrapperEntity" -> "packet entity"; + case "com.github.retrooper.packetevents.protocol.player.InteractionHand" -> "interaction hand"; + case "com.github.retrooper.packetevents.protocol.world.blockentity.BlockEntityType" -> "block entity type"; + case "com.github.retrooper.packetevents.wrapper.PacketWrapper" -> "packet"; + case "com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon" -> "packet type"; + default -> org.crebs.skpackets.elements.general.field.PacketValueTypes.label(type); + }; + } - String joined = String.join("`\n - `", fieldStrings); - return " - `" + joined + "`"; + private static String pluralTypeName(Class type) { + String singular = typeName(type); + return switch (singular) { + case "difficulty" -> "difficulties"; + case "sound category" -> "sound categories"; + case "block data", "entity meta" -> singular; + case "number", "boolean", "text", "object", "type", "uuid", "vector", "location", + "equipment slot", "item", "game mode", "main hand", "entity pose", + "entity type", "potion effect type", "color", "sign side", "skin", "packet entity", + "interaction hand", "block entity type", "packet", "packet type" -> singular + "s"; + default -> singular + " values"; + }; } } diff --git a/src/main/java/org/crebs/skpackets/api/util/field/FieldSections.java b/src/main/java/org/crebs/skpackets/api/util/field/FieldSections.java index 55b2981..9789b5d 100644 --- a/src/main/java/org/crebs/skpackets/api/util/field/FieldSections.java +++ b/src/main/java/org/crebs/skpackets/api/util/field/FieldSections.java @@ -61,7 +61,10 @@ public static boolean parse( } Class expected = field.expectedType(); Class baseType = expected.isArray() ? expected.getComponentType() : expected; - Expression converted = expression.getConvertedExpression(baseType); + Expression converted = expected.isArray() && !ch.njol.skript.util.LiteralUtils.hasUnparsedLiteral(expression) + && (expression.getReturnType() == Object.class + || expected.isAssignableFrom(expression.getReturnType())) + ? expression : expression.getConvertedExpression(baseType); if (converted == null) { Skript.error("The value for '" + field.name() + "' must be of type " + expected.getSimpleName() + "."); @@ -91,7 +94,21 @@ public static boolean parse( Object value; if (field.expectedType().isArray() || !expression.isSingle()) { Object[] array = expression.getArray(event); - value = array.length == 0 ? null : array; + if (field.expectedType().isArray()) { + Object input = array.length == 1 && array[0] != null && array[0].getClass().isArray() + ? array[0] : array; + int length = java.lang.reflect.Array.getLength(input); + Object typed = java.lang.reflect.Array.newInstance(field.expectedType().getComponentType(), length); + for (int i = 0; i < length; i++) { + Object element = java.lang.reflect.Array.get(input, i); + Class component = field.expectedType().getComponentType(); + if (element != null && !component.isInstance(element)) + element = org.skriptlang.skript.lang.converter.Converters.convert(element, component); + if (element == null) throw new IllegalArgumentException("Invalid element for packet field '" + field.name() + "'."); + java.lang.reflect.Array.set(typed, i, element); + } + value = typed; + } else value = array; } else { value = expression.getSingle(event); } diff --git a/src/main/java/org/crebs/skpackets/api/util/field/FieldValues.java b/src/main/java/org/crebs/skpackets/api/util/field/FieldValues.java index b10fa5d..1d8923f 100644 --- a/src/main/java/org/crebs/skpackets/api/util/field/FieldValues.java +++ b/src/main/java/org/crebs/skpackets/api/util/field/FieldValues.java @@ -34,6 +34,12 @@ private FieldValues() {} public static Object @Nullable [] read(T[] source, Function> resolver, Class returnType) { + return read(source, resolver, returnType, true); + } + + public static Object @Nullable [] read(T[] source, + Function> resolver, Class returnType, + boolean flattenArrays) { List elements = new ArrayList<>(); for (T entity : source) { @@ -42,7 +48,7 @@ private FieldValues() {} } FieldAccessor accessor = resolver.apply(entity); - if (accessor == null) { + if (accessor == null || accessor.getter() == null) { continue; } @@ -51,13 +57,18 @@ private FieldValues() {} continue; } - if (value.getClass().isArray()) { + if (flattenArrays && value.getClass().isArray()) { int len = Array.getLength(value); int i = 0; while (i < len) { elements.add(Array.get(value, i++)); } } else { + if (value.getClass().isArray() && accessor.expectedType().isArray() && !accessor.expectedType().isInstance(value)) { + Object typed = Array.newInstance(accessor.expectedType().getComponentType(), Array.getLength(value)); + for (int i = 0; i < Array.getLength(value); i++) Array.set(typed, i, Array.get(value, i)); + value = typed; + } elements.add(value); } } @@ -70,9 +81,15 @@ private FieldValues() {} return elements.toArray((Object[]) Array.newInstance(wrappedType, 0)); } - @SuppressWarnings("unchecked") public static void set(T[] source, Function> resolver, Object[] delta, String kind) { + set(source, resolver, delta, kind, Skript::error); + } + + @SuppressWarnings("unchecked") + public static void set(T[] source, + Function> resolver, Object[] delta, String kind, + java.util.function.Consumer reportError) { for (T entity : source) { if (entity == null) { continue; @@ -89,9 +106,12 @@ public static void set(T[] source, if (expected.isArray()) { Class componentType = expected.getComponentType(); - Object typedArray = Array.newInstance(componentType, delta.length); - for (int i = 0; i < delta.length; i++) { - Array.set(typedArray, i, delta[i]); + Object input = delta.length == 1 && delta[0] != null && delta[0].getClass().isArray() + ? delta[0] : delta; + int length = Array.getLength(input); + Object typedArray = Array.newInstance(componentType, length); + for (int i = 0; i < length; i++) { + Array.set(typedArray, i, Array.get(input, i)); } newValue = typedArray; } else { @@ -105,7 +125,7 @@ public static void set(T[] source, boolean isCompatible = expected == Object.class || expected.isInstance(newValue); if (!isCompatible) { - Skript.warning( + reportError.accept( "Cannot set the " + kind + " field '" + accessor.name() + "' to a value of type " @@ -117,7 +137,7 @@ public static void set(T[] source, ((BiConsumer) accessor.setter()).accept(entity, newValue); } catch (IllegalArgumentException exception) { - Skript.error(exception.getMessage()); + reportError.accept(exception.getMessage()); } } } diff --git a/src/main/java/org/crebs/skpackets/elements/general/Functions.java b/src/main/java/org/crebs/skpackets/elements/general/Functions.java new file mode 100644 index 0000000..248e7d6 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/Functions.java @@ -0,0 +1,30 @@ +package org.crebs.skpackets.elements.general; + +import ch.njol.skript.Skript; +import com.github.shanebeee.skr.Registration; +import org.crebs.skpackets.api.general.Vector2; +import org.skriptlang.skript.common.function.DefaultFunction; + +public final class Functions { + + private Functions() {} + + public static void register(Registration reg) { + reg.newFunction(DefaultFunction.builder(reg.getAddon(), "vec2", Vector2.class) + .parameter("x", Number.class).parameter("y", Number.class) + .description("Creates a persistable two-dimensional vector.") + .since("1.0.1").examples("set {saved::offset} to vec2(2, 3)") + .build(args -> createVector2(args.get("x"), args.get("y")))) + .name("General - Vec2").since("1.0.1").register(); + } + + public static Vector2 createVector2(Number x, Number y) { + if (x == null || y == null) return null; + try { + return new Vector2(x.doubleValue(), y.doubleValue()); + } catch (IllegalArgumentException ex) { + Skript.error(ex.getMessage()); + return null; + } + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/GeneralModule.java b/src/main/java/org/crebs/skpackets/elements/general/GeneralModule.java index 136b43f..77783f4 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/GeneralModule.java +++ b/src/main/java/org/crebs/skpackets/elements/general/GeneralModule.java @@ -29,11 +29,14 @@ import org.crebs.skpackets.elements.general.event.EvtNettyPacket; import org.crebs.skpackets.elements.general.event.EvtPacketSendOrReceive; import org.crebs.skpackets.elements.general.expressions.ExprSkinFromValue; +import org.crebs.skpackets.elements.general.expressions.ExprVector2; import org.crebs.skpackets.elements.general.expressions.prop.ExprEntityId; import org.crebs.skpackets.elements.general.expressions.prop.ExprPacketField; import org.crebs.skpackets.elements.general.expressions.prop.ExprPacketPacketType; import org.crebs.skpackets.elements.general.expressions.prop.ExprPlayerSkin; +import org.crebs.skpackets.elements.general.expressions.prop.ExprVector2Coordinate; import org.crebs.skpackets.elements.general.field.PacketFieldRegistry; +import org.crebs.skpackets.elements.general.field.PacketValueTypes; import org.crebs.skpackets.elements.general.section.SecExprNewPacket; import org.skriptlang.skript.addon.SkriptAddon; @@ -49,6 +52,10 @@ public void load(SkriptAddon addon) { PacketFieldRegistry.INSTANCE.registerAll(); register(registration, Types::register, + Functions::register, + ExprVector2::register, + ExprVector2Coordinate::register, + PacketValueTypes::register, CondPacketTypeIsBound::register, EffCancelPacket::register, EffFetchSkin::register, diff --git a/src/main/java/org/crebs/skpackets/elements/general/Types.java b/src/main/java/org/crebs/skpackets/elements/general/Types.java index 8c98e4a..1330ea8 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/Types.java +++ b/src/main/java/org/crebs/skpackets/elements/general/Types.java @@ -37,6 +37,7 @@ import org.bukkit.block.sign.Side; import org.crebs.skpackets.api.entity.Skin; +import org.crebs.skpackets.api.general.Vector2; import org.crebs.skpackets.api.general.packet.PacketTypeRegistry; import org.crebs.skpackets.api.util.DebugUtil; import org.jetbrains.annotations.Nullable; @@ -50,6 +51,20 @@ public class Types { + public static final class Vector2Serializer extends Serializer { + @Override public Fields serialize(Vector2 value) { + Fields fields = new Fields(); + fields.putPrimitive("x", value.x()); fields.putPrimitive("y", value.y()); + return fields; + } + @Override public Vector2 deserialize(Fields fields) throws StreamCorruptedException { + try { return new Vector2(fields.getPrimitive("x", double.class), fields.getPrimitive("y", double.class)); } + catch (IllegalArgumentException ex) { throw new StreamCorruptedException(ex.getMessage()); } + } + @Override public boolean mustSyncDeserialization() { return false; } + @Override protected boolean canBeInstantiated() { return false; } + } + public static class PacketTypeParser extends Parser { @Override public @Nullable PacketTypeCommon parse(String input, ParseContext context) { @@ -90,6 +105,20 @@ public String toVariableNameString(PacketTypeCommon type) { @SuppressWarnings("UnstableApiUsage") public static void register(Registration reg) { + reg.newType(Vector2.class, "vector2") + .user("vector ?2s?").name("General - Vector2") + .description("Immutable two-dimensional coordinates, saved in persistent variables. For packet X/Z pairs, the second coordinate is Z.") + .since("1.0.1") + .parser(new Parser<>() { + @Override public boolean canParse(ParseContext context) { return false; } + @Override public String toString(Vector2 value, int flags) { return value.toString(); } + @Override public String toVariableNameString(Vector2 value) { + return "vector2:" + Double.toHexString(value.x()) + ":" + Double.toHexString(value.y()); + } + }) + .serializer(new Vector2Serializer()).register(); + + reg.newType(PacketWrapper.class, "packet") .user("packet") .name("General - Packet") @@ -248,7 +277,7 @@ protected boolean canBeInstantiated() { """ on serverbound interact entity packet: set {_hand} to packet field hand of event-packet - if {_hand} is "main hand" parsed as interaction hand: + if {_hand} is main hand: send "You interacted using your main hand." to player """) .since("1.0.0") diff --git a/src/main/java/org/crebs/skpackets/elements/general/expressions/ExprVector2.java b/src/main/java/org/crebs/skpackets/elements/general/expressions/ExprVector2.java new file mode 100644 index 0000000..1576056 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/expressions/ExprVector2.java @@ -0,0 +1,46 @@ +package org.crebs.skpackets.elements.general.expressions; + +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.skript.lang.util.SimpleExpression; +import ch.njol.util.Kleenean; +import com.github.shanebeee.skr.Registration; +import org.bukkit.event.Event; +import org.crebs.skpackets.api.general.Vector2; +import org.crebs.skpackets.elements.general.Functions; + +public final class ExprVector2 extends SimpleExpression { + + public static void register(Registration reg) { + reg.newSimpleExpression(ExprVector2.class, Vector2.class, "[a] [new] vector2 %number%,[ ]%number%") + .name("General - Vector2").description("Creates a two-dimensional vector, like vec2(x, y).") + .examples("set {_v} to vector2 2, 3").since("1.0.1").register(); + } + + private Expression x, y; + + @SuppressWarnings("unchecked") + @Override + public boolean init(Expression[] expressions, int pattern, Kleenean delayed, SkriptParser.ParseResult result) { + x = (Expression) expressions[0]; + y = (Expression) expressions[1]; + return true; + } + + @Override + protected Vector2[] get(Event event) { + Vector2 value = Functions.createVector2(x.getSingle(event), y.getSingle(event)); + return value == null ? new Vector2[0] : new Vector2[]{value}; + } + + @Override + public boolean isSingle() { return true; } + + @Override + public Class getReturnType() { return Vector2.class; } + + @Override + public String toString(Event event, boolean debug) { + return "vector2 " + x.toString(event, debug) + ", " + y.toString(event, debug); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketField.java b/src/main/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketField.java index f13c8d8..4df8121 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketField.java +++ b/src/main/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketField.java @@ -51,7 +51,7 @@ public class ExprPacketField extends PropertyExpression { public static void register(Registration reg) { String description = "Gets or sets a field's value from a packet by its name.\n\n" + FieldSchema.describe(PacketFieldRegistry.INSTANCE.getAllSchemas(), - type -> type.getName().toLowerCase(Locale.ENGLISH).replace("_", " ")); + type -> new org.crebs.skpackets.elements.general.Types.PacketTypeParser().toString(type, 0)); reg.newPropertyExpression( ExprPacketField.class, @@ -63,10 +63,10 @@ public static void register(Registration reg) { .examples( """ on clientbound entity metadata: - set {_meta} to packet meta of event-packet + set {_meta} to packet field entity metadata of event-packet set meta glowing state of {_meta} to true - set packet meta of event-packet to {_meta} + set packet field entity metadata of event-packet to {_meta} """) .since("1.0.0") .register(); @@ -75,12 +75,20 @@ public static void register(Registration reg) { private String fieldName; private List, ?>> accessors = List.of(); private Class returnType = Object.class; + private boolean mixedShapes; @SuppressWarnings("unchecked") @Override public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult) { fieldName = parseResult.regexes.getFirst().group().trim(); - accessors = PacketFieldRegistry.INSTANCE.getAllSchemas().stream() + var schemas = PacketFieldRegistry.INSTANCE.getAllSchemas().stream(); + if (exprs[0].getSource() instanceof ch.njol.skript.expressions.base.EventValueExpression + && getParser().isCurrentEvent(PacketSendOrReceiveEvent.class)) { + var data = getParser().getData(PacketSendOrReceiveParserData.class); + if (data != null && data.getPacketType() != null) + schemas = schemas.filter(schema -> schema.type() == data.getPacketType()); + } + accessors = schemas ., ?>>map(schema -> schema.getAccessor(fieldName)) .filter(Objects::nonNull) .toList(); @@ -95,6 +103,10 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye returnType = returnType.isInterface() ? Object.class : returnType.getSuperclass(); } } + boolean hasArrays = accessors.stream().anyMatch(a -> a.expectedType().isArray()); + boolean hasScalars = accessors.stream().anyMatch(a -> !a.expectedType().isArray()); + mixedShapes = hasArrays && hasScalars; + if (mixedShapes) returnType = Object.class; setExpr((Expression) exprs[0]); return true; } @@ -117,7 +129,7 @@ private static Class valueType(FieldAccessor accessor) { @Override protected Object[] get(Event event, PacketWrapper[] source) { - return FieldValues.read(source, this::resolveAccessor, getReturnType()); + return FieldValues.read(source, this::resolveAccessor, getReturnType(), !mixedShapes); } @Override @@ -142,11 +154,11 @@ public Class[] acceptChange(Changer.ChangeMode mode) { return null; } } - return accessors.stream() + var types = accessors.stream() .filter(accessor -> accessor.setter() != null) - .map(ExprPacketField::valueType) - .distinct() - .toArray(Class[]::new); + .map(accessor -> accessor.expectedType().isArray() ? accessor.expectedType() : valueType(accessor)); + if (mixedShapes) types = java.util.stream.Stream.concat(types, java.util.stream.Stream.of(Object.class)); + return types.distinct().toArray(Class[]::new); } @Override @@ -154,12 +166,12 @@ public void change(Event event, Object[] delta, Changer.ChangeMode mode) { if (mode != Changer.ChangeMode.SET || delta == null || delta.length == 0) { return; } - FieldValues.set(getExpr().getArray(event), this::resolveAccessor, delta, "packet"); + FieldValues.set(getExpr().getArray(event), this::resolveAccessor, delta, "packet", this::error); } @Override public boolean isSingle() { - return getExpr().isSingle() && accessors.stream().noneMatch(accessor -> accessor.expectedType().isArray()); + return getExpr().isSingle() && (mixedShapes || accessors.stream().noneMatch(accessor -> accessor.expectedType().isArray())); } @Override diff --git a/src/main/java/org/crebs/skpackets/elements/general/expressions/prop/ExprVector2Coordinate.java b/src/main/java/org/crebs/skpackets/elements/general/expressions/prop/ExprVector2Coordinate.java new file mode 100644 index 0000000..b59c472 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/expressions/prop/ExprVector2Coordinate.java @@ -0,0 +1,31 @@ +package org.crebs.skpackets.elements.general.expressions.prop; + +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.skript.lang.util.SimpleExpression; +import ch.njol.util.Kleenean; +import com.github.shanebeee.skr.Registration; +import org.bukkit.event.Event; +import org.crebs.skpackets.api.general.Vector2; + +public final class ExprVector2Coordinate extends SimpleExpression { + public static void register(Registration reg) { + reg.newSimpleExpression(ExprVector2Coordinate.class, Number.class, + "vector2 x of %vector2%", "vector2 (y|z) of %vector2%") + .name("General - Vector2 Coordinate").description("Reads the first (X) or second (Y, also called Z for packet coordinates) coordinate.") + .since("1.0.1").register(); + } + private Expression vector; + private boolean first; + @SuppressWarnings("unchecked") + @Override public boolean init(Expression[] expressions, int pattern, Kleenean delayed, SkriptParser.ParseResult result) { + vector = (Expression) expressions[0]; first = pattern == 0; return true; + } + @Override protected Number[] get(Event event) { + Vector2 value = vector.getSingle(event); + return value == null ? new Number[0] : new Number[]{first ? value.x() : value.y()}; + } + @Override public boolean isSingle() { return true; } + @Override public Class getReturnType() { return Number.class; } + @Override public String toString(Event event, boolean debug) { return "vector2 " + (first ? "x" : "y") + " of " + vector.toString(event, debug); } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/ClientBoundPacketFieldRegistrar.java b/src/main/java/org/crebs/skpackets/elements/general/field/ClientBoundPacketFieldRegistrar.java deleted file mode 100644 index b131d46..0000000 --- a/src/main/java/org/crebs/skpackets/elements/general/field/ClientBoundPacketFieldRegistrar.java +++ /dev/null @@ -1,316 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2026 3add - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or - * substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING - * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package org.crebs.skpackets.elements.general.field; - -import com.github.retrooper.packetevents.protocol.entity.type.EntityType; -import com.github.retrooper.packetevents.protocol.packettype.PacketType; -import com.github.retrooper.packetevents.wrapper.play.server.*; - -import io.github.retrooper.packetevents.util.SpigotConversionUtil; - -import org.bukkit.block.data.BlockData; -import org.bukkit.block.sign.Side; -import org.bukkit.util.Vector; -import org.crebs.skpackets.api.entity.metadata.EntityMeta; -import org.crebs.skpackets.api.general.EntityTracker; -import org.crebs.skpackets.api.util.ConversionUtil; -import org.crebs.skpackets.api.util.field.FieldRegistrar; - -import java.util.Arrays; - -public class ClientBoundPacketFieldRegistrar implements FieldRegistrar { - - @Override - public void register() { - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Server.BLOCK_BREAK_ANIMATION, WrapperPlayServerBlockBreakAnimation.class) - .requiredField(Number.class, WrapperPlayServerBlockBreakAnimation::getEntityId, - (w, value) -> w.setEntityId(value.intValue()), "entity id") - .requiredField(Vector.class, w -> ConversionUtil.toBukkitVector(w.getBlockPosition()), - (w, value) -> w.setBlockPosition(ConversionUtil.toPeVectorI(value)), "block position", "position", "pos") - .requiredField(Number.class, WrapperPlayServerBlockBreakAnimation::getDestroyStage, - (w, value) -> w.setDestroyStage(value.byteValue()), "destroy stage", "breaking stage", "stage") - .constructor(values -> new WrapperPlayServerBlockBreakAnimation( - values.getRequired("entity id", Number.class).intValue(), - ConversionUtil.toPeVectorI(values.getRequired("block position", Vector.class)), - values.getRequired("destroy stage", Number.class).byteValue())) - .build(); - - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Server.BLOCK_ACTION, WrapperPlayServerBlockAction.class) - .requiredField(Vector.class, w -> ConversionUtil.toBukkitVector(w.getBlockPosition()), - (w, value) -> w.setBlockPosition(ConversionUtil.toPeVectorI(value)), "block position", "position", "pos") - .requiredField(Number.class, WrapperPlayServerBlockAction::getActionId, - (w, value) -> w.setActionId(value.intValue()), "action id") - .requiredField(Number.class, WrapperPlayServerBlockAction::getActionData, - (w, value) -> w.setActionData(value.intValue()), "action data") - .requiredField(Number.class, WrapperPlayServerBlockAction::getBlockTypeId, - (w, value) -> w.setBlockTypeId(value.intValue()), "block type id") - .constructor(values -> new WrapperPlayServerBlockAction( - ConversionUtil.toPeVectorI(values.getRequired("block position", Vector.class)), - values.getRequired("action id", Number.class).intValue(), - values.getRequired("action data", Number.class).intValue(), - values.getRequired("block type id", Number.class).intValue())) - .build(); - - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Server.EFFECT, WrapperPlayServerEffect.class) - .requiredField(Number.class, WrapperPlayServerEffect::getType, - (w, value) -> w.setType(value.intValue()), "effect id") - .requiredField(Vector.class, w -> ConversionUtil.toBukkitVector(w.getPosition()), - (w, value) -> w.setPosition(ConversionUtil.toPeVectorI(value)), "position") - .requiredField(Number.class, WrapperPlayServerEffect::getData, - (w, value) -> w.setData(value.intValue()), "data") - .requiredField(Boolean.class, WrapperPlayServerEffect::isGlobalEvent, - (w, value) -> w.setGlobalEvent(value), "global event") - .constructor(values -> new WrapperPlayServerEffect( - values.getRequired("effect id", Number.class).intValue(), - ConversionUtil.toPeVectorI(values.getRequired("position", Vector.class)), - values.getRequired("data", Number.class).intValue(), - values.getRequired("global event", Boolean.class))) - .build(); - - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Server.HURT_ANIMATION, WrapperPlayServerHurtAnimation.class) - .requiredField(Number.class, WrapperPlayServerHurtAnimation::getEntityId, - (w, value) -> w.setEntityId(value.intValue()), "entity id") - .requiredField(Number.class, WrapperPlayServerHurtAnimation::getYaw, - (w, value) -> w.setYaw(value.floatValue()), "yaw") - .constructor(values -> new WrapperPlayServerHurtAnimation( - values.getRequired("entity id", Number.class).intValue(), - values.getRequired("yaw", Number.class).floatValue())) - .build(); - - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Server.CAMERA, WrapperPlayServerCamera.class) - .requiredField( - Number.class, - WrapperPlayServerCamera::getCameraId, - (w, id) -> w.setCameraId(id.intValue()), - "entity id", - "camera id", - "id") - .constructor( - values -> - new WrapperPlayServerCamera( - values.getRequired("entity id", Number.class).intValue())) - .build(); - - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Server.ENTITY_STATUS, WrapperPlayServerEntityStatus.class) - .requiredField( - Number.class, - WrapperPlayServerEntityStatus::getEntityId, - (w, id) -> w.setEntityId(id.intValue()), - "entity id", - "id") - .requiredField( - Number.class, - WrapperPlayServerEntityStatus::getStatus, - (w, status) -> w.setStatus(status.byteValue()), - "status") - .constructor( - values -> - new WrapperPlayServerEntityStatus( - values.getRequired("entity id", Number.class).intValue(), - values.getRequired("status", Number.class).byteValue())) - .build(); - - PacketFieldRegistry.INSTANCE - .builder( - PacketType.Play.Server.ENTITY_VELOCITY, - WrapperPlayServerEntityVelocity.class) - .requiredField( - Number.class, - WrapperPlayServerEntityVelocity::getEntityId, - (w, id) -> w.setEntityId(id.intValue()), - "entity id", - "id") - .requiredField( - Vector.class, - w -> ConversionUtil.toBukkitVector(w.getVelocity()), - (w, vector) -> w.setVelocity(ConversionUtil.toPeVectorD(vector)), - "velocity vector", - "vector", - "velocity") - .constructor( - values -> - new WrapperPlayServerEntityVelocity( - values.getRequired("entity id", Number.class).intValue(), - ConversionUtil.toPeVectorD( - values.getRequired( - "velocity vector", Vector.class)))) - .build(); - - PacketFieldRegistry.INSTANCE - .builder( - PacketType.Play.Server.GAME_TEST_HIGHLIGHT_POS, - WrapperPlayServerGameTestHighlightPos.class) - .requiredField( - Vector.class, - w -> ConversionUtil.toBukkitVector(w.getAbsolutePos()), - (w, vector) -> w.setAbsolutePos(ConversionUtil.toPeVectorI(vector)), - "absolute position", - "absolute pos", - "abs position", - "abs pos", - "position", - "pos") - .optionalField( - Vector.class, - w -> ConversionUtil.toBukkitVector(w.getRelativePos()), - (w, vector) -> w.setRelativePos(ConversionUtil.toPeVectorI(vector)), - "relative position", - "relative pos", - "rel position", - "rel pos") - .constructor( - values -> - new WrapperPlayServerGameTestHighlightPos( - ConversionUtil.toPeVectorI( - values.getRequired( - "absolute position", Vector.class)), - ConversionUtil.toPeVectorI( - values.getOrElse( - "relative position", Vector.class, new Vector())))) - .build(); - - PacketFieldRegistry.INSTANCE - .builder( - PacketType.Play.Server.ENTITY_METADATA, - WrapperPlayServerEntityMetadata.class) - .requiredField( - Number.class, - WrapperPlayServerEntityMetadata::getEntityId, - (w, id) -> w.setEntityId(id.intValue()), - "entity id", - "id") - .requiredField( - EntityMeta.class, - w -> { - EntityType type = EntityTracker.getType(w.getEntityId()); - if (type == null) { - throw new IllegalStateException( - "Failed to find entity type of entity with id " - + w.getEntityId()); - } - - EntityMeta meta = EntityMeta.createMeta(w.getEntityId(), type); - meta.readPacket(w); - return meta; - }, - (packet, meta) -> packet.setEntityMetadata(meta.entityData()), - "entity metadata", - "entity meta", - "metadata", - "meta") - .constructor( - values -> - new WrapperPlayServerEntityMetadata( - values.getRequired("entity id", Number.class).intValue(), - values.getRequired("entity metadata", EntityMeta.class) - .entityData())) - .build(); - - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Server.BLOCK_CHANGE, WrapperPlayServerBlockChange.class) - .requiredField( - Vector.class, - w -> ConversionUtil.toBukkitVector(w.getBlockPosition()), - (w, vector) -> w.setBlockPosition(ConversionUtil.toPeVectorI(vector)), - "block position", - "block pos", - "position", - "pos") - .requiredField( - BlockData.class, - w -> SpigotConversionUtil.toBukkitBlockData(w.getBlockState()), - (w, blockData) -> - w.setBlockState( - SpigotConversionUtil.fromBukkitBlockData(blockData)), - "block state", - "state", - "block data", - "data") - .constructor( - values -> - new WrapperPlayServerBlockChange( - ConversionUtil.toPeVectorI( - values.getRequired("block position", Vector.class)), - SpigotConversionUtil.fromBukkitBlockData( - values.getRequired( - "block state", BlockData.class)))) - .build(); - - PacketFieldRegistry.INSTANCE - .builder( - PacketType.Play.Server.OPEN_SIGN_EDITOR, - WrapperPlayServerOpenSignEditor.class) - .requiredField( - Vector.class, - w -> ConversionUtil.toBukkitVector(w.getPosition()), - (w, vector) -> w.setPosition(ConversionUtil.toPeVectorI(vector)), - "block position", - "block pos", - "position", - "pos") - .requiredField( - Side.class, - w -> w.isFrontText() ? Side.FRONT : Side.BACK, - (w, side) -> w.setFrontText(side == Side.FRONT), - "sign side", - "side") - .constructor( - values -> - new WrapperPlayServerOpenSignEditor( - ConversionUtil.toPeVectorI( - values.getRequired("block position", Vector.class)), - values.getRequired("sign side", Side.class) == Side.FRONT)) - .build(); - - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Server.CLOSE_WINDOW, WrapperPlayServerCloseWindow.class) - .constructor(k -> new WrapperPlayServerCloseWindow()) - .build(); - - PacketFieldRegistry.INSTANCE - .builder( - PacketType.Play.Server.DESTROY_ENTITIES, - WrapperPlayServerDestroyEntities.class) - .requiredField( - Number[].class, - w -> Arrays.stream(w.getEntityIds()).boxed().toArray(Number[]::new), - (w, ids) -> - w.setEntityIds( - Arrays.stream(ids).mapToInt(Number::intValue).toArray()), - "entity ids", - "ids") - .constructor( - values -> - new WrapperPlayServerDestroyEntities( - Arrays.stream( - values.getRequired( - "entity ids", Number[].class)) - .mapToInt(Number::intValue) - .toArray())) - .build(); - } -} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/SkBeePacketFieldRegistrar.java b/src/main/java/org/crebs/skpackets/elements/general/field/NbtPacketFieldRegistrar.java similarity index 98% rename from src/main/java/org/crebs/skpackets/elements/general/field/SkBeePacketFieldRegistrar.java rename to src/main/java/org/crebs/skpackets/elements/general/field/NbtPacketFieldRegistrar.java index 9aa49e5..93e2557 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/SkBeePacketFieldRegistrar.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/NbtPacketFieldRegistrar.java @@ -30,7 +30,7 @@ import org.crebs.skpackets.api.util.SkBeeConversionUtil; import org.crebs.skpackets.api.util.field.FieldRegistrar; -public class SkBeePacketFieldRegistrar implements FieldRegistrar { +public class NbtPacketFieldRegistrar implements FieldRegistrar { @Override public void register() { diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketArguments.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketArguments.java new file mode 100644 index 0000000..92ce260 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketArguments.java @@ -0,0 +1,156 @@ +package org.crebs.skpackets.elements.general.field; + +import ch.njol.skript.registrations.Classes; +import com.github.retrooper.packetevents.util.Vector2i; +import org.bukkit.util.Vector; + +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public final class PacketArguments { + private static final Map, Class> PUBLIC_TYPES = new ConcurrentHashMap<>(); + + private PacketArguments() {} + + public static Class expressionType(Type type) { + Class raw = raw(type); + if (raw == Optional.class) return expressionType(argument(type, 0)); + if (raw.isArray()) return arrayType(expressionType(raw.getComponentType())); + if (Collection.class.isAssignableFrom(raw)) return arrayType(expressionType(argument(type, 0))); + return PUBLIC_TYPES.computeIfAbsent(raw, PacketArguments::scalarType); + } + + private static Class scalarType(Class type) { + if (type == boolean.class || type == Boolean.class) return Boolean.class; + if (type.isPrimitive() || Number.class.isAssignableFrom(type)) return Number.class; + if (type == String.class) return String.class; + var codec = PacketValueCodecs.find(type); + if (codec != null) return codec.skriptType(); + if (type.getName().startsWith("org.bukkit.") || type.getName().startsWith("ch.njol.skript.") + || type.getName().startsWith("org.skriptlang.skript.")) return type; + if (Classes.getExactClassInfo(type) != null) return type; + if (type.isEnum() && !PacketValueTypes.managedByModule(type)) return String.class; + if (type == Object.class || type == UUID.class || PacketValueTypes.managedByModule(type)) return type; + return PacketValue.class; + } + + public static Class raw(Type type) { + return switch (type) { + case Class value -> value; + case ParameterizedType value -> raw(value.getRawType()); + case WildcardType value -> raw(value.getUpperBounds()[0]); + case TypeVariable value -> raw(value.getBounds()[0]); + case GenericArrayType value -> arrayType(raw(value.getGenericComponentType())); + default -> throw new IllegalArgumentException("Unsupported PacketEvents value type: " + type); + }; + } + + public static Type argument(Type type, int index) { + return type instanceof ParameterizedType value ? value.getActualTypeArguments()[index] : Object.class; + } + + public static Object convert(Object value, Type type, String name) { + if (value instanceof PacketValue wrapped) value = wrapped.value(); + Class target = raw(type); + if (target == Optional.class) { + Object input = value instanceof Optional optional ? optional.orElse(null) : value; + return input == null ? Optional.empty() : Optional.of(convert(input, argument(type, 0), name)); + } + if (value instanceof PacketRecord record) value = PacketRecords.construct(record, type, name); + if (value == null) throw invalid(name, type, null); + if (target.isArray()) return convertArray(value, type, name); + if (Collection.class.isAssignableFrom(target)) return convertCollection(value, type, name); + if (Map.class.isAssignableFrom(target)) return convertMap(value, type, name); + if (value instanceof Number number && (target.isPrimitive() || Number.class.isAssignableFrom(target))) + return PacketNumbers.convert(number, target, name); + if (target == boolean.class && value instanceof Boolean) return value; + if (target.isInstance(value)) return value; + if (target.isEnum() && expressionType(target) == String.class && value instanceof String text) + return convertEnum(text, target, name); + if (target == Vector2i.class && value instanceof Vector vector) + return new Vector2i(vector.getBlockX(), vector.getBlockZ()); + if (target == com.github.retrooper.packetevents.protocol.world.BlockFace.class && value instanceof org.bukkit.block.BlockFace face) + return face == org.bukkit.block.BlockFace.SELF ? com.github.retrooper.packetevents.protocol.world.BlockFace.OTHER + : com.github.retrooper.packetevents.protocol.world.BlockFace.valueOf(face.name()); + var codec = PacketValueCodecs.find(target); + if (codec != null) return codec.write(value, name); + throw invalid(name, type, value); + } + + private static Object convertArray(Object value, Type type, String name) { + Object[] input = elements(value); + Type component = type instanceof GenericArrayType array ? array.getGenericComponentType() : raw(type).getComponentType(); + Object result = Array.newInstance(raw(component), input.length); + for (int i = 0; i < input.length; i++) Array.set(result, i, convert(input[i], component, name + "[" + i + "]")); + return result; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Object convertCollection(Object value, Type type, String name) { + Class target = raw(type); + Type elementType = argument(type, 0); + Collection result; + if (target == EnumSet.class) { + Class element = raw(elementType); + if (!element.isEnum()) throw invalid(name, type, value); + result = EnumSet.noneOf((Class) element); + } else { + result = Set.class.isAssignableFrom(target) ? new LinkedHashSet<>() : new ArrayList<>(); + } + for (Object element : elements(value)) result.add(convert(element, elementType, name + " entry")); + if (!target.isInstance(result)) throw invalid(name, type, value); + return result; + } + + private static Object convertMap(Object value, Type type, String name) { + if (!(value instanceof Map input)) throw invalid(name, type, value); + Map result = new LinkedHashMap<>(); + input.forEach((key, entry) -> result.put(convert(key, argument(type, 0), name + " key"), + convert(entry, argument(type, 1), name + " value"))); + if (!raw(type).isInstance(result)) throw invalid(name, type, value); + return result; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Object convertEnum(String text, Class type, String name) { + try { + return Enum.valueOf((Class) type, text.trim().replace('-', '_').replace(' ', '_').toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + List choices = Arrays.stream(type.getEnumConstants()).map(value -> enumName((Enum) value)).toList(); + throw new IllegalArgumentException("Invalid value for packet field '" + name + "': '" + text + "'. Expected: " + choices); + } + } + + static Object toSkript(Object value) { + if (value == null) return null; + if (value instanceof Optional optional) return toSkript(optional.orElse(null)); + if (value instanceof Collection collection) return collection.stream().map(PacketArguments::toSkript).toArray(); + if (value.getClass().isArray()) return Arrays.stream(elements(value)).map(PacketArguments::toSkript).toArray(); + var codec = PacketValueCodecs.find(value.getClass()); + if (codec != null) return codec.read(value); + if (value instanceof Enum entry && expressionType(entry.getDeclaringClass()) == String.class) return enumName(entry); + return expressionType(value.getClass()) == PacketValue.class ? new PacketValue(value) : value; + } + + private static Object[] elements(Object value) { + if (value instanceof Collection collection) return collection.toArray(); + if (!value.getClass().isArray()) return new Object[]{value}; + Object[] result = new Object[Array.getLength(value)]; + for (int i = 0; i < result.length; i++) result[i] = Array.get(value, i); + return result; + } + + private static Class arrayType(Class component) { + return Array.newInstance(component, 0).getClass(); + } + + private static String enumName(Enum value) { + return value.name().toLowerCase(Locale.ROOT).replace('_', ' '); + } + + static IllegalArgumentException invalid(String name, Type type, Object value) { + return new IllegalArgumentException("Packet field '" + name + "' requires " + type.getTypeName() + + "; received " + (value == null ? "nothing" : value.getClass().getTypeName())); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketConstructor.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketConstructor.java new file mode 100644 index 0000000..043d0dd --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketConstructor.java @@ -0,0 +1,103 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.protocol.player.ClientVersion; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import org.crebs.skpackets.api.util.field.ConstructionContext; + +import java.io.IOException; +import java.lang.classfile.Attributes; +import java.lang.classfile.ClassFile; +import java.lang.classfile.MethodModel; +import java.lang.classfile.TypeAnnotation; +import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Type; +import java.util.*; +import java.util.stream.Stream; + +record PacketConstructor(Constructor method, List arguments) { + private static final Map> PREFERRED = Map.of( + "WrapperPlayClientInteractEntity", List.of("int", "InteractionHand", "Vector3d", "boolean"), + "WrapperPlayServerBlockChange", List.of("Vector3i", "WrappedBlockState"), + "WrapperPlayServerDestroyEntities", List.of("int[]"), + "WrapperPlayServerEntityMetadata", List.of("int", "List"), + "WrapperPlayClientPlayerDigging", List.of("DiggingAction", "Vector3i", "BlockFace", "int"), + "WrapperPlayServerPlayerInfoUpdate", List.of("EnumSet", "List"), + "WrapperPlayServerPlayerInfo", List.of("Action", "List"), + "WrapperPlayServerSpawnLivingEntity", List.of("int", "UUID", "EntityType", "Vector3d", "float", "float", "float", "Vector3d", "List"), + "WrapperPlayServerSpawnPlayer", List.of("int", "UUID", "Vector3d", "float", "float", "List")); + + record Argument(String name, Type type, boolean nullable) { + boolean injected() { + Class raw = PacketArguments.raw(type); + return raw == PacketTypeCommon.class || raw == ClientVersion.class; + } + } + + static PacketConstructor find(Class wrapper) { + Map> constructors = new HashMap<>(); + for (Constructor constructor : wrapper.getConstructors()) { + if (constructor.isAnnotationPresent(Deprecated.class) || Arrays.stream(constructor.getParameterTypes()) + .anyMatch(type -> type.getSimpleName().equals("PacketReceiveEvent") || type.getSimpleName().equals("PacketSendEvent"))) continue; + List preferred = PREFERRED.get(wrapper.getSimpleName()); + if (preferred != null && !Arrays.stream(constructor.getParameterTypes()).map(Class::getSimpleName).toList().equals(preferred)) continue; + constructors.put(MethodType.methodType(void.class, constructor.getParameterTypes()).descriptorString(), constructor); + } + try (var stream = wrapper.getResourceAsStream("/" + wrapper.getName().replace('.', '/') + ".class")) { + if (stream == null) throw new IllegalStateException("Cannot read PacketEvents class " + wrapper.getName()); + PacketConstructor selected = null; + for (MethodModel model : ClassFile.of().parse(stream.readAllBytes()).methods()) { + if (!model.methodName().equalsString("")) continue; + Constructor constructor = constructors.get(model.methodType().stringValue()); + if (constructor == null || selected != null && constructor.getParameterCount() < selected.arguments().size()) continue; + var parameters = constructor.getParameters(); + List arguments = new ArrayList<>(); + for (int i = 0; i < parameters.length; i++) { + if (!parameters[i].isNamePresent()) throw new IllegalStateException("Missing PacketEvents parameter names for " + constructor); + arguments.add(new Argument(parameters[i].getName(), parameters[i].getParameterizedType(), nullable(model, i))); + } + selected = new PacketConstructor(constructor, List.copyOf(arguments)); + } + if (selected != null) return selected; + } catch (IOException exception) { + throw new IllegalStateException("Cannot read PacketEvents constructor for " + wrapper.getName(), exception); + } + throw new IllegalStateException("No supported PacketEvents constructor for " + wrapper.getName()); + } + + @SuppressWarnings("UnstableApiUsage") + PacketWrapper create(ConstructionContext values, Map fields) { + Object[] args = new Object[arguments.size()]; + for (int i = 0; i < arguments.size(); i++) { + Argument argument = arguments.get(i); + Class raw = PacketArguments.raw(argument.type()); + if (raw == PacketTypeCommon.class) args[i] = values.getKey(); + else if (raw == ClientVersion.class) args[i] = PacketEvents.getAPI().getServerManager().getVersion().toClientVersion(); + else args[i] = fields.get(argument.name()).constructorValue(values); + } + try { + PacketWrapper packet = (PacketWrapper) method.newInstance(args); + if (packet.getPacketTypeData().getPacketType() == null) { + packet.getPacketTypeData().setPacketType(values.getKey()); + packet.getPacketTypeData().setNativePacketId(values.getKey().getId(packet.getServerVersion().toClientVersion())); + } + return packet; + } catch (InvocationTargetException exception) { + throw new IllegalArgumentException("Cannot create " + values.getKey().getName() + ": " + exception.getCause().getMessage(), exception.getCause()); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Cannot invoke PacketEvents constructor " + method, exception); + } + } + + private static boolean nullable(MethodModel model, int index) { + return Stream.concat( + model.findAttribute(Attributes.runtimeInvisibleTypeAnnotations()).stream().flatMap(attribute -> attribute.annotations().stream()), + model.findAttribute(Attributes.runtimeVisibleTypeAnnotations()).stream().flatMap(attribute -> attribute.annotations().stream())) + .anyMatch(annotation -> annotation.targetInfo() instanceof TypeAnnotation.FormalParameterTarget target + && target.formalParameterIndex() == index && annotation.targetPath().isEmpty() + && annotation.annotation().className().stringValue().endsWith("/Nullable;")); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldAdapter.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldAdapter.java new file mode 100644 index 0000000..4115093 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldAdapter.java @@ -0,0 +1,17 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.wrapper.PacketWrapper; + +import java.util.function.BiFunction; +import java.util.function.Function; + +record PacketFieldAdapter(Class skriptType, + BiFunction, Object, Object> reader, + Function writer, + boolean acceptsNull) { + + PacketFieldAdapter(Class skriptType, BiFunction, Object, Object> reader, + Function writer) { + this(skriptType, reader, writer, false); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldBinding.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldBinding.java new file mode 100644 index 0000000..1b8d326 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldBinding.java @@ -0,0 +1,93 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import org.crebs.skpackets.api.util.field.BaseFieldRegistry; +import org.crebs.skpackets.api.util.field.ConstructionContext; + +import java.lang.reflect.Array; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.*; + +record PacketFieldBinding( + String name, + List aliases, + Type nativeType, + boolean optional, + boolean nullable, + Method getter, + Method setter, + PacketFieldRule rule) { + + static PacketFieldBinding parameter(Class wrapper, PacketConstructor.Argument argument, List methods) { + var rule = PacketFieldRules.find(wrapper, argument.name()); + String property = PacketReflection.property(wrapper, argument.name()); + Method getter = PacketReflection.getter(methods, property, argument.type()); + Method setter = PacketReflection.setter(methods, property, PacketArguments.raw(argument.type())); + if (getter == null) getter = PacketReflection.getter(methods, argument.name(), argument.type()); + if (setter == null) setter = PacketReflection.setter(methods, argument.name(), PacketArguments.raw(argument.type())); + + LinkedHashSet names = new LinkedHashSet<>(); + names.add(rule.name().isEmpty() ? PacketReflection.fieldName(argument.name()) : rule.name()); + names.add(PacketReflection.fieldName(property)); + if (getter != null) names.add(PacketReflection.fieldName(getter.getName().replaceFirst("^(get|is|has)", ""))); + String name = names.removeFirst(); + boolean optional = argument.nullable() || PacketArguments.raw(argument.type()) == Optional.class || rule.defaultValue() != null; + return new PacketFieldBinding(name, List.copyOf(names), argument.type(), optional, argument.nullable(), getter, setter, rule); + } + + static PacketFieldBinding extra(Method getter, Method setter) { + String name = PacketReflection.fieldName(setter.getName().substring(3)); + return new PacketFieldBinding(name, List.of(), setter.getGenericParameterTypes()[0], true, false, getter, setter, PacketFieldRule.DEFAULT); + } + + Class skriptType() { + return rule.adapter() == null ? PacketArguments.expressionType(nativeType) : rule.adapter().skriptType(); + } + + List names() { + List names = new ArrayList<>(); + names.add(name); + names.addAll(aliases); + return names; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + void register(BaseFieldRegistry.Builder builder) { + java.util.function.Function read = getter == null ? null : (Object packet) -> read((PacketWrapper) packet); + java.util.function.BiConsumer write = setter == null ? null : (Object packet, Object value) -> write((PacketWrapper) packet, value); + if (optional) builder.optionalField(skriptType(), read, write, names().toArray(String[]::new)); + else builder.requiredField(skriptType(), read, write, names().toArray(String[]::new)); + builder.nativeType(nativeType); + } + + Object constructorValue(ConstructionContext values) { + Object value = optional ? values.getOptional(name, Object.class) : values.getRequired(name, Object.class); + if (value == null && rule.defaultValue() != null) value = rule.defaultValue().get(); + return value == null && nullable ? null : encode(value); + } + + Object read(PacketWrapper packet) { + Object value = PacketReflection.invoke(getter, packet); + if (rule.adapter() != null) return rule.adapter().reader().apply(packet, value); + return typedArray(PacketArguments.toSkript(value), skriptType()); + } + + void write(PacketWrapper packet, Object value) { + PacketReflection.invoke(setter, packet, encode(value)); + } + + private Object encode(Object value) { + if (rule.adapter() != null && (rule.adapter().skriptType().isInstance(value) + || value == null && rule.adapter().acceptsNull())) value = rule.adapter().writer().apply(value); + return PacketArguments.convert(value, nativeType, name); + } + + private static Object typedArray(Object value, Class type) { + if (value == null || !type.isArray() || type.isInstance(value)) return value; + Object result = Array.newInstance(type.getComponentType(), Array.getLength(value)); + for (int i = 0; i < Array.getLength(value); i++) Array.set(result, i, typedArray(Array.get(value, i), type.getComponentType())); + return result; + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistrar.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistrar.java new file mode 100644 index 0000000..e3d3003 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistrar.java @@ -0,0 +1,113 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBossBar; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTitle; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; +import org.crebs.skpackets.api.general.packet.PacketTypeRegistry; +import org.crebs.skpackets.api.util.field.FieldRegistrar; + +import java.lang.reflect.Method; +import java.util.*; + +public final class PacketFieldRegistrar implements FieldRegistrar { + @Override + public void register() { + for (PacketTypeCommon type : PacketTypeRegistry.getAllPackets()) { + if (PacketFieldRegistry.INSTANCE.getSchema(type) != null) continue; + Class> wrapper = wrapperClass(type); + if (wrapper == null || wrapper.getSimpleName().equals("WrapperPlayServerChunkDataBulk")) registerRaw(type); + else register(type, wrapper); + } + } + + static Class> wrapperClass(PacketTypeCommon type) { + return type == PacketType.Play.Server.TITLE ? WrapperPlayServerTitle.class : type.getWrapperClass(); + } + + private static > void register(PacketTypeCommon type, Class wrapper) { + PacketConstructor constructor = PacketConstructor.find(wrapper); + List methods = PacketReflection.methods(wrapper); + var builder = PacketFieldRegistry.INSTANCE.builder(type, wrapper); + Map parameters = new LinkedHashMap<>(); + Set names = new HashSet<>(); + + for (var argument : constructor.arguments()) { + if (argument.injected()) continue; + var field = PacketFieldBinding.parameter(wrapper, argument, methods); + parameters.put(argument.name(), field); + field.names().forEach(name -> names.add(PacketReflection.normalized(name))); + field.register(builder); + } + + List extras = extraFields(methods, names); + extras.forEach(field -> field.register(builder)); + builder.constructor(values -> { + T packet = wrapper.cast(constructor.create(values, parameters)); + initializeDefaults(packet); + for (var field : extras) { + Object value = values.getOptional(field.name(), Object.class); + if (value != null) field.write(packet, value); + } + return packet; + }); + builder.build(); + } + + private static List extraFields(List methods, Set names) { + List fields = new ArrayList<>(); + for (Method setter : methods) { + if (!setter.getName().startsWith("set") || setter.getParameterCount() != 1 + || setter.getDeclaringClass() == PacketWrapper.class + || !setter.getDeclaringClass().getName().startsWith("com.github.retrooper.packetevents.wrapper.")) continue; + String property = setter.getName().substring(3); + Method getter = PacketReflection.getter(methods, property, setter.getGenericParameterTypes()[0]); + if (getter == null || getter.getReturnType() != setter.getParameterTypes()[0] + || !names.add(PacketReflection.normalized(property))) continue; + fields.add(PacketFieldBinding.extra(getter, setter)); + } + return List.copyOf(fields); + } + + private static void initializeDefaults(PacketWrapper packet) { + if (packet instanceof WrapperPlayServerBossBar bar) { + bar.setTitle(Component.empty()); + bar.setHealth(1f); + bar.setColor(BossBar.Color.WHITE); + bar.setOverlay(BossBar.Overlay.PROGRESS); + bar.setFlags(EnumSet.noneOf(BossBar.Flag.class)); + } + } + + private static void registerRaw(PacketTypeCommon type) { + PacketFieldRegistry.INSTANCE.builder(type, RawPacket.class) + .requiredField(Number[].class, RawPacket::numbers, RawPacket::setData, "raw data") + .constructor(values -> new RawPacket(type, values.getRequired("raw data", Number[].class))) + .build(); + } + + public static final class RawPacket extends PacketWrapper { + private byte[] data; + + RawPacket(PacketTypeCommon type, Number[] data) { + super(type); + setData(data); + } + + void setData(Number[] values) { + data = (byte[]) PacketArguments.convert(values, byte[].class, "raw data"); + } + + Number[] numbers() { + Number[] result = new Number[data.length]; + for (int i = 0; i < data.length; i++) result[i] = Byte.toUnsignedInt(data[i]); + return result; + } + + @Override public void write() { writeBytes(data); } + @Override public void copy(RawPacket other) { data = other.data.clone(); } + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistry.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistry.java index 0cc4029..dbcb2e5 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistry.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistry.java @@ -38,12 +38,12 @@ public class PacketFieldRegistry extends BaseFieldRegistry(); - this.registrars.addAll( - List.of( - new ClientBoundPacketFieldRegistrar(), - new ServerBoundPacketFieldRegistrar(), - new org.crebs.skpackets.elements.team.TeamPacketFields())); + this.registrars.add(new org.crebs.skpackets.elements.team.TeamPacketFields()); + registerNbt(); + this.registrars.add(new PacketFieldRegistrar()); + } + private void registerNbt() { if (!org.bukkit.Bukkit.getPluginManager().isPluginEnabled("SkBee")) { return; } @@ -51,7 +51,7 @@ private PacketFieldRegistry() { Class nbtApiClass = Class.forName("com.shanebeestudios.skbee.api.nbt.NBTApi"); boolean enabled = (boolean) nbtApiClass.getMethod("isEnabled").invoke(null); if (enabled) { - this.registrars.add(new SkBeePacketFieldRegistrar()); + this.registrars.add(new NbtPacketFieldRegistrar()); LogUtil.info("Hooked into SkBee NBT using NBT-API"); } } catch (ClassNotFoundException ignored) { diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRule.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRule.java new file mode 100644 index 0000000..cc45197 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRule.java @@ -0,0 +1,19 @@ +package org.crebs.skpackets.elements.general.field; + +import java.util.function.Supplier; + +record PacketFieldRule(String name, Supplier defaultValue, PacketFieldAdapter adapter) { + static final PacketFieldRule DEFAULT = new PacketFieldRule("", null, null); + + static PacketFieldRule named(String name) { + return new PacketFieldRule(name, null, null); + } + + PacketFieldRule defaulting(Supplier value) { + return new PacketFieldRule(name, value, adapter); + } + + PacketFieldRule adapting(PacketFieldAdapter value) { + return new PacketFieldRule(name, defaultValue, value); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRules.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRules.java new file mode 100644 index 0000000..bedae33 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRules.java @@ -0,0 +1,61 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.entity.type.EntityType; +import com.github.retrooper.packetevents.wrapper.play.server.*; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientInteractEntity; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPlayerFlying; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientUpdateSign; +import org.bukkit.block.sign.Side; +import org.bukkit.util.Vector; +import org.crebs.skpackets.api.entity.metadata.EntityMeta; +import org.crebs.skpackets.api.general.EntityTracker; + +import java.util.Map; + +import static java.util.Map.entry; +import static org.crebs.skpackets.elements.general.field.PacketFieldRule.named; +import static org.crebs.skpackets.elements.general.field.PacketFieldRule.DEFAULT; + +final class PacketFieldRules { + private static final PacketFieldAdapter SIGN_SIDE = new PacketFieldAdapter(Side.class, + (packet, value) -> Boolean.TRUE.equals(value) ? Side.FRONT : Side.BACK, + value -> value == Side.FRONT); + private static final PacketFieldAdapter ENTITY_META = new PacketFieldAdapter(EntityMeta.class, + (packet, value) -> readMetadata((WrapperPlayServerEntityMetadata) packet), + value -> ((EntityMeta) value).entityData()); + private static final PacketFieldAdapter STATUS_BYTE = new PacketFieldAdapter(Number.class, + (packet, value) -> value, value -> ((Number) value).byteValue()); + + private static final PacketFieldAdapter SNEAKING = new PacketFieldAdapter(Boolean.class, + (packet, value) -> Boolean.TRUE.equals(value instanceof java.util.Optional optional ? optional.orElse(null) : value), + Boolean.TRUE::equals, true); + + private static final Map, Map> RULES = Map.ofEntries( + entry(WrapperPlayServerEntityStatus.class, Map.of("status", DEFAULT.adapting(STATUS_BYTE))), + entry(WrapperPlayServerGameTestHighlightPos.class, Map.of("relativepos", DEFAULT.defaulting(Vector::new))), + entry(WrapperPlayServerEntityMetadata.class, Map.of("entitymetadata", DEFAULT.adapting(ENTITY_META))), + entry(WrapperPlayServerOpenSignEditor.class, Map.of("isfronttext", named("sign side").adapting(SIGN_SIDE))), + entry(WrapperPlayServerCloseWindow.class, Map.of("id", DEFAULT.defaulting(() -> 0))), + entry(WrapperPlayClientUpdateSign.class, Map.of("isfronttext", named("sign side").adapting(SIGN_SIDE))), + entry(WrapperPlayClientInteractEntity.class, Map.of("sneaking", DEFAULT.adapting(SNEAKING))), + entry(WrapperPlayClientPlayerFlying.class, Map.of( + "positionchanged", DEFAULT.defaulting(() -> false), + "rotationchanged", DEFAULT.defaulting(() -> false), + "horizontalcollision", DEFAULT.defaulting(() -> false), + "location", DEFAULT.defaulting(() -> new org.bukkit.Location(null, 0, 0, 0)))) + ); + + private PacketFieldRules() {} + + static PacketFieldRule find(Class wrapper, String parameter) { + return RULES.getOrDefault(wrapper, Map.of()).getOrDefault(PacketReflection.normalized(parameter), PacketFieldRule.DEFAULT); + } + + private static EntityMeta readMetadata(WrapperPlayServerEntityMetadata packet) { + EntityType type = EntityTracker.getType(packet.getEntityId()); + if (type == null) throw new IllegalStateException("Failed to find entity type of entity with id " + packet.getEntityId()); + EntityMeta meta = EntityMeta.createMeta(packet.getEntityId(), type); + meta.readPacket(packet); + return meta; + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldSections.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldSections.java new file mode 100644 index 0000000..516f11f --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldSections.java @@ -0,0 +1,121 @@ +package org.crebs.skpackets.elements.general.field; + +import ch.njol.skript.Skript; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.skript.lang.util.SimpleExpression; +import ch.njol.util.Kleenean; +import com.github.shanebeee.skr.skript.SimpleEntryValidator; +import org.bukkit.event.Event; +import org.crebs.skpackets.api.util.field.*; + +import java.lang.reflect.Type; +import java.util.*; + +public final class PacketFieldSections { + private PacketFieldSections() {} + + public static boolean parse(List> fields, SectionNode node, + Map> expressions, String description) { + var validator = SimpleEntryValidator.builder(); + for (var field : fields) { + List names = new ArrayList<>(List.of(field.name())); + names.addAll(Arrays.asList(field.aliases())); + for (String name : names) { + if (node.get(name) instanceof SectionNode) validator.addOptionalSection(name); + else validator.addOptionalEntry(name, Object.class); + } + } + var entries = validator.build().validate(node); + if (entries == null) return false; + Set nested = new HashSet<>(); + for (var field : fields) { + List names = new ArrayList<>(List.of(field.name())); + names.addAll(Arrays.asList(field.aliases())); + List present = names.stream().filter(entries::hasEntry).toList(); + if (present.size() > 1) return error("Duplicate entries for packet field '" + field.name() + "': " + present); + if (present.isEmpty() || !(node.get(present.getFirst()) instanceof SectionNode section)) continue; + Expression value = nested(field.nativeType(), section, description + "." + field.name()); + if (value == null) return false; + expressions.put(field.name(), value); + nested.add(field.name()); + } + if (!FieldSections.parse(fields, entries, expressions, description, true, nested)) return false; + for (var field : fields) { + Class nativeType = PacketArguments.raw(PacketRecords.elementType(field.nativeType())); + Class publicType = field.expectedType().isArray() ? field.expectedType().getComponentType() : field.expectedType(); + Expression expression = expressions.get(field.name()); + if (!nativeType.isEnum() || publicType != String.class || expression == null) continue; + if (!(expression instanceof ch.njol.skript.lang.Literal) + && !(expression instanceof ch.njol.skript.lang.VariableString text && text.isSimple())) continue; + try { + for (Object value : expression.getArray(null)) PacketArguments.convert(value, nativeType, field.name()); + } catch (IllegalArgumentException exception) { + return error(exception.getMessage()); + } + } + return true; + } + + private static Expression nested(Type type, SectionNode section, String name) { + PacketRecords.Shape shape = PacketRecords.shape(type); + if (shape == null) { + error("Packet field '" + name + "' does not support nested entries. Use a value copied from a packet."); + return null; + } + List>> records = new ArrayList<>(); + if (PacketRecords.isList(type)) { + Set labels = new HashSet<>(); + for (var node : section) { + if (node.isVoid()) continue; + if (!(node instanceof SectionNode child)) { + error("'" + name + "' requires a section per list entry."); return null; + } + if (!labels.add(child.getKey().toLowerCase(Locale.ROOT))) { + error("Duplicate list entry in '" + name + "': " + child.getKey()); return null; + } + Map> record = new LinkedHashMap<>(); + if (!parse(shape.fields(), child, record, name + "." + child.getKey())) return null; + records.add(record); + } + } else { + Map> record = new LinkedHashMap<>(); + if (!parse(shape.fields(), section, record, name)) return null; + records.add(record); + } + return new RecordsExpression(shape, records, !PacketRecords.isList(type)); + } + + private static boolean error(String message) { Skript.error(message); return false; } + + private static final class RecordsExpression extends SimpleExpression { + private final PacketRecords.Shape shape; + private final List>> records; + private final boolean single; + RecordsExpression(PacketRecords.Shape shape, List>> records, boolean single) { + this.shape = shape; this.records = records; this.single = single; + } + @Override public boolean init(Expression[] expressions, int pattern, Kleenean delayed, SkriptParser.ParseResult result) { return true; } + @Override protected PacketValue[] get(Event event) { + List values = new ArrayList<>(); + var schema = new FieldSchema("nested packet value", shape.fields(), context -> { + Map fields = new LinkedHashMap<>(); + for (var field : shape.fields()) { + Object value = context.getOptional(field.name(), Object.class); + if (value != null) fields.put(field.name(), value); + } + return new PacketValue(new PacketRecord(fields)); + }); + for (var record : records) { + Object value = FieldSections.create(schema, "nested packet value", record, event, Map.of()); + if (value == null) throw new IllegalArgumentException("A required nested packet entry evaluated to no value."); + values.add((PacketValue)value); + } + return values.toArray(PacketValue[]::new); + } + @Override public boolean isSingle() { return single; } + @Override public Class getReturnType() { return PacketValue.class; } + @Override public String toString(Event event, boolean debug) { return "nested packet fields"; } + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketNumbers.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketNumbers.java new file mode 100644 index 0000000..fb64cb1 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketNumbers.java @@ -0,0 +1,36 @@ +package org.crebs.skpackets.elements.general.field; + +import java.math.BigDecimal; + +final class PacketNumbers { + private PacketNumbers() {} + + static Object convert(Number value, Class type, String name) { + try { + if (type == float.class || type == Float.class) { + float result = value.floatValue(); + if (!Float.isFinite(result)) throw new ArithmeticException(); + return result; + } + if (type == double.class || type == Double.class) { + double result = value.doubleValue(); + if (!Double.isFinite(result)) throw new ArithmeticException(); + return result; + } + long result = new BigDecimal(value.toString()).longValueExact(); + if (type == long.class || type == Long.class) return result; + if (type == int.class || type == Integer.class) return Math.toIntExact(result); + if (type == short.class || type == Short.class) { + if (result < Short.MIN_VALUE || result > Short.MAX_VALUE) throw new ArithmeticException(); + return (short) result; + } + if (type == byte.class || type == Byte.class) { + if (result < -128 || result > 255) throw new ArithmeticException(); + return (byte) result; + } + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("Invalid or out-of-range number for packet field '" + name + "': " + value, exception); + } + throw PacketArguments.invalid(name, type, value); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketRecord.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketRecord.java new file mode 100644 index 0000000..ec9411e --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketRecord.java @@ -0,0 +1,7 @@ +package org.crebs.skpackets.elements.general.field; + +import java.util.Map; + +public record PacketRecord(Map fields) { + public PacketRecord { fields = Map.copyOf(fields); } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketRecords.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketRecords.java new file mode 100644 index 0000000..a614713 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketRecords.java @@ -0,0 +1,86 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.player.Equipment; +import com.github.retrooper.packetevents.protocol.player.UserProfile; +import com.github.retrooper.packetevents.protocol.recipe.data.MerchantOffer; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate.PlayerInfo; +import org.crebs.skpackets.api.util.field.FieldAccessor; + +import java.lang.reflect.Type; +import java.util.*; +import java.util.function.Function; + +public final class PacketRecords { + private PacketRecords() {} + public record Shape(List> fields, Map defaults, + Function, Object> factory) {} + private static final Map, Shape> SHAPES = new HashMap<>(); + static { + define(Equipment.class, List.of( + field("slot", com.github.retrooper.packetevents.protocol.player.EquipmentSlot.class, false), + field("item", com.github.retrooper.packetevents.protocol.item.ItemStack.class, false)), Map.of(), + v -> new Equipment((com.github.retrooper.packetevents.protocol.player.EquipmentSlot)v.get("slot"), + (com.github.retrooper.packetevents.protocol.item.ItemStack)v.get("item"))); + define(UserProfile.class, List.of(field("uuid", UUID.class, false), field("name", String.class, false)), Map.of(), + v -> new UserProfile((UUID)v.get("uuid"), (String)v.get("name"))); + define(PlayerInfo.class, List.of( + field("profile", UserProfile.class, false), field("listed", boolean.class, true), + field("latency", int.class, true), field("game mode", com.github.retrooper.packetevents.protocol.player.GameMode.class, true), + field("display name", net.kyori.adventure.text.Component.class, true), field("list order", int.class, true), + field("show hat", boolean.class, true)), + Map.of("listed", true, "latency", 0, "game mode", org.bukkit.GameMode.SURVIVAL, "list order", 0, "show hat", true), + v -> new PlayerInfo((UserProfile)v.get("profile"), (boolean)v.get("listed"), (int)v.get("latency"), + (com.github.retrooper.packetevents.protocol.player.GameMode)v.get("game mode"), + (net.kyori.adventure.text.Component)v.get("display name"), null, (int)v.get("list order"), (boolean)v.get("show hat"))); + define(MerchantOffer.class, List.of( + field("input", com.github.retrooper.packetevents.protocol.item.ItemStack.class, false), + field("second input", com.github.retrooper.packetevents.protocol.item.ItemStack.class, true), + field("output", com.github.retrooper.packetevents.protocol.item.ItemStack.class, false), + field("uses", int.class, true), field("max uses", int.class, true), field("xp", int.class, true), + field("special price", int.class, true), field("price multiplier", float.class, true), field("demand", int.class, true)), + Map.of("uses", 0, "max uses", 12, "xp", 0, "special price", 0, "price multiplier", 0f, "demand", 0), + v -> MerchantOffer.of((com.github.retrooper.packetevents.protocol.item.ItemStack)v.get("input"), + (com.github.retrooper.packetevents.protocol.item.ItemStack)v.get("second input"), + (com.github.retrooper.packetevents.protocol.item.ItemStack)v.get("output"), + (int)v.get("uses"), (int)v.get("max uses"), (int)v.get("xp"), (int)v.get("special price"), + (float)v.get("price multiplier"), (int)v.get("demand"))); + } + private static void define(Class type, List> fields, Map defaults, + Function, Object> factory) { + SHAPES.put(type, new Shape(List.copyOf(fields), Map.copyOf(defaults), factory)); + } + @SuppressWarnings({"rawtypes", "unchecked"}) + private static FieldAccessor field(String name, Type nativeType, boolean optional) { + return new FieldAccessor(name, new String[0], PacketArguments.expressionType(nativeType), optional, null, null, nativeType); + } + public static Type elementType(Type type) { + Class raw = PacketArguments.raw(type); + if (raw == Optional.class) return elementType(PacketArguments.argument(type, 0)); + if (raw.isArray()) return raw.getComponentType(); + if (Collection.class.isAssignableFrom(raw)) return PacketArguments.argument(type, 0); + return type; + } + public static boolean isList(Type type) { + Class raw = PacketArguments.raw(type); + if (raw == Optional.class) return isList(PacketArguments.argument(type, 0)); + return raw.isArray() || Collection.class.isAssignableFrom(raw); + } + public static Shape shape(Type type) { return SHAPES.get(PacketArguments.raw(elementType(type))); } + public static Object construct(PacketRecord record, Type type, String name) { + Shape shape = SHAPES.get(PacketArguments.raw(type)); + if (shape == null) throw new IllegalArgumentException("Packet field '" + name + "' does not support nested entries."); + Map values = new HashMap<>(shape.defaults()); + for (String key : record.fields().keySet()) { + if (shape.fields().stream().noneMatch(f -> f.name().equals(key))) + throw new IllegalArgumentException("Unknown nested packet field '" + name + "." + key + "'."); + } + values.putAll(record.fields()); + Map converted = new HashMap<>(); + for (var field : shape.fields()) { + Object value = values.get(field.name()); + if (value == null && !field.isOptional()) throw new IllegalArgumentException("Missing packet field '" + name + "." + field.name() + "'."); + if (value != null) converted.put(field.name(), PacketArguments.convert(value, field.nativeType(), name + "." + field.name())); + } + return shape.factory().apply(converted); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketReflection.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketReflection.java new file mode 100644 index 0000000..c5655fb --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketReflection.java @@ -0,0 +1,83 @@ +package org.crebs.skpackets.elements.general.field; + +import java.lang.reflect.*; +import java.util.*; + +final class PacketReflection { + private PacketReflection() {} + + static List methods(Class wrapper) { + return Arrays.stream(wrapper.getMethods()) + .filter(method -> !method.isBridge() && !method.isSynthetic()) + .sorted(Comparator.comparing(Method::toGenericString)).toList(); + } + + static String fieldName(String name) { + return name.replaceAll("([A-Z]{2,})s$", "$1S").replaceAll("([A-Z]+)([A-Z][a-z])", "$1 $2") + .replaceAll("([a-z0-9])([A-Z])", "$1 $2").toLowerCase(Locale.ROOT); + } + + static String normalized(String name) { + return name.replace("_", "").replace(" ", "").toLowerCase(Locale.ROOT); + } + + static String property(Class wrapper, String parameter) { + return switch (wrapper.getSimpleName() + ":" + parameter) { + case "WrapperPlayClientAnimation:interactionHand", "WrapperPlayClientPlayerBlockPlacement:interactionHand" -> "hand"; + case "WrapperPlayClientGenerateStructure:keepJigsaws" -> "keepingJigsaws"; + case "WrapperPlayClientPlayerAbilities:godMode", "WrapperPlayServerPlayerAbilities:godMode" -> "inGodMode"; + case "WrapperPlayClientPlayerAbilities:creativeMode", "WrapperPlayServerPlayerAbilities:creativeMode" -> "inCreativeMode"; + case "WrapperPlayClientSettings:visibility" -> "chatVisibility"; + case "WrapperPlayClientSettings:hand" -> "mainHand"; + case "WrapperPlayClientSettings:allowServerListings", "WrapperConfigClientSettings:allowServerListings" -> "serverListingAllowed"; + case "WrapperPlayClientSpectate:uuid" -> "targetUUID"; + case "WrapperPlayServerBlockChange:state" -> "blockState"; + case "WrapperPlayServerOpenSignEditor:isFrontText", "WrapperPlayClientUpdateSign:isFrontText" -> "frontText"; + case "WrapperPlayServerBlockAction:actionParam" -> "actionData"; + case "WrapperPlayServerBlockEntityData:type" -> "blockEntityType"; + case "WrapperPlayServerParticle:maxSpeed" -> "maxSpeedVec"; + case "WrapperPlayServerCloseWindow:id" -> "windowId"; + case "WrapperPlayServerEntityEffect:amplifier" -> "effectAmplifier"; + case "WrapperPlayServerEntityEffect:duration" -> "effectDurationTicks"; + case "WrapperPlayServerExplosion:smallParticle" -> "smallExplosionParticles"; + case "WrapperPlayServerExplosion:playerMotion" -> "knockback"; + case "WrapperPlayServerJoinGame:enableRespawnScreen" -> "respawnScreenEnabled"; + case "WrapperPlayServerMultiBlockChange:blockData" -> "blocks"; + case "WrapperPlayServerPlayerInfo:playerData" -> "playerDataList"; + case "WrapperPlayServerPlayerPositionAndLook:flags" -> "relativeMask"; + case "WrapperPlayServerTimeUpdate:gameTime" -> "worldAge"; + case "WrapperPlayServerWindowProperty:windowId" -> "containerId"; + default -> parameter; + }; + } + + static Method getter(List methods, String name, Type expectedType) { + Class expected = PacketArguments.expressionType(expectedType); + return methods.stream().filter(m -> m.getParameterCount() == 0) + .filter(m -> m.getName().startsWith("get") || m.getName().startsWith("is") || m.getName().startsWith("has")) + .filter(m -> normalized(m.getName().replaceFirst("^(get|is|has)", "")).equals(normalized(name)) + || normalized(m.getName()).equals(normalized(name))) + .filter(m -> PacketArguments.expressionType(m.getGenericReturnType()) == expected) + .findFirst().orElse(null); + } + + static Method setter(List methods, String name, Class parameter) { + return methods.stream().filter(m -> m.getParameterCount() == 1) + .filter(m -> m.getName().startsWith("set")) + .filter(m -> normalized(m.getName().substring(3)).equals(normalized(name))) + .filter(m -> com.google.common.primitives.Primitives.wrap(m.getParameterTypes()[0]) + == com.google.common.primitives.Primitives.wrap(parameter)) + .findFirst().orElse(null); + } + + static Object invoke(Method method, Object target, Object... args) { + try { + return method.invoke(target, args); + } catch (InvocationTargetException e) { + throw new IllegalArgumentException("PacketEvents field access failed: " + method.getName(), e.getCause()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot access PacketEvents method " + method, e); + } + } + +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketValue.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValue.java new file mode 100644 index 0000000..540a4db --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValue.java @@ -0,0 +1,7 @@ +package org.crebs.skpackets.elements.general.field; + +import java.util.Objects; + +public record PacketValue(Object value) { + public PacketValue { Objects.requireNonNull(value); } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodec.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodec.java new file mode 100644 index 0000000..f1e45bb --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodec.java @@ -0,0 +1,25 @@ +package org.crebs.skpackets.elements.general.field; + +import java.util.function.BiFunction; +import java.util.function.Function; + +record PacketValueCodec( + Class

packetType, + Class skriptType, + Function reader, + BiFunction writer) { + + static PacketValueCodec of(Class

packetType, Class skriptType, + Function reader, Function writer) { + return new PacketValueCodec<>(packetType, skriptType, reader, (value, name) -> writer.apply(value)); + } + + Object read(Object value) { + return reader.apply(packetType.cast(value)); + } + + Object write(Object value, String name) { + if (!skriptType.isInstance(value)) throw PacketArguments.invalid(name, packetType, value); + return writer.apply(skriptType.cast(value), name); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodecs.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodecs.java new file mode 100644 index 0000000..78b27e4 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodecs.java @@ -0,0 +1,188 @@ +package org.crebs.skpackets.elements.general.field; + +import ch.njol.skript.aliases.ItemType; +import ch.njol.skript.util.Color; +import ch.njol.skript.util.ColorRGB; +import ch.njol.skript.util.Direction; +import com.github.retrooper.packetevents.protocol.entity.pose.EntityPose; +import com.github.retrooper.packetevents.protocol.item.ItemStack; +import com.github.retrooper.packetevents.protocol.player.EquipmentSlot; +import com.github.retrooper.packetevents.protocol.player.GameMode; +import com.github.retrooper.packetevents.protocol.player.HumanoidArm; +import com.github.retrooper.packetevents.protocol.potion.PotionType; +import com.github.retrooper.packetevents.protocol.sound.Sound; +import com.github.retrooper.packetevents.protocol.sound.SoundCategory; +import com.github.retrooper.packetevents.protocol.sound.Sounds; +import com.github.retrooper.packetevents.protocol.world.BlockFace; +import com.github.retrooper.packetevents.protocol.world.Difficulty; +import com.github.retrooper.packetevents.protocol.world.states.WrappedBlockState; +import com.github.retrooper.packetevents.resources.ResourceLocation; +import com.github.retrooper.packetevents.util.Vector2i; +import com.github.retrooper.packetevents.util.Vector3d; +import com.github.retrooper.packetevents.util.Vector3f; +import com.github.retrooper.packetevents.util.Vector3i; +import com.github.retrooper.packetevents.wrapper.configuration.client.WrapperConfigClientResourcePackStatus; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientResourcePackStatus; +import io.github.retrooper.packetevents.util.SpigotConversionUtil; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import org.bukkit.NamespacedKey; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.event.player.PlayerResourcePackStatusEvent.Status; +import org.bukkit.util.Vector; +import org.crebs.skpackets.api.general.Vector2; +import org.crebs.skpackets.api.simple.FakeEquipmentManager; +import org.crebs.skpackets.api.util.ConversionUtil; +import org.skriptlang.skript.bukkit.bossbar.BossBarUtils; +import org.skriptlang.skript.bukkit.entity.EntityData; +import org.skriptlang.skript.bukkit.entity.EntityUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.crebs.skpackets.elements.general.field.PacketValueCodec.of; + +final class PacketValueCodecs { + private static final Map, PacketValueCodec> CODECS = codecs(); + + private PacketValueCodecs() {} + + private static Map, PacketValueCodec> codecs() { + List> values = List.of( + new PacketValueCodec<>(Vector2i.class, Vector2.class, + value -> new Vector2(value.getX(), value.getZ()), + (value, name) -> new Vector2i((int) PacketNumbers.convert(value.x(), int.class, name + " x"), + (int) PacketNumbers.convert(value.y(), int.class, name + " z"))), + of(Vector3i.class, Vector.class, + value -> new Vector(value.getX(), value.getY(), value.getZ()), ConversionUtil::toPeVectorI), + of(Vector3f.class, Vector.class, + value -> new Vector(value.getX(), value.getY(), value.getZ()), ConversionUtil::toPeVectorF), + of(Vector3d.class, Vector.class, + value -> new Vector(value.getX(), value.getY(), value.getZ()), + value -> new Vector3d(value.getX(), value.getY(), value.getZ())), + of(com.github.retrooper.packetevents.protocol.world.Location.class, org.bukkit.Location.class, + ConversionUtil::toBukkitLocation, ConversionUtil::toPeLocation), + of(ItemStack.class, org.bukkit.inventory.ItemStack.class, + SpigotConversionUtil::toBukkitItemStack, SpigotConversionUtil::fromBukkitItemStack), + of(WrappedBlockState.class, org.bukkit.block.data.BlockData.class, + SpigotConversionUtil::toBukkitBlockData, SpigotConversionUtil::fromBukkitBlockData), + of(EquipmentSlot.class, org.bukkit.inventory.EquipmentSlot.class, + PacketValueCodecs::equipmentSlot, FakeEquipmentManager::packetSlot), + of(GameMode.class, org.bukkit.GameMode.class, + SpigotConversionUtil::toBukkitGameMode, SpigotConversionUtil::fromBukkitGameMode), + of(Difficulty.class, org.bukkit.Difficulty.class, + value -> org.bukkit.Difficulty.valueOf(value.name()), value -> Difficulty.valueOf(value.name())), + of(BlockFace.class, Direction.class, + value -> new Direction(value == BlockFace.OTHER ? org.bukkit.block.BlockFace.SELF + : org.bukkit.block.BlockFace.valueOf(value.name()), 1), + value -> blockFace(value)), + of(com.github.retrooper.packetevents.protocol.world.Direction.class, Direction.class, + value -> new Direction(org.bukkit.block.BlockFace.valueOf(value.name()), 1), + value -> com.github.retrooper.packetevents.protocol.world.Direction.valueOf(absoluteFace(value).name())), + of(com.github.retrooper.packetevents.protocol.entity.type.EntityType.class, EntityData.class, + value -> EntityUtils.toSkriptEntityData(SpigotConversionUtil.toBukkitEntityType(value)), + value -> SpigotConversionUtil.fromBukkitEntityType(EntityUtils.toBukkitEntityType(value))), + of(PotionType.class, org.bukkit.potion.PotionEffectType.class, + SpigotConversionUtil::toBukkitPotionEffectType, SpigotConversionUtil::fromBukkitPotionEffectType), + of(com.github.retrooper.packetevents.protocol.item.type.ItemType.class, ItemType.class, + value -> new ItemType(SpigotConversionUtil.toBukkitItemMaterial(value)), + value -> SpigotConversionUtil.fromBukkitItemMaterial(value.getRandom().getType())), + of(SoundCategory.class, org.bukkit.SoundCategory.class, + PacketValueCodecs::soundCategory, PacketValueCodecs::soundCategory), + of(HumanoidArm.class, org.bukkit.inventory.MainHand.class, + SpigotConversionUtil::toBukkitHand, value -> HumanoidArm.valueOf(value.name())), + of(EntityPose.class, org.bukkit.entity.Pose.class, + SpigotConversionUtil::toBukkitPose, SpigotConversionUtil::fromBukkitPose), + of(ResourceLocation.class, NamespacedKey.class, + value -> NamespacedKey.fromString(value.toString()), value -> new ResourceLocation(value.toString())), + of(BossBar.Color.class, Color.class, + value -> BossBarUtils.rgbFromBarColor(BarColor.valueOf(value.name())), PacketValueCodecs::bossBarColor), + of(BossBar.Overlay.class, BarStyle.class, + value -> BarStyle.valueOf(value == BossBar.Overlay.PROGRESS ? "SOLID" : value.name().replace("NOTCHED_", "SEGMENTED_")), + value -> BossBar.Overlay.valueOf(value == BarStyle.SOLID ? "PROGRESS" : value.name().replace("SEGMENTED_", "NOTCHED_"))), + of(WrapperPlayClientResourcePackStatus.Result.class, Status.class, + value -> Status.valueOf(value.name()), value -> WrapperPlayClientResourcePackStatus.Result.valueOf(value.name())), + of(WrapperConfigClientResourcePackStatus.Result.class, Status.class, + value -> Status.valueOf(value.name()), value -> WrapperConfigClientResourcePackStatus.Result.valueOf(value.name())), + of(TextColor.class, Color.class, + value -> ColorRGB.fromRGB(value.red(), value.green(), value.blue()), PacketValueCodecs::textColor), + of(NamedTextColor.class, Color.class, + value -> ColorRGB.fromRGB(value.red(), value.green(), value.blue()), value -> NamedTextColor.nearestTo(textColor(value))), + of(Component.class, Component.class, value -> value, value -> value), + of(Sound.class, String.class, value -> value.getSoundId().toString(), PacketValueCodecs::sound) + ); + Map, PacketValueCodec> result = new LinkedHashMap<>(); + for (var codec : values) result.put(codec.packetType(), codec); + return java.util.Collections.unmodifiableMap(result); + } + + static PacketValueCodec find(Class type) { + var exact = CODECS.get(type); + if (exact != null) return exact; + for (var codec : CODECS.values()) { + if (codec.packetType().isAssignableFrom(type)) return codec; + } + return null; + } + + private static org.bukkit.inventory.EquipmentSlot equipmentSlot(EquipmentSlot value) { + return switch (value) { + case MAIN_HAND -> org.bukkit.inventory.EquipmentSlot.HAND; + case OFF_HAND -> org.bukkit.inventory.EquipmentSlot.OFF_HAND; + case BOOTS -> org.bukkit.inventory.EquipmentSlot.FEET; + case LEGGINGS -> org.bukkit.inventory.EquipmentSlot.LEGS; + case CHEST_PLATE -> org.bukkit.inventory.EquipmentSlot.CHEST; + case HELMET -> org.bukkit.inventory.EquipmentSlot.HEAD; + case BODY -> org.bukkit.inventory.EquipmentSlot.BODY; + case SADDLE -> org.bukkit.inventory.EquipmentSlot.SADDLE; + }; + } + + private static org.bukkit.SoundCategory soundCategory(SoundCategory value) { + return org.bukkit.SoundCategory.valueOf(switch (value) { + case RECORD -> "RECORDS"; + case BLOCK -> "BLOCKS"; + case PLAYER -> "PLAYERS"; + default -> value.name(); + }); + } + + private static SoundCategory soundCategory(org.bukkit.SoundCategory value) { + return SoundCategory.valueOf(switch (value) { + case RECORDS -> "RECORD"; + case BLOCKS -> "BLOCK"; + case PLAYERS -> "PLAYER"; + default -> value.name(); + }); + } + + private static org.bukkit.block.BlockFace absoluteFace(Direction value) { + if (value.isRelative()) throw new IllegalArgumentException("Packet directions must be absolute, such as north or up."); + Vector vector = value.getDirection(); + if (vector.lengthSquared() == 0) throw new IllegalArgumentException("A packet direction cannot be zero."); + return Direction.toNearestCartesianBlockFace(vector); + } + + private static BlockFace blockFace(Direction value) { + if (!value.isRelative() && value.getDirection().lengthSquared() == 0) return BlockFace.OTHER; + return BlockFace.valueOf(absoluteFace(value).name()); + } + + private static BossBar.Color bossBarColor(Color value) { + BarColor nearest = BossBarUtils.nearest(value); + return BossBar.Color.valueOf(nearest == null ? "WHITE" : nearest.name()); + } + + private static TextColor textColor(Color value) { + return TextColor.color(value.getRed(), value.getGreen(), value.getBlue()); + } + + private static Sound sound(String value) { + if (NamespacedKey.fromString(value) == null) throw new IllegalArgumentException("Invalid sound key: " + value); + return Sounds.getByNameOrCreate(value); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueTypes.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueTypes.java new file mode 100644 index 0000000..7456b9b --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueTypes.java @@ -0,0 +1,71 @@ +package org.crebs.skpackets.elements.general.field; + +import ch.njol.skript.classes.Parser; +import ch.njol.skript.lang.ParseContext; +import ch.njol.skript.registrations.Classes; +import com.github.shanebeee.skr.Registration; + +import java.util.*; + +public final class PacketValueTypes { + private PacketValueTypes() {} + + public static void register(Registration registration) { + registration.newType(PacketValue.class, "packetvalue") + .user("packet ?values?").name("General - Packet Value") + .description("A complex packet value copied from an intercepted packet or built with a nested packet field section. Runtime-only; not saved across restarts.") + .since("1.0.1") + .parser(new Parser() { + @Override public boolean canParse(ParseContext context) { return false; } + @Override public String toString(PacketValue value, int flags) { + return "packet value (" + value.value().getClass().getSimpleName() + ")"; + } + @Override public String toVariableNameString(PacketValue value) { + return "packetvalue:" + value.value().getClass().getName() + ":" + System.identityHashCode(value.value()); + } + }).register(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public static List literalNames(Class type) { + if (!type.isEnum()) return List.of(); + var info = Classes.getExactClassInfo(type); + Parser parser = info == null ? null : info.getParser(); + List names = new ArrayList<>(); + for (Object constant : type.getEnumConstants()) { + if (type == org.bukkit.block.BlockFace.class && !Set.of(org.bukkit.block.BlockFace.DOWN, + org.bukkit.block.BlockFace.UP, org.bukkit.block.BlockFace.NORTH, org.bukkit.block.BlockFace.SOUTH, + org.bukkit.block.BlockFace.WEST, org.bukkit.block.BlockFace.EAST, org.bukkit.block.BlockFace.SELF).contains(constant)) continue; + String name; + if (parser != null) name = parser.toString(constant, 0).replace('_', ' '); + else { + name = ((Enum) constant).name().toLowerCase(Locale.ROOT).replace('_', ' '); + if (!managedByModule(type)) name += " " + label(type); + } + names.add(name); + } + return List.copyOf(names); + } + + static boolean managedByModule(Class type) { + + return type == Object.class || type.getName().startsWith("org.crebs.skpackets.") + || type == com.github.retrooper.packetevents.wrapper.PacketWrapper.class + || type == com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon.class + || type == com.github.retrooper.packetevents.protocol.world.blockentity.BlockEntityType.class + || type == com.github.retrooper.packetevents.protocol.player.InteractionHand.class + || type == org.bukkit.block.sign.Side.class + || type == me.tofaa.entitylib.wrapper.WrapperEntity.class; + } + + public static String label(Class type) { + if (type == com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams.CollisionRule.class) + return "packet team collision rule"; + if (type == com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams.NameTagVisibility.class) + return "packet team name tag visibility"; + + String name = type.getName(); + name = name.substring(name.lastIndexOf('.') + 1).replace('$', ' '); + return "packet " + PacketReflection.fieldName(name.replace("Wrapper", "")); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/ServerBoundPacketFieldRegistrar.java b/src/main/java/org/crebs/skpackets/elements/general/field/ServerBoundPacketFieldRegistrar.java deleted file mode 100644 index 1cf000b..0000000 --- a/src/main/java/org/crebs/skpackets/elements/general/field/ServerBoundPacketFieldRegistrar.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2026 3add - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or - * substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING - * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package org.crebs.skpackets.elements.general.field; - -import com.github.retrooper.packetevents.protocol.packettype.PacketType; -import com.github.retrooper.packetevents.protocol.player.InteractionHand; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientInteractEntity; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientSelectBundleItem; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientUpdateSign; - -import org.bukkit.block.sign.Side; -import org.bukkit.util.Vector; -import org.crebs.skpackets.api.util.ConversionUtil; -import org.crebs.skpackets.api.util.field.FieldRegistrar; - -public class ServerBoundPacketFieldRegistrar implements FieldRegistrar { - - @Override - public void register() { - PacketFieldRegistry.INSTANCE - .builder( - PacketType.Play.Client.SELECT_BUNDLE_ITEM, - WrapperPlayClientSelectBundleItem.class) - .requiredField( - Number.class, - WrapperPlayClientSelectBundleItem::getSlotId, - (w, id) -> w.setSlotId(id.intValue()), - "slot id", - "id") - .requiredField( - Number.class, - WrapperPlayClientSelectBundleItem::getSelectedItemIndex, - (w, index) -> w.setSelectedItemIndex(index.intValue()), - "selected item index", - "selected index", - "item index", - "index") - .constructor( - values -> - new WrapperPlayClientSelectBundleItem( - values.getRequired("slot id", Number.class).intValue(), - values.getRequired("selected item index", Number.class) - .intValue())) - .build(); - - PacketFieldRegistry.INSTANCE - .builder( - PacketType.Play.Client.INTERACT_ENTITY, - WrapperPlayClientInteractEntity.class) - .requiredField( - Number.class, - WrapperPlayClientInteractEntity::getEntityId, - (w, id) -> w.setEntityId(id.intValue()), - "entity id", - "id") - .requiredField( - InteractionHand.class, - WrapperPlayClientInteractEntity::getHand, - WrapperPlayClientInteractEntity::setHand, - "interaction hand", - "hand") - .requiredField( - Vector.class, - w -> ConversionUtil.toBukkitVector(w.getLocation()), - (w, newLoc) -> w.setLocation(ConversionUtil.toPeVectorD(newLoc)), - "location vector", - "location", - "loc") - .requiredField( - Boolean.class, - w -> w.isSneaking().orElse(false), - (w, sneaking) -> w.setSneaking(Boolean.TRUE.equals(sneaking)), - "sneaking state", - "sneaking") - .constructor( - values -> - new WrapperPlayClientInteractEntity( - values.getRequired("entity id", Number.class).intValue(), - values.getRequired( - "interaction hand", InteractionHand.class), - ConversionUtil.toPeVectorD( - values.getRequired( - "location vector", Vector.class)), - values.getRequired("sneaking state", Boolean.class))) - .build(); - - PacketFieldRegistry.INSTANCE - .builder(PacketType.Play.Client.UPDATE_SIGN, WrapperPlayClientUpdateSign.class) - .requiredField( - Vector.class, - w -> ConversionUtil.toBukkitVector(w.getBlockPosition()), - (w, newPos) -> w.setBlockPosition(ConversionUtil.toPeVectorI(newPos)), - "location vector", - "location") - .requiredField( - String[].class, - WrapperPlayClientUpdateSign::getTextLines, - WrapperPlayClientUpdateSign::setTextLines, - "sign lines", - "lines") - .requiredField( - Side.class, - w -> w.isFrontText() ? Side.FRONT : Side.BACK, - (w, side) -> w.setFrontText(side == Side.FRONT), - "sign side", - "side") - .constructor( - values -> - new WrapperPlayClientUpdateSign( - ConversionUtil.toPeVectorI( - values.getRequired( - "location vector", Vector.class)), - values.getRequired("sign lines", String[].class), - values.getRequired("sign side", Side.class) == Side.FRONT)) - .build(); - } -} diff --git a/src/main/java/org/crebs/skpackets/elements/general/section/SecExprNewPacket.java b/src/main/java/org/crebs/skpackets/elements/general/section/SecExprNewPacket.java index eb6d28b..a6ad732 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/section/SecExprNewPacket.java +++ b/src/main/java/org/crebs/skpackets/elements/general/section/SecExprNewPacket.java @@ -31,43 +31,25 @@ import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; import com.github.retrooper.packetevents.wrapper.PacketWrapper; import com.github.shanebeee.skr.Registration; -import com.github.shanebeee.skr.skript.SimpleEntryValidator; import org.bukkit.event.Event; -import org.crebs.skpackets.api.util.field.FieldAccessor; import org.crebs.skpackets.api.util.field.FieldSchema; import org.crebs.skpackets.api.util.field.FieldSections; import org.crebs.skpackets.elements.general.field.PacketFieldRegistry; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.skriptlang.skript.lang.entry.EntryContainer; -import org.skriptlang.skript.lang.entry.EntryValidator; import java.util.*; public class SecExprNewPacket extends SectionExpression> { - private static final Map>, EntryValidator> - VALIDATORS = new HashMap<>(); - @SuppressWarnings({"unchecked", "rawtypes"}) public static void register(Registration reg) { - - for (FieldSchema> def : - PacketFieldRegistry.INSTANCE.getAllSchemas()) { - SimpleEntryValidator builder = SimpleEntryValidator.builder(); - for (FieldAccessor, ?> field : def.accessors()) { - builder.addOptionalEntry(field.name(), Object.class); - - for (String alias : field.aliases()) { - builder.addOptionalEntry(alias, Object.class); - } - } - VALIDATORS.put(def, builder.build()); - } - - String description = "Create a new packet from a packet type.\n" + String description = "Create a new packet from a packet type. Fields use native Skript values; " + + "packet-specific enums use quoted text. Use vec2(x, z) for two-dimensional coordinates. Equipment, player-info entries, profiles and trades support nested sections. Other complex values can be copied from packets using the shared packet value type. Optional fields may be omitted. Types without a public " + + "PacketEvents data constructor expose raw data as a list of byte numbers. " + + "Sending a packet still requires the matching protocol version and connection state.\n" + FieldSchema.describe(PacketFieldRegistry.INSTANCE.getAllSchemas(), - type -> type.getName().toLowerCase(Locale.ENGLISH).replace("_", " ")); + type -> new org.crebs.skpackets.elements.general.Types.PacketTypeParser().toString(type, 0)); reg.newSimpleExpression( SecExprNewPacket.class, @@ -155,22 +137,9 @@ public boolean init( return true; } - EntryValidator validator = VALIDATORS.get(this.schema); - if (validator == null) { - Skript.error( - "No validator found for " - + this.type.getName().toLowerCase(Locale.ENGLISH).replace("_", " ") - + " packet."); - return false; - } - - EntryContainer container = validator.validate(sectionNode); - if (container == null) { - return false; - } - - return FieldSections.parse(this.schema.accessors(), container, this.fieldExpressions, - this.type.getName().toLowerCase(Locale.ENGLISH).replace("_", " ") + " packet", true, Set.of()); + return org.crebs.skpackets.elements.general.field.PacketFieldSections.parse( + this.schema.accessors(), sectionNode, this.fieldExpressions, + this.type.getName().toLowerCase(Locale.ENGLISH).replace("_", " ") + " packet"); } @Override @@ -179,7 +148,7 @@ public boolean init( PacketWrapper packet = createPacket(event); return packet == null ? new PacketWrapper[0] : new PacketWrapper[] {packet}; } catch (IllegalArgumentException | IllegalStateException ex) { - Skript.error(ex.getMessage()); + error(ex.getMessage()); return new PacketWrapper[0]; } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..9a2af01 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,5 @@ +# Check the latest release. +update-checker: #Only checks when server starts + enabled: true + # Notify players with skpackets.update.check + notify-on-join: true diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 39aaa3f..9b5e0b3 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,3 +5,8 @@ api-version: '26.3' description: A packet addon for Skript depend: [Skript, packetevents] softdepend: [SkBee] +website: https://github.com/CrebsTheCoder/skPackets +permissions: + skpackets.update.check: + description: Receive skPackets update notices on join. + default: op diff --git a/src/test/java/org/crebs/skpackets/api/util/field/FieldSchemaDocumentationTest.java b/src/test/java/org/crebs/skpackets/api/util/field/FieldSchemaDocumentationTest.java new file mode 100644 index 0000000..deb6da9 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/api/util/field/FieldSchemaDocumentationTest.java @@ -0,0 +1,90 @@ +package org.crebs.skpackets.api.util.field; + +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBossBar; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; + +class FieldSchemaDocumentationTest { + @Test + void packetHeadingsContainCollapsibleFieldLists() { + FieldSchema schema = new FieldSchema<>("clientbound example packet", + List.of(field("id", Number.class, false, ignored -> 1)), null); + String docs = FieldSchema.describe(List.of(schema), Function.identity()); + assertEquals("### Available Packets and their fields\n\n" + + "* **clientbound example packet** fields:\n\n" + + "

\n View fields\n\n" + + " - `id: number` (required)\n\n" + + "
\n\n", docs); + } + + @Test + void fieldTypesUseFriendlySkriptNamesAndRegisteredPacketLabels() { + FieldSchema schema = new FieldSchema<>("example", List.of( + field("id", Number.class, false, ignored -> 1), + field("enabled", Boolean.class, false, ignored -> true), + field("position", Vector.class, false, ignored -> new Vector()), + field("slot", EquipmentSlot.class, false, ignored -> EquipmentSlot.HAND), + field("item", org.bukkit.inventory.ItemStack.class, false, null), + field("state", String.class, false, null).withNativeType(WrapperPlayServerBossBar.Action.class)), null); + String docs = schema.getReadableFields(); + assertTrue(docs.contains("`id: number`")); + assertTrue(docs.contains("`enabled: boolean`")); + assertTrue(docs.contains("`position: vector`")); + assertTrue(docs.contains("`slot: equipment slot`")); + assertTrue(docs.contains("`item: item`")); + assertTrue(docs.contains("`state: text`")); + assertFalse(docs.matches("(?s).*(?:java|org|com|net|io|me)\\..*")); + assertFalse(docs.contains("Number")); + assertFalse(docs.contains("ItemStack")); + } + + @Test + void showsOptionalRequiredAliasesAndCreationOnlyFields() { + FieldAccessor accessor = new FieldAccessor<>( + "ids", new String[]{"entity ids"}, Number[].class, true, null, null); + FieldSchema schema = new FieldSchema<>("example", List.of(accessor), null); + assertEquals("- `ids|entity ids: numbers` (optional; creation-only)", schema.getReadableFields()); + } + + @Test + void emptyPacketsDoNotHaveEmptyDetailsSections() { + FieldSchema schema = new FieldSchema<>("empty packet", List.of(), null); + String docs = FieldSchema.describe(List.of(schema), Function.identity()); + assertEquals("### Available Packets and their fields\n\n* **empty packet**: no fields.\n\n", docs); + assertFalse(docs.contains("
")); + assertFalse(docs.contains("")); + } + + @Test + void packetOrderingIsIndependentOfRegistryIterationOrder() { + FieldSchema alpha = new FieldSchema<>("alpha packet", List.of(), null); + FieldSchema zeta = new FieldSchema<>("zeta packet", + List.of(field("enabled", Boolean.class, true, ignored -> true)), null); + String forward = FieldSchema.describe(List.of(alpha, zeta), Function.identity()); + String reversed = FieldSchema.describe(List.of(zeta, alpha), Function.identity()); + assertEquals(forward, reversed); + assertTrue(forward.indexOf("**alpha packet**") < forward.indexOf("**zeta packet**")); + } + + @Test + void fieldsShowEnumValuesWithoutExamples() { + FieldSchema schema = new FieldSchema<>("example", List.of( + field("hand", com.github.retrooper.packetevents.protocol.player.InteractionHand.class, false, null), + field("update type", String.class, false, null).withNativeType(com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientSetStructureBlock.UpdateType.class)), null); + String docs = schema.getReadableFields(); + assertTrue(docs.contains("field values: `main hand`, `off hand`")); + assertFalse(docs.contains("Example:")); + assertTrue(docs.contains("field values: `\"update data\"`")); + } + + private static FieldAccessor field(String name, Class type, boolean optional, + Function getter) { + return new FieldAccessor<>(name, new String[0], type, optional, getter, null); + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/AddonRegistrationTest.java b/src/test/java/org/crebs/skpackets/elements/AddonRegistrationTest.java index 149591d..4569fa8 100644 --- a/src/test/java/org/crebs/skpackets/elements/AddonRegistrationTest.java +++ b/src/test/java/org/crebs/skpackets/elements/AddonRegistrationTest.java @@ -21,6 +21,7 @@ import org.crebs.skpackets.elements.simple.SimpleModule; import org.crebs.skpackets.elements.team.TeamModule; import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.crebs.skpackets.support.SkriptRegistrationTestSupport; import org.junit.jupiter.api.Test; import org.skriptlang.skript.bukkit.lang.eventvalue.EventValueRegistry; import org.skriptlang.skript.lang.comparator.Comparators; @@ -46,6 +47,7 @@ void registersAllModulesWithoutDuplicateTypes() throws Exception { var skriptPlugin = mockStatic(ch.njol.skript.Skript.class); var bukkit = mockStatic(Bukkit.class)) { skriptPlugin.when(ch.njol.skript.Skript::instance).thenReturn(skript); + skriptPlugin.when(ch.njol.skript.Skript::getVersion).thenReturn(new ch.njol.skript.util.Version("2.17.1")); bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); var server = mock(Server.class, RETURNS_DEEP_STUBS); when(server.getServerConfig().isProxyOnlineMode()).thenReturn(true); @@ -57,33 +59,35 @@ void registersAllModulesWithoutDuplicateTypes() throws Exception { packetSchemas.clear(); snapshots.captureRegistry(FakeEntityFieldRegistry.INSTANCE); snapshots.captureRegistry(MetaFieldRegistry.INSTANCE); - Registration registration = new Registration("skPackets", true); - registration.getAddon().loadModules( - new GeneralModule(registration), - new SimpleModule(registration), - new EntityModule(registration), - new TeamModule(registration)); - Field infoField = Registration.TypeRegistrar.class.getDeclaredField("classInfo"); - infoField.setAccessible(true); - List> infos = new ArrayList<>(); - Set> classes = new HashSet<>(); - Set codes = new HashSet<>(); - for (var queued : registration.getTypes()) { - ClassInfo info = (ClassInfo) infoField.get(queued); - assertTrue(classes.add(info.getC()), () -> "Duplicate queued class: " + info.getC()); - assertTrue(codes.add(info.getCodeName()), () -> "Duplicate queued codename: " + info.getCodeName()); - infos.add(info); - } - assertFalse(infos.isEmpty()); - assertDoesNotThrow(registration::finalizeRegistration); - for (ClassInfo info : infos) { - assertSame(info, Classes.getExactClassInfo(info.getC())); - assertSame(info, Classes.getClassInfoNoError(info.getCodeName())); + try (var functions = SkriptRegistrationTestSupport.initialize()) { + Registration registration = new Registration("skPackets", true); + registration.getAddon().loadModules( + new GeneralModule(registration), + new SimpleModule(registration), + new EntityModule(registration), + new TeamModule(registration)); + Field infoField = Registration.TypeRegistrar.class.getDeclaredField("classInfo"); + infoField.setAccessible(true); + List> infos = new ArrayList<>(); + Set> classes = new HashSet<>(); + Set codes = new HashSet<>(); + for (var queued : registration.getTypes()) { + ClassInfo info = (ClassInfo) infoField.get(queued); + assertTrue(classes.add(info.getC()), () -> "Duplicate queued class: " + info.getC()); + assertTrue(codes.add(info.getCodeName()), () -> "Duplicate queued codename: " + info.getCodeName()); + infos.add(info); + } + assertFalse(infos.isEmpty()); + assertDoesNotThrow(registration::finalizeRegistration); + for (ClassInfo info : infos) { + assertSame(info, Classes.getExactClassInfo(info.getC())); + assertSame(info, Classes.getClassInfoNoError(info.getCodeName())); + } + assertNotNull(Classes.getExactClassInfo(PacketTypeCommon.class)); + assertNotNull(Classes.getExactClassInfo(EntityMeta.class)); + assertFalse(registration.getEffects().isEmpty()); + assertFalse(registration.getExpressions().isEmpty()); } - assertNotNull(Classes.getExactClassInfo(PacketTypeCommon.class)); - assertNotNull(Classes.getExactClassInfo(EntityMeta.class)); - assertFalse(registration.getEffects().isEmpty()); - assertFalse(registration.getExpressions().isEmpty()); } } diff --git a/src/test/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketFieldParserTest.java b/src/test/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketFieldParserTest.java new file mode 100644 index 0000000..2cbed84 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketFieldParserTest.java @@ -0,0 +1,256 @@ +package org.crebs.skpackets.elements.general.expressions.prop; + +import ch.njol.skript.Skript; +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.classes.ClassInfo; +import ch.njol.skript.effects.EffChange; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.lang.parser.ParserInstance; +import ch.njol.skript.log.SkriptLogger; +import ch.njol.skript.registrations.Classes; +import ch.njol.util.Kleenean; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEffect; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerDestroyEntities; +import org.bukkit.Bukkit; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import org.crebs.skpackets.elements.general.field.PacketFieldRegistry; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ExprPacketFieldParserTest { + @BeforeAll + static void initializeFields() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void skriptAcceptsExistingScalarDataAssignment() { + Event event = mock(Event.class); + WrapperPlayServerEffect packet = mock(WrapperPlayServerEffect.class, RETURNS_DEEP_STUBS); + when(packet.getPacketTypeData().getPacketType()).thenReturn(PacketType.Play.Server.EFFECT); + when(packet.getData()).thenReturn(7); + ParserInstance parser = mock(ParserInstance.class); + try (var parsers = mockStatic(ParserInstance.class); + var skript = mockStatic(Skript.class); + var classes = mockStatic(Classes.class); + var logs = mockStatic(SkriptLogger.class)) { + parsers.when(ParserInstance::get).thenReturn(parser); + classes.when(() -> Classes.getSuperClassInfo(any(Class.class))).thenReturn(mock(ClassInfo.class)); + Expression variable = mock(Expression.class); + when(variable.acceptChange(ChangeMode.SET)).thenReturn(new Class[]{Object.class}); + doReturn(Object.class).when(variable).getReturnType(); + when(variable.toString(null, false)).thenReturn("{_effectData}"); + ExprPacketField field = field("data", packet, event); + EffChange assignment = new EffChange(); + assertTrue(assignment.init(new Expression[]{variable, field}, 3, Kleenean.FALSE, + new ParseResult("set {_effectData} to packet field data of {_effect}", new Expression[0]))); + assertEquals(7, field.getSingle(event)); + skript.verify(() -> Skript.error(anyString()), never()); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void skriptAcceptsMultipleValuesForAnArrayPacketField() { + Event event = mock(Event.class); + WrapperPlayServerDestroyEntities packet = mock(WrapperPlayServerDestroyEntities.class, RETURNS_DEEP_STUBS); + when(packet.getPacketTypeData().getPacketType()).thenReturn(PacketType.Play.Server.DESTROY_ENTITIES); + ParserInstance parser = mock(ParserInstance.class); + try (var parsers = mockStatic(ParserInstance.class); + var skript = mockStatic(Skript.class); + var classes = mockStatic(Classes.class); + var logs = mockStatic(SkriptLogger.class)) { + parsers.when(ParserInstance::get).thenReturn(parser); + classes.when(() -> Classes.getSuperClassInfo(any(Class.class))).thenReturn(mock(ClassInfo.class)); + classes.when(() -> Classes.toString(any(Object[].class), eq(false))).thenReturn("numbers"); + Expression values = mock(Expression.class); + doReturn(Number.class).when(values).getReturnType(); + when(values.canReturn(Number.class)).thenReturn(true); + when(values.isSingle()).thenReturn(false); + when(values.canBeSingle()).thenReturn(false); + when(values.getArray(event)).thenReturn(new Number[]{3, 4}); + ExprPacketField field = field("entity ids", packet, event); + EffChange assignment = new EffChange(); + assertTrue(assignment.init(new Expression[]{field, values}, 3, Kleenean.FALSE, + new ParseResult("set packet field entity ids of {_packet} to 3 and 4", new Expression[0]))); + field.change(event, values.getArray(event), ChangeMode.SET); + verify(packet).setEntityIds(new int[]{3, 4}); + skript.verify(() -> Skript.error(anyString()), never()); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void skriptCopiesBoxedMixedArrayFieldsThroughItsActualConversionPipeline() { + Event event = mock(Event.class); + PacketWrapper sourcePacket = mock(PacketWrapper.class, RETURNS_DEEP_STUBS); + PacketWrapper targetPacket = mock(PacketWrapper.class, RETURNS_DEEP_STUBS); + var sourceType = mock(com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon.class); + var targetType = mock(com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon.class); + var scalarType = mock(com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon.class); + when(sourcePacket.getPacketTypeData().getPacketType()).thenReturn(sourceType); + when(targetPacket.getPacketTypeData().getPacketType()).thenReturn(targetType); + var target = new java.util.concurrent.atomic.AtomicReference(new Number[]{99}); + PacketFieldRegistry.INSTANCE.builder(sourceType, (Class) PacketWrapper.class) + .requiredField(Number[].class, ignored -> new Number[]{3, 4}, null, "parser mixed array").build(); + PacketFieldRegistry.INSTANCE.builder(targetType, (Class) PacketWrapper.class) + .requiredField(Number[].class, ignored -> target.get(), + (ignored, value) -> target.set((Number[]) value), "parser mixed array").build(); + PacketFieldRegistry.INSTANCE.builder(scalarType, (Class) PacketWrapper.class) + .requiredField(Number.class, ignored -> 1, null, "parser mixed array").build(); + ParserInstance parser = mock(ParserInstance.class); + try (var parsers = mockStatic(ParserInstance.class); + var skript = mockStatic(Skript.class); + var classes = mockStatic(Classes.class); + var logs = mockStatic(SkriptLogger.class)) { + parsers.when(ParserInstance::get).thenReturn(parser); + ClassInfo info = mock(ClassInfo.class); + doReturn(Object.class).when(info).getC(); + classes.when(() -> Classes.getSuperClassInfo(any(Class.class))).thenReturn(info); + classes.when(() -> Classes.getSuperClassInfo(any(Class[].class))).thenReturn(info); + ExprPacketField source = field("parser mixed array", sourcePacket, event); + ExprPacketField destination = field("parser mixed array", targetPacket, event); + RunChange assignment = new RunChange(); + assertTrue(assignment.init(new Expression[]{destination, source}, 3, Kleenean.FALSE, + new ParseResult("set packet field parser mixed array of {_target} to packet field parser mixed array of {_source}", new Expression[0]))); + assignment.executeAssignment(event); + assertArrayEquals(new Number[]{3, 4}, target.get()); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void skriptCopiesRealPluginMessageDataUsingTypedArrayValues() { + Event event = mock(Event.class); + var sourcePacket = mock(com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPluginMessage.class, RETURNS_DEEP_STUBS); + var targetPacket = mock(com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPluginMessage.class, RETURNS_DEEP_STUBS); + when(sourcePacket.getPacketTypeData().getPacketType()).thenReturn(PacketType.Play.Client.PLUGIN_MESSAGE); + when(targetPacket.getPacketTypeData().getPacketType()).thenReturn(PacketType.Play.Client.PLUGIN_MESSAGE); + when(sourcePacket.getData()).thenReturn(new byte[]{3, 4}); + ParserInstance parser = mock(ParserInstance.class); + try (var parsers = mockStatic(ParserInstance.class); + var skript = mockStatic(Skript.class); + var classes = mockStatic(Classes.class); + var logs = mockStatic(SkriptLogger.class)) { + parsers.when(ParserInstance::get).thenReturn(parser); + ClassInfo info = mock(ClassInfo.class); + doReturn(Object.class).when(info).getC(); + classes.when(() -> Classes.getSuperClassInfo(any(Class.class))).thenReturn(info); + classes.when(() -> Classes.getSuperClassInfo(any(Class[].class))).thenReturn(info); + ExprPacketField source = field("data", sourcePacket, event); + ExprPacketField destination = field("data", targetPacket, event); + assertInstanceOf(Number[].class, source.getSingle(event)); + RunChange assignment = new RunChange(); + assertTrue(assignment.init(new Expression[]{destination, source}, 3, Kleenean.FALSE, + new ParseResult("set packet field data of {_target} to packet field data of {_source}", new Expression[0]))); + assignment.executeAssignment(event); + verify(targetPacket).setData(new byte[]{3, 4}); + } + } + + @Test + void invalidArrayElementsAreReportedWithoutEscapingTheChangeEffect() { + Event event = mock(Event.class); + var packet = mock(WrapperPlayServerDestroyEntities.class, RETURNS_DEEP_STUBS); + when(packet.getPacketTypeData().getPacketType()).thenReturn(PacketType.Play.Server.DESTROY_ENTITIES); + ParserInstance parser = mock(ParserInstance.class); + try (var parsers = mockStatic(ParserInstance.class); + var skript = mockStatic(Skript.class)) { + parsers.when(ParserInstance::get).thenReturn(parser); + ExprPacketField expression = spy(field("entity ids", packet, event)); + doNothing().when(expression).error(anyString()); + assertDoesNotThrow(() -> expression.change(event, new Object[]{"wrong"}, ChangeMode.SET)); + verify(packet, never()).setEntityIds(any(int[].class)); + verify(expression).error(anyString()); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void packetCreationSectionCopiesBoxedArrayDataThroughSkriptConversion() { + Event event = mock(Event.class); + var packet = mock(com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPluginMessage.class, RETURNS_DEEP_STUBS); + when(packet.getPacketTypeData().getPacketType()).thenReturn(PacketType.Play.Client.PLUGIN_MESSAGE); + when(packet.getData()).thenReturn(new byte[]{3, 4}); + ParserInstance parser = mock(ParserInstance.class); + try (var parsers = mockStatic(ParserInstance.class); + var skript = mockStatic(Skript.class); + var classes = mockStatic(Classes.class); + var logs = mockStatic(SkriptLogger.class)) { + parsers.when(ParserInstance::get).thenReturn(parser); + ClassInfo info = mock(ClassInfo.class); + doReturn(Object.class).when(info).getC(); + classes.when(() -> Classes.getSuperClassInfo(any(Class.class))).thenReturn(info); + classes.when(() -> Classes.getSuperClassInfo(any(Class[].class))).thenReturn(info); + ExprPacketField source = field("data", packet, event); + var schema = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Client.PLUGIN_MESSAGE); + var entries = mock(org.skriptlang.skript.lang.entry.EntryContainer.class); + doReturn(source).when(entries).getOptional("data", Object.class, false); + java.util.Map> expressions = new java.util.HashMap<>(); + assertTrue(org.crebs.skpackets.api.util.field.FieldSections.parse( + java.util.List.of(schema.getAccessor("data")), entries, expressions, "plugin message", true, java.util.Set.of())); + var created = org.crebs.skpackets.api.util.field.FieldSections.create(schema, + PacketType.Play.Client.PLUGIN_MESSAGE, expressions, event, + java.util.Map.of("channel name", "minecraft:brand")); + assertInstanceOf(com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPluginMessage.class, created); + assertArrayEquals(new byte[]{3, 4}, ((com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPluginMessage) created).getData()); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void numericListLiteralsAreConvertedBeforePacketCreation() { + Event event = mock(Event.class); + var unparsed = mock(ch.njol.skript.lang.UnparsedLiteral.class); + var numbers = mock(ch.njol.skript.lang.Literal.class); + doReturn(Object.class).when(unparsed).getReturnType(); + doReturn(numbers).when(unparsed).getConvertedExpression(Number.class); + when(numbers.getArray(event)).thenReturn(new Number[]{1L, 2L, 3L}); + var schema = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.DESTROY_ENTITIES); + var entries = mock(org.skriptlang.skript.lang.entry.EntryContainer.class); + doReturn(unparsed).when(entries).getOptional("entity ids", Object.class, false); + java.util.Map> expressions = new java.util.HashMap<>(); + assertTrue(org.crebs.skpackets.api.util.field.FieldSections.parse(schema.accessors(), entries, + expressions, "destroy entities", true, java.util.Set.of())); + verify(unparsed).getConvertedExpression(Number.class); + var packet = org.crebs.skpackets.api.util.field.FieldSections.create(schema, + PacketType.Play.Server.DESTROY_ENTITIES, expressions, event, java.util.Map.of()); + assertArrayEquals(new int[]{1, 2, 3}, ((WrapperPlayServerDestroyEntities) packet).getEntityIds()); + } + + private static class RunChange extends EffChange { + void executeAssignment(Event event) { execute(event); } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static ExprPacketField field(String name, PacketWrapper packet, Event event) { + Expression> source = mock(Expression.class); + when(source.getArray(event)).thenReturn(new PacketWrapper[]{packet}); + when(source.isSingle()).thenReturn(true); + Expression[] inputs = {source}; + ParseResult result = new ParseResult("", inputs); + var matcher = Pattern.compile(Pattern.quote(name)).matcher(name); + assertTrue(matcher.find()); + result.regexes.add(matcher); + ExprPacketField field = new ExprPacketField(); + assertTrue(field.init(inputs, 0, Kleenean.FALSE, result)); + return field; + } +} + + diff --git a/src/test/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketFieldTest.java b/src/test/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketFieldTest.java index b43060a..8a38b75 100644 --- a/src/test/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketFieldTest.java +++ b/src/test/java/org/crebs/skpackets/elements/general/expressions/prop/ExprPacketFieldTest.java @@ -34,6 +34,41 @@ import static org.mockito.Mockito.*; class ExprPacketFieldTest { + @Test void invalidDynamicValueUsesTheExpressionRuntimeErrorHandler() { + Event event = mock(Event.class); + PacketWrapper packet = mock(PacketWrapper.class, RETURNS_DEEP_STUBS); + FieldAccessor, String> accessor = new FieldAccessor<>("review runtime validation", new String[0], String.class, + false, ignored -> "before", (ignored, next) -> { throw new IllegalArgumentException("Invalid action: update"); }); + Probe expression = spy(expression(accessor, packet, event)); + doNothing().when(expression).error(anyString()); + expression.change(event, new Object[]{"update"}, ChangeMode.SET); + verify(expression).error("Invalid action: update"); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + void knownEventPacketKeepsItsArrayFieldTypeDespiteUnrelatedScalarFields() { + var eventPacket = mock(ch.njol.skript.expressions.base.EventValueExpression.class); + when(eventPacket.getSource()).thenReturn(eventPacket); + when(eventPacket.isSingle()).thenReturn(true); + var data = mock(PacketSendOrReceiveParserData.class); + when(data.getPacketType()).thenReturn(PacketType.Play.Server.PLUGIN_MESSAGE); + var parser = mock(ParserInstance.class); + when(parser.isCurrentEvent(PacketSendOrReceiveEvent.class)).thenReturn(true); + when(parser.getData(PacketSendOrReceiveParserData.class)).thenReturn(data); + try (var parsers = mockStatic(ParserInstance.class)) { + parsers.when(ParserInstance::get).thenReturn(parser); + var parsed = new ParseResult("packet field data of event-packet", new Expression[0]); + var match = Pattern.compile("data").matcher("data"); + assertTrue(match.find()); + parsed.regexes.add(match); + Probe expression = new Probe(); + assertTrue(expression.init(new Expression[]{eventPacket}, 0, Kleenean.FALSE, parsed)); + assertSame(Number.class, expression.getReturnType()); + assertFalse(expression.isSingle()); + } + } + @BeforeAll static void initializeFields() { PacketEventsTestBootstrap.initialize(); @@ -78,7 +113,7 @@ void flattensArrayFieldsAndReportsPlurality() { } @Test - void sharedDataAliasReadsBothScalarTypesWithoutArrayStoreFailure() { + void nativeScalarFieldsReadTheirOwnValuesWithoutArrayStoreFailure() { Event event = mock(Event.class); WrapperPlayServerEffect effect = mock(WrapperPlayServerEffect.class, RETURNS_DEEP_STUBS); WrapperPlayServerBlockChange block = mock(WrapperPlayServerBlockChange.class, RETURNS_DEEP_STUBS); @@ -91,11 +126,41 @@ void sharedDataAliasReadsBothScalarTypesWithoutArrayStoreFailure() { Probe expression = initialized("data", effect, event, null); assertSame(Object.class, expression.getReturnType()); assertArrayEquals(new Object[]{1}, expression.read(event, effect)); - assertArrayEquals(new Object[]{data}, expression.read(event, block)); + assertArrayEquals(new Object[]{data}, initialized("block state", block, event, null).read(event, block)); assertTrue(expression.isSingle()); } } + @Test + void mixedScalarAndArrayNamesKeepOneValuePerPacketAndRoundTripArrays() { + Event event = mock(Event.class); + PacketWrapper packet = mock(PacketWrapper.class, RETURNS_DEEP_STUBS); + AtomicReference bytes = new AtomicReference<>(new Number[]{1, 2, 3}); + FieldAccessor, Number[]> arrayField = new FieldAccessor<>( + "review mixed data", new String[0], Number[].class, false, + ignored -> bytes.get(), (ignored, next) -> bytes.set(next)); + expression(arrayField, packet, event); + PacketWrapper scalar = mock(PacketWrapper.class, RETURNS_DEEP_STUBS); + FieldAccessor, Number> scalarField = new FieldAccessor<>( + "review mixed data", new String[0], Number.class, false, ignored -> 42, null); + Probe expression = expression(scalarField, scalar, event); + assertTrue(expression.isSingle()); + assertSame(Object.class, expression.getReturnType()); + assertArrayEquals(new Object[]{42}, expression.read(event, scalar)); + Object[] result = expression.read(event, packet); + assertEquals(1, result.length); + assertSame(bytes.get(), result[0]); + + Probe arrayExpression = initialized("review mixed data", packet, event, null); + arrayExpression.change(event, new Object[]{new Number[]{7, 8}}, ChangeMode.SET); + assertArrayEquals(new Number[]{7, 8}, bytes.get()); + arrayExpression.change(event, new Object[]{9, 10}, ChangeMode.SET); + assertArrayEquals(new Number[]{9, 10}, bytes.get()); + bytes.set(new Number[0]); + assertEquals(1, arrayExpression.read(event, packet).length); + assertArrayEquals(new Number[0], (Number[]) arrayExpression.read(event, packet)[0]); + } + @Test void typedMetadataEventStillResolvesTheAccessedVelocityPacketsSchema() { Event event = mock(Event.class); @@ -114,7 +179,7 @@ void writableSharedAliasAcceptsBothRuntimeValueTypes() { ParserInstance parser = mock(ParserInstance.class); try (var parsers = mockStatic(ParserInstance.class)) { parsers.when(ParserInstance::get).thenReturn(parser); - assertTrue(java.util.Set.of(expression.acceptChange(ChangeMode.SET)).containsAll(java.util.Set.of(Number.class, BlockData.class))); + assertTrue(java.util.Set.of(expression.acceptChange(ChangeMode.SET)).containsAll(java.util.Set.of(Number.class, Number[].class))); } } diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketArgumentsTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketArgumentsTest.java new file mode 100644 index 0000000..8462359 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketArgumentsTest.java @@ -0,0 +1,118 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.player.EquipmentSlot; +import com.github.retrooper.packetevents.protocol.player.InteractionHand; +import com.github.retrooper.packetevents.util.Vector3d; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.lang.reflect.Type; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class PacketArgumentsTest { + List ids; + Optional> optionalIds; + List vectors; + EnumSet hands; + + private static Type type(String field) throws Exception { return PacketArgumentsTest.class.getDeclaredField(field).getGenericType(); } + + @Test void usesProperScalarAndCollectionTypes() throws Exception { + assertSame(Number.class, PacketArguments.expressionType(int.class)); + assertSame(Boolean.class, PacketArguments.expressionType(boolean.class)); + assertSame(Number[].class, PacketArguments.expressionType(byte[].class)); + assertSame(UUID[].class, PacketArguments.expressionType(type("ids"))); + assertSame(UUID[].class, PacketArguments.expressionType(type("optionalIds"))); + assertSame(Vector[].class, PacketArguments.expressionType(type("vectors"))); + assertSame(InteractionHand[].class, PacketArguments.expressionType(type("hands"))); + assertSame(org.bukkit.inventory.EquipmentSlot.class, PacketArguments.expressionType(EquipmentSlot.class)); + assertSame(org.bukkit.inventory.ItemStack.class, PacketArguments.expressionType(com.github.retrooper.packetevents.protocol.item.ItemStack.class)); + } + + @ParameterizedTest @EnumSource(org.bukkit.inventory.EquipmentSlot.class) + void equipmentUsesNativeSlotsInBothDirections(org.bukkit.inventory.EquipmentSlot slot) { + Object packetSlot = PacketArguments.convert(slot, EquipmentSlot.class, "slot"); + assertInstanceOf(EquipmentSlot.class, packetSlot); + assertSame(slot, PacketArguments.toSkript(packetSlot)); + } + + @ParameterizedTest @EnumSource(org.bukkit.SoundCategory.class) + void soundCategoriesUseNativeValuesIncludingPluralNames(org.bukkit.SoundCategory category) { + assertEquals(category, PacketArguments.toSkript(PacketArguments.convert(category, + com.github.retrooper.packetevents.protocol.sound.SoundCategory.class, "sound category"))); + } + + @ParameterizedTest @EnumSource(org.bukkit.GameMode.class) + void gameModesUseNativeValues(org.bukkit.GameMode mode) { + assertEquals(mode, PacketArguments.toSkript(PacketArguments.convert(mode, + com.github.retrooper.packetevents.protocol.player.GameMode.class, "game mode"))); + } + + @ParameterizedTest @EnumSource(org.bukkit.Difficulty.class) + void difficultyUsesNativeValues(org.bukkit.Difficulty difficulty) { + assertEquals(difficulty, PacketArguments.toSkript(PacketArguments.convert(difficulty, + com.github.retrooper.packetevents.protocol.world.Difficulty.class, "difficulty"))); + } + + @ParameterizedTest @EnumSource(org.bukkit.inventory.MainHand.class) + void mainHandUsesNativeValues(org.bukkit.inventory.MainHand hand) { + assertEquals(hand, PacketArguments.toSkript(PacketArguments.convert(hand, + com.github.retrooper.packetevents.protocol.player.HumanoidArm.class, "main hand"))); + } + + @Test void convertsListsOptionalsAndEnumSetsWithoutStringCoercion() throws Exception { + UUID uuid = UUID.randomUUID(); + assertEquals(List.of(uuid), PacketArguments.convert(new UUID[]{uuid}, type("ids"), "ids")); + assertEquals(Optional.empty(), PacketArguments.convert(null, type("optionalIds"), "ids")); + assertEquals(Optional.of(List.of(uuid)), PacketArguments.convert(new UUID[]{uuid}, type("optionalIds"), "ids")); + assertEquals(EnumSet.of(InteractionHand.MAIN_HAND), PacketArguments.convert(new InteractionHand[]{InteractionHand.MAIN_HAND}, type("hands"), "hands")); + assertThrows(IllegalArgumentException.class, () -> PacketArguments.convert("main hand", InteractionHand.class, "hand")); + assertThrows(IllegalArgumentException.class, () -> PacketArguments.convert("true", boolean.class, "enabled")); + assertThrows(IllegalArgumentException.class, () -> PacketArguments.convert("12", int.class, "count")); + } + + @Test void convertsVectorsWithoutLosingDoublePrecision() throws Exception { + Vector vector = new Vector(1.1234567890123, 2, -4); + var result = (List) PacketArguments.convert(new Vector[]{vector}, type("vectors"), "vectors"); + assertEquals(vector.getX(), ((Vector3d) result.getFirst()).getX()); + assertEquals(vector, PacketArguments.toSkript(result.getFirst())); + } + + @Test void validatesNumbersAndByteRanges() { + assertArrayEquals(new byte[]{0, -1, -128}, (byte[]) PacketArguments.convert(new Number[]{0L, 255L, -128L}, byte[].class, "data")); + for (Number bad : new Number[]{256, -129, 1.5, Double.NaN, Double.POSITIVE_INFINITY}) + assertThrows(IllegalArgumentException.class, () -> PacketArguments.convert(bad, byte.class, "data")); + assertThrows(IllegalArgumentException.class, () -> PacketArguments.convert(Long.MAX_VALUE, int.class, "count")); + assertEquals(Long.MAX_VALUE, PacketArguments.convert(Long.MAX_VALUE, long.class, "id")); + } + + @Test void leavesQueuedModuleTypesToTheirExistingRegistrations() { + assertTrue(PacketValueTypes.managedByModule(InteractionHand.class)); + assertTrue(PacketValueTypes.managedByModule(org.crebs.skpackets.api.entity.metadata.EntityMeta.class)); + assertFalse(PacketValueTypes.managedByModule(com.github.retrooper.packetevents.protocol.player.Equipment.class)); + } + + + + + + + + + @Test void twoDimensionalVectorsUseNativeXAndZCoordinates() { + assertSame(org.crebs.skpackets.api.general.Vector2.class, PacketArguments.expressionType(com.github.retrooper.packetevents.util.Vector2i.class)); + var value = (com.github.retrooper.packetevents.util.Vector2i) PacketArguments.convert( + new Vector(3, 99, -2), com.github.retrooper.packetevents.util.Vector2i.class, "chunk position"); + assertEquals(3, value.getX()); + assertEquals(-2, value.getZ()); + assertEquals(new org.crebs.skpackets.api.general.Vector2(3, -2), PacketArguments.toSkript(value)); + } + + + + +} diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketCreationCoverageTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketCreationCoverageTest.java new file mode 100644 index 0000000..5ba089a --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketCreationCoverageTest.java @@ -0,0 +1,147 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.PacketSide; +import com.github.retrooper.packetevents.protocol.packettype.*; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientUseItem; +import com.github.retrooper.packetevents.wrapper.play.server.*; +import com.github.retrooper.packetevents.protocol.player.InteractionHand; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.crebs.skpackets.api.general.packet.PacketTypeRegistry; +import org.crebs.skpackets.api.util.field.*; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.*; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketCreationCoverageTest { + @BeforeAll static void initialize() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + static Stream packetTypes() { return PacketTypeRegistry.getAllPackets().stream(); } + + @ParameterizedTest(name = "{0}") @MethodSource("packetTypes") + void everyPacketTypeHasAValidatedCreationSchema(PacketTypeCommon type) { + var schema = PacketFieldRegistry.INSTANCE.getSchema(type); + assertNotNull(schema, () -> type + " has no schema"); + assertNotNull(schema.constructor(), () -> type + " has no constructor"); + Set names = new HashSet<>(); + for (var field : schema.accessors()) { + assertTrue(names.add(field.name()), () -> type + " duplicates " + field.name()); + assertNotNull(field.expectedType()); + + assertFalse(field.expectedType().isPrimitive()); + } + } + + @Test void generatedSchemasExposeNativeTypesOrTheSharedCarrier() { + for (var schema : PacketFieldRegistry.INSTANCE.getAllSchemas()) { + for (var field : schema.accessors()) { + Class type = field.expectedType(); + while (type.isArray()) type = type.getComponentType(); + assertTrue(type == PacketValue.class || type == Component.class || PacketValueTypes.managedByModule(type) + || type.getName().startsWith("java.") || type.getName().startsWith("org.bukkit.") + || type.getName().startsWith("ch.njol.skript.") || type.getName().startsWith("org.skriptlang.skript."), + "Leaked PacketEvents implementation type: " + type.getName()); + } + } + } + + @Test void onlyTheKnownUnconstructableTypesUseRawPayloads() { + Set expected = Set.of( + PacketType.Handshaking.Client.LEGACY_SERVER_LIST_PING, + PacketType.Handshaking.Server.LEGACY_SERVER_LIST_RESPONSE, + PacketType.Configuration.Server.UPDATE_TAGS, + PacketType.Play.Client.RECIPE_BOOK_DATA, + PacketType.Play.Server.MAP_CHUNK_BULK, + PacketType.Play.Server.UPDATE_SIGN, + PacketType.Play.Server.SCULK_VIBRATION_SIGNAL, + PacketType.Play.Server.NAMED_SOUND_EFFECT, + PacketType.Play.Server.UNLOCK_RECIPES, + PacketType.Play.Server.STOP_SOUND); + Set actual = new HashSet<>(); + for (PacketTypeCommon type : PacketTypeRegistry.getAllPackets()) + if (PacketFieldRegistry.INSTANCE.getSchema(type).getAccessor("raw data") != null) actual.add(type); + assertEquals(expected, actual, "A new or existing typed wrapper must not silently become raw-only"); + } + + @Test void reportsCoverage() { + long raw = PacketTypeRegistry.getAllPackets().stream().filter(t -> PacketFieldRegistry.INSTANCE.getSchema(t).getAccessor("raw data") != null).count(); + System.out.println("Packet creation coverage: " + PacketTypeRegistry.getAllPackets().size() + " types, " + raw + " raw-payload fallbacks"); + assertTrue(PacketTypeRegistry.getAllPackets().size() > 280); + } + + @Test void createsUseItemWithBothHandsAndAllFields() { + for (InteractionHand hand : InteractionHand.values()) { + WrapperPlayClientUseItem packet = (WrapperPlayClientUseItem) create(PacketType.Play.Client.USE_ITEM, + Map.of("hand", hand, "sequence", 300L, "yaw", 12.5d, "pitch", -4d)); + assertSame(hand, packet.getHand()); + assertEquals(300, packet.getSequence()); + assertEquals(12.5f, packet.getYaw()); + assertEquals(-4f, packet.getPitch()); + assertEquals(PacketSide.CLIENT, packet.getPacketTypeData().getPacketType().getSide()); + } + } + + @Test void constructsPacketsInEveryProtocolState() { + assertEquals(PacketType.Status.Client.REQUEST, create(PacketType.Status.Client.REQUEST, Map.of()).getPacketTypeData().getPacketType()); + assertEquals(PacketType.Login.Server.SET_COMPRESSION, create(PacketType.Login.Server.SET_COMPRESSION, Map.of("threshold", 256L)).getPacketTypeData().getPacketType()); + assertEquals(PacketType.Configuration.Server.KEEP_ALIVE, create(PacketType.Configuration.Server.KEEP_ALIVE, Map.of("id", 99L)).getPacketTypeData().getPacketType()); + var handshake = create(PacketType.Handshaking.Client.HANDSHAKE, + Map.of("protocol version", 767L, "server address", "localhost", "server port", 25565L, + "intention", com.github.retrooper.packetevents.wrapper.handshaking.client.WrapperHandshakingClientHandshake.ConnectionIntention.LOGIN)); + assertEquals(PacketType.Handshaking.Client.HANDSHAKE, handshake.getPacketTypeData().getPacketType()); + } + + @Test void supportsBossBarFieldsBeyondConstructorArguments() { + UUID uuid = UUID.randomUUID(); + var packet = (WrapperPlayServerBossBar) create(PacketType.Play.Server.BOSS_BAR, + Map.of("uuid", uuid, "action", WrapperPlayServerBossBar.Action.ADD, + "title", Component.text("Test"), "health", 0.75d)); + assertEquals(uuid, packet.getUUID()); + assertEquals(Component.text("Test"), packet.getTitle()); + assertEquals(0.75f, packet.getHealth()); + } + + @Test void acceptsTypedCollectionsAndEmptyLists() { + var packet = (WrapperPlayServerPlayerInfoRemove) create(PacketType.Play.Server.PLAYER_INFO_REMOVE, + Map.of("profile ids", new UUID[]{new UUID(0, 1), new UUID(0, 2)})); + assertEquals(List.of(new UUID(0, 1), new UUID(0, 2)), packet.getProfileIds()); + var empty = (WrapperPlayServerPlayerInfoRemove) create(PacketType.Play.Server.PLAYER_INFO_REMOVE, + Map.of("profile ids", new UUID[0])); + assertTrue(empty.getProfileIds().isEmpty()); + } + + @Test void rawPayloadIsExplicitAndBytesAreChecked() { + var schema = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.STOP_SOUND); + assertNotNull(schema.getAccessor("raw data")); + var packet = create(PacketType.Play.Server.STOP_SOUND, Map.of("raw data", new Number[]{0, 255, -1})); + assertInstanceOf(PacketFieldRegistrar.RawPacket.class, packet); + assertEquals(PacketType.Play.Server.STOP_SOUND, packet.getPacketTypeData().getPacketType()); + assertThrows(IllegalArgumentException.class, () -> create(PacketType.Play.Server.STOP_SOUND, Map.of("raw data", new Number[]{256}))); + } + + @Test void invalidValueReportsItsFieldInsteadOfSilentlyConstructing() { + assertThrows(IllegalArgumentException.class, () -> create(PacketType.Play.Client.HELD_ITEM_CHANGE, Map.of("slot", "1"))); + assertThrows(IllegalArgumentException.class, () -> create(PacketType.Play.Client.HELD_ITEM_CHANGE, Map.of("slot", 1.5))); + assertThrows(IllegalStateException.class, () -> create(PacketType.Play.Client.HELD_ITEM_CHANGE, Map.of())); + } + + static PacketWrapper create(PacketTypeCommon type, Map values) { + var schema = PacketFieldRegistry.INSTANCE.getSchema(type); + return schema.constructor().apply(new ConstructionContext<>(type, schema.accessors(), values)); + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldBehaviorTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldBehaviorTest.java new file mode 100644 index 0000000..b59f242 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldBehaviorTest.java @@ -0,0 +1,123 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.entity.type.EntityTypes; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPlayerFlying; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientUpdateSign; +import com.github.retrooper.packetevents.wrapper.play.server.*; +import org.bukkit.Bukkit; +import org.bukkit.block.sign.Side; +import org.bukkit.plugin.PluginManager; +import org.bukkit.util.Vector; +import org.crebs.skpackets.api.entity.metadata.EntityMeta; +import org.crebs.skpackets.api.general.EntityTracker; +import org.crebs.skpackets.api.util.field.FieldAccessor; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketFieldBehaviorTest { + @BeforeAll static void initialize() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + @Test void cameraDestroyAndSignPacketsUseDiscoveredFieldsAndNativeValues() { + var camera = (WrapperPlayServerCamera) PacketCreationCoverageTest.create(PacketType.Play.Server.CAMERA, Map.of("camera id", 42)); + assertEquals(42, camera.getCameraId()); + var destroy = (WrapperPlayServerDestroyEntities) PacketCreationCoverageTest.create(PacketType.Play.Server.DESTROY_ENTITIES, + Map.of("entity ids", new Number[]{1, 2, 3})); + assertArrayEquals(new int[]{1, 2, 3}, destroy.getEntityIds()); + var sign = (WrapperPlayServerOpenSignEditor) PacketCreationCoverageTest.create(PacketType.Play.Server.OPEN_SIGN_EDITOR, + Map.of("position", new Vector(1, 2, 3), "sign side", Side.BACK)); + assertFalse(sign.isFrontText()); + var side = field(PacketType.Play.Server.OPEN_SIGN_EDITOR, "sign side"); + assertSame(Side.class, side.expectedType()); + assertSame(Side.BACK, side.getter().apply(sign)); + side.setter().accept(sign, Side.FRONT); + assertTrue(sign.isFrontText()); + } + + @Test void serverboundPacketsUseTheSameRegistrarWithNativeValuesAndDefaults() { + var flying = (WrapperPlayClientPlayerFlying) PacketCreationCoverageTest.create(PacketType.Play.Client.PLAYER_FLYING, + Map.of("on ground", true)); + assertTrue(flying.isOnGround()); + assertFalse(flying.hasPositionChanged()); + assertFalse(flying.hasRotationChanged()); + assertFalse(flying.isHorizontalCollision()); + + var sign = (WrapperPlayClientUpdateSign) PacketCreationCoverageTest.create(PacketType.Play.Client.UPDATE_SIGN, + Map.of("block position", new Vector(1, 2, 3), "text lines", new String[]{"one", "two", "three", "four"}, "sign side", Side.BACK)); + assertFalse(sign.isFrontText()); + assertArrayEquals(new String[]{"one", "two", "three", "four"}, sign.getTextLines()); + var side = field(PacketType.Play.Client.UPDATE_SIGN, "sign side"); + assertSame(Side.BACK, side.getter().apply(sign)); + side.setter().accept(sign, Side.FRONT); + assertTrue(sign.isFrontText()); + } + + @Test void closeWindowAndHighlightKeepTheirOmittedFieldDefaults() { + var close = (WrapperPlayServerCloseWindow) PacketCreationCoverageTest.create(PacketType.Play.Server.CLOSE_WINDOW, Map.of()); + assertEquals(0, close.getWindowId()); + assertTrue(PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.CLOSE_WINDOW).accessors().stream().allMatch(FieldAccessor::isOptional)); + var highlight = (WrapperPlayServerGameTestHighlightPos) PacketCreationCoverageTest.create(PacketType.Play.Server.GAME_TEST_HIGHLIGHT_POS, + Map.of("absolute pos", new Vector(1, 2, 3))); + assertEquals(new com.github.retrooper.packetevents.util.Vector3i(0, 0, 0), highlight.getRelativePos()); + assertTrue(field(PacketType.Play.Server.GAME_TEST_HIGHLIGHT_POS, "relative pos").isOptional()); + } + + @Test void blockStateRemainsBlockDataRatherThanANumericBlockId() { + var data = mock(org.bukkit.block.data.BlockData.class); + var state = mock(com.github.retrooper.packetevents.protocol.world.states.WrappedBlockState.class); + when(state.getGlobalId()).thenReturn(17); + try (var conversion = mockStatic(io.github.retrooper.packetevents.util.SpigotConversionUtil.class)) { + conversion.when(() -> io.github.retrooper.packetevents.util.SpigotConversionUtil.fromBukkitBlockData(data)).thenReturn(state); + var block = (WrapperPlayServerBlockChange) PacketCreationCoverageTest.create(PacketType.Play.Server.BLOCK_CHANGE, + Map.of("block position", new Vector(1, 2, 3), "block state", data)); + assertEquals(17, block.getBlockId()); + assertSame(org.bukkit.block.data.BlockData.class, field(PacketType.Play.Server.BLOCK_CHANGE, "block state").expectedType()); + } + } + + @Test void metadataStillAcceptsAndReturnsEntityMeta() { + var source = mock(EntityMeta.class); + var snapshot = mock(EntityMeta.class); + when(source.entityData()).thenReturn(List.of()); + var packet = (WrapperPlayServerEntityMetadata) PacketCreationCoverageTest.create(PacketType.Play.Server.ENTITY_METADATA, + Map.of("entity id", 71, "entity metadata", source)); + assertTrue(packet.getEntityMetadata().isEmpty()); + var meta = field(PacketType.Play.Server.ENTITY_METADATA, "entity metadata"); + assertSame(EntityMeta.class, meta.expectedType()); + var entityType = EntityTypes.ZOMBIE; + try (var tracker = mockStatic(EntityTracker.class); var metadata = mockStatic(EntityMeta.class)) { + tracker.when(() -> EntityTracker.getType(71)).thenReturn(entityType); + metadata.when(() -> EntityMeta.createMeta(71, entityType)).thenReturn(snapshot); + assertSame(snapshot, meta.getter().apply(packet)); + verify(snapshot).readPacket(packet); + } + } + + @Test void entityStatusKeepsItsSignedByteBehavior() { + var packet = (WrapperPlayServerEntityStatus) PacketCreationCoverageTest.create(PacketType.Play.Server.ENTITY_STATUS, + Map.of("entity id", 71, "status", 255)); + assertEquals(-1, packet.getStatus()); + field(PacketType.Play.Server.ENTITY_STATUS, "status").setter().accept(packet, 128); + assertEquals(-128, packet.getStatus()); + } + + @SuppressWarnings("unchecked") + private static FieldAccessor, Object> field(PacketTypeCommon type, String name) { + return (FieldAccessor, Object>) PacketFieldRegistry.INSTANCE.getSchema(type).getAccessor(name); + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/ServerBoundPacketFieldRegistrarTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldNullabilityTest.java similarity index 97% rename from src/test/java/org/crebs/skpackets/elements/general/field/ServerBoundPacketFieldRegistrarTest.java rename to src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldNullabilityTest.java index ac18cab..5353da5 100644 --- a/src/test/java/org/crebs/skpackets/elements/general/field/ServerBoundPacketFieldRegistrarTest.java +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldNullabilityTest.java @@ -18,7 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.*; -class ServerBoundPacketFieldRegistrarTest { +class PacketFieldNullabilityTest { @BeforeAll static void registerFields() { PacketEventsTestBootstrap.initialize(); @@ -37,7 +37,7 @@ static void registerFields() { ) void sneakingSetterHandlesNullableBoolean(Boolean value, boolean expected) { FieldSchema> schema = PacketFieldRegistry.INSTANCE.getSchema(Client.INTERACT_ENTITY); - FieldAccessor, Boolean> field = (FieldAccessor, Boolean>) schema.getAccessor("sneaking state"); + FieldAccessor, Boolean> field = (FieldAccessor, Boolean>) schema.getAccessor("sneaking"); assertNotNull(field); assertNotNull(field.setter()); WrapperPlayClientInteractEntity packet = mock(WrapperPlayClientInteractEntity.class); diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldSectionsTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldSectionsTest.java new file mode 100644 index 0000000..4a01a83 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldSectionsTest.java @@ -0,0 +1,96 @@ +package org.crebs.skpackets.elements.general.field; + +import ch.njol.skript.Skript; +import ch.njol.skript.config.Config; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.skript.lang.util.SimpleLiteral; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.crebs.skpackets.api.util.field.FieldSections; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketFieldSectionsTest { + @Test void fixedInvalidEnumRejectsTheSectionDuringLoading() throws Exception { + var schema = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.BOSS_BAR); + var uuid = new UUID(1, 2); + try (var skript = mockStatic(Skript.class); + var parsers = mockConstruction(SkriptParser.class, (parser, context) -> { + String input = (String)context.arguments().getFirst(); + Object value = input.equals("uuid-value") ? uuid : "update"; + doReturn(new SimpleLiteral<>(value, false)).when(parser).parseExpression(any(Class[].class)); + })) { + var text = "uuid: uuid-value\naction: \"update\"\n"; + var node = new Config(new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)), "test.sk", true, true, ":").getMainNode(); + assertFalse(PacketFieldSections.parse(schema.accessors(), node, new HashMap<>(), "boss bar")); + skript.verify(() -> Skript.error(contains("Invalid value for packet field 'action': 'update'"))); + } + } + + @BeforeAll static void initialize() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + @Test void parsesNestedListAndProfileThenBuildsActualPlayerInfoPacket() throws Exception { + UUID uuid = new UUID(1, 2); + String text = """ + actions: "update latency" + entries: + first: + profile: + uuid: uuid-value + name: "Example" + latency: 42 + """; + var schema = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.PLAYER_INFO_UPDATE); + Map> expressions = new HashMap<>(); + try (var parsers = mockConstruction(SkriptParser.class, (parser, context) -> { + String input = (String)context.arguments().getFirst(); + Object value = input.equals("uuid-value") ? uuid : input.equals("42") ? 42 : input.replace("\"", ""); + doReturn(new SimpleLiteral<>(value, false)).when(parser).parseExpression(any(Class[].class)); + })) { + var node = new Config(new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)), "test.sk", true, true, ":").getMainNode(); + assertTrue(PacketFieldSections.parse(schema.accessors(), node, expressions, "player info")); + var packet = (WrapperPlayServerPlayerInfoUpdate) FieldSections.create(schema, PacketType.Play.Server.PLAYER_INFO_UPDATE, expressions, mock(org.bukkit.event.Event.class), Map.of()); + assertNotNull(packet); + assertEquals(EnumSet.of(WrapperPlayServerPlayerInfoUpdate.Action.UPDATE_LATENCY), packet.getActions()); + assertEquals(1, packet.getEntries().size()); + assertEquals(uuid, packet.getEntries().getFirst().getProfileId()); + assertEquals(42, packet.getEntries().getFirst().getLatency()); + } + } + + @Test void rejectsBothCanonicalNameAndAliasInsteadOfSilentlyIgnoringOne() throws Exception { + var schema = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.PLAYER_ABILITIES); + String text = "god mode: true\nin god mode: false\n"; + try (var skript = mockStatic(Skript.class)) { + var node = new Config(new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)), "test.sk", true, true, ":").getMainNode(); + assertFalse(PacketFieldSections.parse(schema.accessors(), node, new HashMap<>(), "player abilities")); + skript.verify(() -> Skript.error(contains("Duplicate entries"))); + } + } + + @Test void rejectsNestedSectionForUnsupportedComplexShape() throws Exception { + var schema = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.CAMERA); + try (var skript = mockStatic(Skript.class)) { + var node = new Config(new ByteArrayInputStream("camera id:\n value: 1\n".getBytes(StandardCharsets.UTF_8)), "test.sk", true, true, ":").getMainNode(); + assertFalse(PacketFieldSections.parse(schema.accessors(), node, new HashMap<>(), "camera")); + skript.verify(() -> Skript.error(contains("does not support nested entries"))); + } + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketRegistrationIntegrationTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketRegistrationIntegrationTest.java new file mode 100644 index 0000000..d63f010 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketRegistrationIntegrationTest.java @@ -0,0 +1,139 @@ +package org.crebs.skpackets.elements.general.field; + +import ch.njol.skript.Skript; +import ch.njol.skript.classes.ClassInfo; +import ch.njol.skript.registrations.Classes; +import com.github.shanebeee.skr.Registration; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.crebs.skpackets.elements.general.GeneralModule; +import org.crebs.skpackets.elements.simple.SimpleModule; +import org.crebs.skpackets.elements.team.TeamModule; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.crebs.skpackets.support.SkriptRegistrationTestSupport; +import org.junit.jupiter.api.Test; +import org.skriptlang.skript.bukkit.lang.eventvalue.EventValueRegistry; +import org.skriptlang.skript.lang.comparator.Comparators; +import org.skriptlang.skript.lang.converter.Converters; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketRegistrationIntegrationTest { + @SuppressWarnings("removal") + @Test + void finalizesRealClassInfosAndSyntaxRegistrations() throws Exception { + PacketEventsTestBootstrap.initialize(); + var core = org.skriptlang.skript.Skript.of(getClass(), "SkriptIntegrationCore"); + core.storeRegistry(EventValueRegistry.class, EventValueRegistry.empty(core)); + try (var snapshots = new RegistrySnapshots(Classes.class, Comparators.class, Converters.class, org.crebs.skpackets.elements.general.section.SecExprNewPacket.class); + var skript = mockStatic(Skript.class); + var bukkit = mockStatic(Bukkit.class)) { + skript.when(Skript::instance).thenReturn(core); + skript.when(Skript::getVersion).thenReturn(new ch.njol.skript.util.Version("2.17.1")); + var runtimeErrors = mock(org.skriptlang.skript.log.runtime.RuntimeErrorManager.class); + when(runtimeErrors.removeAllConsumers()).thenReturn(List.of()); + when(runtimeErrors.removeConsumer(any())).thenReturn(true); + skript.when(Skript::getRuntimeErrorManager).thenReturn(runtimeErrors); + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + var server = mock(org.bukkit.Server.class, RETURNS_DEEP_STUBS); + when(server.getServerConfig().isProxyOnlineMode()).thenReturn(true); + bukkit.when(Bukkit::getServer).thenReturn(server); + Field packetRegistry = org.crebs.skpackets.api.util.field.BaseFieldRegistry.class.getDeclaredField("REGISTRY"); + packetRegistry.setAccessible(true); + Map packetSchemas = (Map) packetRegistry.get(PacketFieldRegistry.INSTANCE); + snapshots.capture(packetSchemas); + packetSchemas.clear(); + try (var functions = SkriptRegistrationTestSupport.initialize()) { + Registration registration = new Registration("PacketRegistrationIntegration", true); + registration.getAddon().loadModules(new GeneralModule(registration), new SimpleModule(registration), new TeamModule(registration)); + org.crebs.skpackets.elements.entity.Types.register(registration); + org.crebs.skpackets.elements.entity.types.PacketEntity.register(registration); + Field infoField = Registration.TypeRegistrar.class.getDeclaredField("classInfo"); + infoField.setAccessible(true); + List> infos = new ArrayList<>(); + Set> classes = new HashSet<>(); + Set codes = new HashSet<>(); + for (var queued : registration.getTypes()) { + ClassInfo info = (ClassInfo) infoField.get(queued); + assertTrue(classes.add(info.getC()), () -> "Duplicate queued class: " + info.getC()); + assertTrue(codes.add(info.getCodeName()), () -> "Duplicate queued codename: " + info.getCodeName()); + infos.add(info); + } + assertTrue(infos.size() < 25, "Packet expansion must not register dozens of public types"); + assertTrue(classes.contains(PacketValue.class)); + assertTrue(classes.contains(org.crebs.skpackets.api.general.Vector2.class)); + assertFalse(classes.contains(com.github.retrooper.packetevents.protocol.player.Equipment.class)); + assertFalse(classes.contains(com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBossBar.Action.class)); + assertDoesNotThrow(registration::finalizeRegistration); + for (ClassInfo info : infos) { + assertSame(info, Classes.getExactClassInfo(info.getC())); + assertSame(info, Classes.getClassInfoNoError(info.getCodeName())); + + } + assertNull(Classes.getExactClassInfo(com.github.retrooper.packetevents.protocol.ConnectionState.class)); + assertEquals(1, registration.getFunctions().size()); + Field functionField = Registration.FunctionRegistrar.class.getDeclaredField("function"); + functionField.setAccessible(true); + var function = (org.skriptlang.skript.common.function.DefaultFunction) functionField.get(registration.getFunctions().getFirst()); + assertArrayEquals(new Object[]{new org.crebs.skpackets.api.general.Vector2(2, 3)}, + ((ch.njol.skript.lang.function.Function)function).execute(null, new Object[][]{{2L}, {3L}})); + Classes.onRegistrationsStop(); + for (String syntax : List.of("vec2(2, 3)", "vec2(2,3)", "vector2 2, 3", "vector2 2,3")) { + try (var parseLog = new ch.njol.skript.log.ParseLogHandler().start()) { + var parsed = new ch.njol.skript.lang.SkriptParser(syntax).parseExpression(org.crebs.skpackets.api.general.Vector2.class); + assertNotNull(parsed, "Skript must parse " + syntax + ": " + parseLog.getError()); + assertEquals(new org.crebs.skpackets.api.general.Vector2(2, 3), parsed.getSingle(mock(org.bukkit.event.Event.class))); + } + } + var vectorInfo = Classes.getExactClassInfo(org.crebs.skpackets.api.general.Vector2.class); + assertEquals("x: 1, y: 5", Classes.toString(new org.crebs.skpackets.api.general.Vector2(1, 5))); + assertEquals("x: 1.25, y: -5.5", Classes.toString(new org.crebs.skpackets.api.general.Vector2(1.25, -5.5))); + var vector = new org.crebs.skpackets.api.general.Vector2(2.125, -3.5); + var storage = new ch.njol.yggdrasil.Yggdrasil(); + storage.registerClassResolver(vectorInfo.getSerializer()); + var bytes = new java.io.ByteArrayOutputStream(); + try (var output = storage.newOutputStream(bytes)) { output.writeObject(vector); } + try (var input = storage.newInputStream(new java.io.ByteArrayInputStream(bytes.toByteArray()))) { + assertEquals(vector, input.readObject(org.crebs.skpackets.api.general.Vector2.class)); + } + assertNotNull(Classes.getExactClassInfo(org.crebs.skpackets.api.entity.metadata.EntityMeta.class)); + assertFalse(registration.getEffects().isEmpty()); + assertFalse(registration.getExpressions().isEmpty()); + } + } + } + + private static final class RegistrySnapshots implements AutoCloseable { + private final Map snapshots = new IdentityHashMap<>(); + RegistrySnapshots(Class... owners) throws IllegalAccessException { + for (Class owner : owners) { + for (Field field : owner.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers())) continue; + field.setAccessible(true); + Object value = field.get(null); + if (value instanceof Map map) snapshots.put(value, new HashMap<>(map)); + else if (value instanceof Collection collection) snapshots.put(value, new ArrayList<>(collection)); + } + } + } + void capture(Map map) { snapshots.put(map, new HashMap<>(map)); } + @SuppressWarnings({"rawtypes", "unchecked"}) + @Override public void close() { + snapshots.forEach((target, saved) -> { + if (target instanceof Map map) { + map.clear(); + map.putAll((Map) saved); + } else if (target instanceof Collection collection) { + collection.clear(); + collection.addAll((Collection) saved); + } + }); + } + } +} + diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketWireTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketWireTest.java new file mode 100644 index 0000000..f078f43 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketWireTest.java @@ -0,0 +1,149 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.manager.server.ServerVersion; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.player.InteractionHand; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientUseItem; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBossBar; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.Arguments; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.UUID; +import java.util.EnumSet; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketWireTest { + static Stream bossBarVersionsAndActions() { + return Stream.of(ServerVersion.V_1_20_5, ServerVersion.V_1_21, ServerVersion.V_26_3) + .flatMap(version -> Stream.of(WrapperPlayServerBossBar.Action.values()).map(action -> Arguments.of(version, action))); + } + + @ParameterizedTest @MethodSource("bossBarVersionsAndActions") + void bossBarActionsWriteAndReadWithAllOptionalFieldsOmitted(ServerVersion version, WrapperPlayServerBossBar.Action action) { + var packet = (WrapperPlayServerBossBar) PacketCreationCoverageTest.create(PacketType.Play.Server.BOSS_BAR, + Map.of("uuid", new UUID(1, 2), "action", action.name().toLowerCase(java.util.Locale.ROOT).replace('_', ' '))); + var decoded = roundTripBossBar(packet, version); + assertEquals(packet.getUUID(), decoded.getUUID()); + assertEquals(action, decoded.getAction()); + if (action == WrapperPlayServerBossBar.Action.ADD || action == WrapperPlayServerBossBar.Action.UPDATE_FLAGS) + assertEquals(EnumSet.noneOf(BossBar.Flag.class), decoded.getFlags()); + if (action == WrapperPlayServerBossBar.Action.ADD || action == WrapperPlayServerBossBar.Action.UPDATE_TITLE) + assertEquals(Component.empty(), decoded.getTitle()); + if (action == WrapperPlayServerBossBar.Action.ADD || action == WrapperPlayServerBossBar.Action.UPDATE_STYLE) { + assertEquals(BossBar.Color.WHITE, decoded.getColor()); + assertEquals(BossBar.Overlay.PROGRESS, decoded.getOverlay()); + } + if (action == WrapperPlayServerBossBar.Action.ADD || action == WrapperPlayServerBossBar.Action.UPDATE_HEALTH) + assertEquals(1f, decoded.getHealth()); + } + + @Test void documentedBossBarExampleWritesWithoutProvidingFlags() { + var packet = (WrapperPlayServerBossBar) PacketCreationCoverageTest.create(PacketType.Play.Server.BOSS_BAR, + Map.of("uuid", new UUID(1, 2), "action", "add", "title", Component.text("Preparing your next quest"), + "health", 0.75, "color", ch.njol.skript.util.SkriptColor.DARK_PURPLE, "overlay", org.bukkit.boss.BarStyle.SOLID)); + var decoded = roundTripBossBar(packet, ServerVersion.V_26_3); + assertEquals(Component.text("Preparing your next quest"), decoded.getTitle()); + assertEquals(0.75f, decoded.getHealth()); + assertEquals(BossBar.Color.PURPLE, decoded.getColor()); + assertTrue(decoded.getFlags().isEmpty()); + } + + @Test void explicitBossBarFlagsOverrideDefaultsAndAreNotSharedBetweenPackets() { + var packet = (WrapperPlayServerBossBar) PacketCreationCoverageTest.create(PacketType.Play.Server.BOSS_BAR, + Map.of("uuid", new UUID(1, 2), "action", "add", "flags", new String[]{"darken screen", "play boss music", "create world fog"})); + var decoded = roundTripBossBar(packet, ServerVersion.V_26_3); + assertEquals(EnumSet.allOf(BossBar.Flag.class), decoded.getFlags()); + var first = (WrapperPlayServerBossBar) PacketCreationCoverageTest.create(PacketType.Play.Server.BOSS_BAR, + Map.of("uuid", new UUID(1, 3), "action", "add")); + var second = (WrapperPlayServerBossBar) PacketCreationCoverageTest.create(PacketType.Play.Server.BOSS_BAR, + Map.of("uuid", new UUID(1, 4), "action", "add")); + first.getFlags().add(BossBar.Flag.DARKEN_SCREEN); + assertTrue(second.getFlags().isEmpty()); + } + + private static WrapperPlayServerBossBar roundTripBossBar(WrapperPlayServerBossBar packet, ServerVersion version) { + ByteBuf buffer = Unpooled.buffer(); + try { + packet.setServerVersion(version); + packet.setBuffer(buffer); + packet.write(); + var decoded = new WrapperPlayServerBossBar(new UUID(0, 0), WrapperPlayServerBossBar.Action.REMOVE); + decoded.setServerVersion(version); + decoded.setBuffer(buffer); + decoded.read(); + assertEquals(0, buffer.readableBytes()); + return decoded; + } finally { buffer.release(); } + } + + @BeforeAll static void initialize() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + static Stream versionsAndHands() { + return Stream.of(ServerVersion.V_1_18, ServerVersion.V_1_19, ServerVersion.V_1_21, ServerVersion.V_26_3) + .flatMap(version -> Stream.of(InteractionHand.values()).map(hand -> Arguments.of(version, hand))); + } + + @ParameterizedTest @MethodSource("versionsAndHands") + void useItemWritesExactBytesAndReadsBack(ServerVersion version, InteractionHand hand) { + var packet = (WrapperPlayClientUseItem) PacketCreationCoverageTest.create(PacketType.Play.Client.USE_ITEM, + Map.of("hand", hand, "sequence", 300L, "yaw", 12.5d, "pitch", -4d)); + ByteBuf buffer = Unpooled.buffer(); + try { + packet.setServerVersion(version); + packet.setBuffer(buffer); + packet.write(); + ByteBuffer expected = ByteBuffer.allocate(11); + expected.put((byte) hand.ordinal()); + boolean sequence = version.isNewerThanOrEquals(ServerVersion.V_1_19); + boolean angles = version.isNewerThanOrEquals(ServerVersion.V_1_21); + if (sequence) expected.put((byte) 0xac).put((byte) 0x02); + if (angles) expected.putFloat(12.5f).putFloat(-4f); + assertArrayEquals(java.util.Arrays.copyOf(expected.array(), expected.position()), ByteBufUtil.getBytes(buffer)); + var decoded = new WrapperPlayClientUseItem(InteractionHand.MAIN_HAND); + decoded.setServerVersion(version); + decoded.setBuffer(buffer); + decoded.read(); + assertSame(hand, decoded.getHand()); + assertEquals(sequence ? 300 : 0, decoded.getSequence()); + assertEquals(angles ? 12.5f : 0f, decoded.getYaw()); + assertEquals(angles ? -4f : 0f, decoded.getPitch()); + assertEquals(0, buffer.readableBytes()); + } finally { buffer.release(); } + } + + @Test void rawFallbackWritesOnlyTheProvidedPayloadAndCopiesIndependently() { + var packet = (PacketFieldRegistrar.RawPacket) PacketCreationCoverageTest.create( + PacketType.Play.Server.STOP_SOUND, Map.of("raw data", new Number[]{0, 255, 127})); + var copy = new PacketFieldRegistrar.RawPacket(PacketType.Play.Server.STOP_SOUND, new Number[0]); + copy.copy(packet); + packet.setData(new Number[]{1}); + ByteBuf buffer = Unpooled.buffer(); + try { + copy.setBuffer(buffer); + copy.write(); + assertArrayEquals(new byte[]{0, -1, 127}, ByteBufUtil.getBytes(buffer)); + } finally { buffer.release(); } + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketWrapperConstructionTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketWrapperConstructionTest.java new file mode 100644 index 0000000..538ace7 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketWrapperConstructionTest.java @@ -0,0 +1,82 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import org.crebs.skpackets.api.general.packet.PacketTypeRegistry; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.*; + +import java.lang.reflect.*; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketWrapperConstructionTest { + @BeforeAll static void initialize() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + @TestFactory List constructsEveryDiscoveredWrapper() { + List tests = new ArrayList<>(); + for (PacketTypeCommon type : PacketTypeRegistry.getAllPackets()) { + var wrapper = PacketFieldRegistrar.wrapperClass(type); + if (wrapper == null) continue; + var schema = PacketFieldRegistry.INSTANCE.getSchema(type); + if (schema.getAccessor("raw data") != null) continue; + if (schema.constructor().getClass().getNestHost() != PacketFieldRegistrar.class) continue; + var constructor = PacketConstructor.find(wrapper); + var methods = PacketReflection.methods(wrapper); + tests.add(DynamicTest.dynamicTest(wrapper.getSimpleName(), () -> { + Map values = new HashMap<>(); + for (var argument : constructor.arguments()) { + if (argument.injected() || argument.nullable()) continue; + String name = PacketFieldBinding.parameter(wrapper, argument, methods).name(); + if (schema.getAccessor(name).isOptional()) continue; + values.put(name, sample(argument.type())); + } + PacketWrapper packet = PacketCreationCoverageTest.create(type, values); + assertInstanceOf(wrapper, packet); + assertEquals(type, packet.getPacketTypeData().getPacketType(), "Constructor must preserve direction/state/type"); + })); + } + assertTrue(tests.size() > 250, "Real construction coverage unexpectedly shrank: " + tests.size()); + return tests; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object sample(Type type) { + Class raw = PacketArguments.raw(type); + if (raw == boolean.class || raw == Boolean.class) return true; + if (raw == int.class || raw == Integer.class) return 1; + if (raw == byte.class || raw == Byte.class) return (byte) 1; + if (raw == short.class || raw == Short.class) return (short) 1; + if (raw == long.class || raw == Long.class) return 1L; + if (raw == float.class || raw == Float.class) return 1f; + if (raw == double.class || raw == Double.class) return 1d; + if (raw == String.class) return "test"; + if (raw == UUID.class) return new UUID(0, 1); + if (raw.isEnum()) return raw.getEnumConstants()[0]; + if (raw == Optional.class) return Optional.empty(); + if (raw == EnumSet.class) return EnumSet.noneOf((Class) PacketArguments.raw(((ParameterizedType) type).getActualTypeArguments()[0])); + if (Set.class.isAssignableFrom(raw)) return Set.of(); + if (Collection.class.isAssignableFrom(raw)) return List.of(); + if (Map.class.isAssignableFrom(raw)) return Map.of(); + if (raw.isArray()) return Array.newInstance(raw.getComponentType(), 0); + if (raw == net.kyori.adventure.text.Component.class) return net.kyori.adventure.text.Component.text("Test"); + if (raw == com.github.retrooper.packetevents.resources.ResourceLocation.class) return new com.github.retrooper.packetevents.resources.ResourceLocation("minecraft:test"); + if (raw == com.github.retrooper.packetevents.protocol.nbt.NBTCompound.class) return new com.github.retrooper.packetevents.protocol.nbt.NBTCompound(); + if (raw == com.github.retrooper.packetevents.protocol.item.ItemStack.class) return com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; + if (raw == com.github.retrooper.packetevents.util.Vector3i.class) return new com.github.retrooper.packetevents.util.Vector3i(1, 2, 3); + if (raw == com.github.retrooper.packetevents.util.Vector3f.class) return new com.github.retrooper.packetevents.util.Vector3f(1, 2, 3); + if (raw == com.github.retrooper.packetevents.util.Vector3d.class) return new com.github.retrooper.packetevents.util.Vector3d(1, 2, 3); + return mock(raw, RETURNS_DEEP_STUBS); + } +} + diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/RequiredPacketFieldReadbackTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/RequiredPacketFieldReadbackTest.java new file mode 100644 index 0000000..7512a66 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/RequiredPacketFieldReadbackTest.java @@ -0,0 +1,228 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.protocol.player.InteractionHand; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.common.client.WrapperCommonClientSettings; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerExplosion; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.bukkit.util.Vector; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RequiredPacketFieldReadbackTest { + @BeforeAll + static void initialize() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + @Test + void animationHandReadsTheValueUsedToConstructThePacket() { + PacketTypeCommon type = PacketType.Play.Client.ANIMATION; + var packet = PacketCreationCoverageTest.create(type, Map.of("interaction hand", InteractionHand.OFF_HAND)); + assertSame(InteractionHand.OFF_HAND, read(type, packet, "interaction hand")); + write(type, packet, "interaction hand", InteractionHand.MAIN_HAND); + assertSame(InteractionHand.MAIN_HAND, read(type, packet, "interaction hand")); + } + + @Test + void structureKeepJigsawsReadsTheBooleanUsedToConstructThePacket() { + PacketTypeCommon type = PacketType.Play.Client.GENERATE_STRUCTURE; + var packet = PacketCreationCoverageTest.create(type, + Map.of("block position", new Vector(1, 2, 3), "levels", 7L, "keep jigsaws", true)); + assertEquals(true, read(type, packet, "keep jigsaws")); + write(type, packet, "keep jigsaws", false); + assertEquals(false, read(type, packet, "keep jigsaws")); + } + + @Test + void serverAbilitiesExposeGodAndCreativeModeAsBooleans() { + PacketTypeCommon type = PacketType.Play.Server.PLAYER_ABILITIES; + var packet = PacketCreationCoverageTest.create(type, Map.of("god mode", true, "flying", false, + "flight allowed", true, "creative mode", true, "fly speed", 0.125d, "fov modifier", 0.25d)); + assertEquals(true, read(type, packet, "god mode")); + assertEquals(true, read(type, packet, "creative mode")); + write(type, packet, "god mode", false); + write(type, packet, "creative mode", false); + assertEquals(false, read(type, packet, "god mode")); + assertEquals(false, read(type, packet, "creative mode")); + } + + @Test + void clientAbilitiesUnwrapOptionalBooleans() { + PacketTypeCommon type = PacketType.Play.Client.PLAYER_ABILITIES; + var packet = PacketCreationCoverageTest.create(type, + Map.of("flying", true, "god mode", true, "creative mode", true)); + assertEquals(true, read(type, packet, "god mode")); + assertEquals(true, read(type, packet, "creative mode")); + write(type, packet, "god mode", false); + write(type, packet, "creative mode", false); + assertEquals(false, read(type, packet, "god mode")); + assertEquals(false, read(type, packet, "creative mode")); + } + + @Test + void spectateUuidReadsTheActualTarget() { + PacketTypeCommon type = PacketType.Play.Client.SPECTATE; + UUID uuid = new UUID(12, 34); + var packet = PacketCreationCoverageTest.create(type, Map.of("uuid", uuid)); + assertEquals(uuid, read(type, packet, "uuid")); + UUID replacement = new UUID(56, 78); + write(type, packet, "uuid", replacement); + assertEquals(replacement, read(type, packet, "uuid")); + } + + @Test + void timeUpdateGameTimeReadsWorldAge() { + PacketTypeCommon type = PacketType.Play.Server.TIME_UPDATE; + var packet = PacketCreationCoverageTest.create(type, + Map.of("game time", 1234567890123L, "time of day", 12345L, "tick time", true)); + assertEquals(1234567890123L, read(type, packet, "game time")); + write(type, packet, "game time", 9876543210123L); + assertEquals(9876543210123L, read(type, packet, "game time")); + } + + @Test + void windowIdReadsContainerIdAsANumber() { + PacketTypeCommon type = PacketType.Play.Server.WINDOW_PROPERTY; + var packet = PacketCreationCoverageTest.create(type, + Map.of("window id", 15L, "id", 2L, "value", 99L)); + assertEquals(15, read(type, packet, "window id")); + write(type, packet, "window id", 300L); + assertEquals(300, read(type, packet, "window id")); + } + + @Test + void clientSettingsReadTheModernVisibilityEnumAndNativeHand() { + PacketTypeCommon type = PacketType.Play.Client.CLIENT_SETTINGS; + var packet = PacketCreationCoverageTest.create(type, Map.ofEntries( + Map.entry("locale", "en_gb"), Map.entry("view distance", 17L), + Map.entry("visibility", WrapperCommonClientSettings.ChatVisibility.SYSTEM), + Map.entry("chat colors", false), Map.entry("visible skin section mask", 90L), + Map.entry("hand", org.bukkit.inventory.MainHand.LEFT), + Map.entry("text filtering enabled", true), Map.entry("allow server listings", true), + Map.entry("particle status", WrapperCommonClientSettings.ParticleStatus.MINIMAL))); + assertEquals("system", read(type, packet, "visibility")); + assertSame(org.bukkit.inventory.MainHand.LEFT, read(type, packet, "hand")); + assertEquals(true, read(type, packet, "allow server listings")); + write(type, packet, "visibility", WrapperCommonClientSettings.ChatVisibility.HIDDEN); + write(type, packet, "hand", org.bukkit.inventory.MainHand.RIGHT); + write(type, packet, "allow server listings", false); + assertEquals("hidden", read(type, packet, "visibility")); + assertSame(org.bukkit.inventory.MainHand.RIGHT, read(type, packet, "hand")); + assertEquals(false, read(type, packet, "allow server listings")); + } + + @Test + void positionFlagsReadTheByteMaskInsteadOfTheRelativeFlagObject() { + PacketTypeCommon type = PacketType.Play.Server.PLAYER_POSITION_AND_LOOK; + var packet = PacketCreationCoverageTest.create(type, Map.ofEntries( + Map.entry("x", 1d), Map.entry("y", 2d), Map.entry("z", 3d), + Map.entry("yaw", 10d), Map.entry("pitch", 20d), Map.entry("flags", 5L), + Map.entry("teleport id", 7L), Map.entry("dismount vehicle", false))); + assertEquals((byte) 5, read(type, packet, "flags")); + write(type, packet, "flags", 9L); + assertEquals((byte) 9, read(type, packet, "flags")); + } + + @Test + void explosionMotionKeepsDoublePrecisionAndSmallParticleCanBeRead() { + PacketTypeCommon type = PacketType.Play.Server.EXPLOSION; + var smallParticle = mock(com.github.retrooper.packetevents.protocol.particle.Particle.class); + var largeParticle = mock(com.github.retrooper.packetevents.protocol.particle.Particle.class); + Vector motion = new Vector(1.1234567890123, 2.9876543210987, -3.1234567890123); + var packet = PacketCreationCoverageTest.create(type, Map.ofEntries( + Map.entry("position", new Vector(1, 2, 3)), Map.entry("strength", 4d), + Map.entry("records", new Vector[0]), Map.entry("player motion", motion), + Map.entry("small particle", smallParticle), Map.entry("particle", largeParticle), + Map.entry("block interaction", WrapperPlayServerExplosion.BlockInteraction.DESTROY_BLOCKS), + Map.entry("explosion sound", mock(com.github.retrooper.packetevents.protocol.sound.Sound.class)), + Map.entry("block particles", mock(com.github.retrooper.packetevents.protocol.util.WeightedList.class)))); + assertEquals(motion, read(type, packet, "player motion")); + assertEquals(new PacketValue(smallParticle), read(type, packet, "small particle")); + Vector replacement = new Vector(9.1234567890123, 8.9876543210987, -7.1234567890123); + write(type, packet, "player motion", replacement); + write(type, packet, "small particle", largeParticle); + assertEquals(replacement, read(type, packet, "player motion")); + assertEquals(new PacketValue(largeParticle), read(type, packet, "small particle")); + } + + @Test + void playerInfoCollectionsKeepTheirDeclaredArrayTypesAndAllElements() { + PacketTypeCommon type = PacketType.Play.Server.PLAYER_INFO; + var first = mock(com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfo.PlayerData.class); + var second = mock(com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfo.PlayerData.class); + var values = new com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfo.PlayerData[]{first, second}; + var packet = PacketCreationCoverageTest.create(type, Map.of("action", + com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfo.Action.ADD_PLAYER, + "player data list", values)); + assertArrayEquals(new PacketValue[]{new PacketValue(first), new PacketValue(second)}, (Object[]) read(type, packet, "player data list")); + } + + @Test + void modernPlayerInfoSupportsMultipleActionsAndNonemptyEntries() { + PacketTypeCommon type = PacketType.Play.Server.PLAYER_INFO_UPDATE; + var entry = mock(com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate.PlayerInfo.class); + var actions = new com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate.Action[]{ + com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate.Action.UPDATE_LISTED, + com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate.Action.UPDATE_LATENCY}; + var entries = new com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate.PlayerInfo[]{entry}; + var packet = PacketCreationCoverageTest.create(type, Map.of("actions", actions, "entries", entries)); + assertArrayEquals(new String[]{"update listed", "update latency"}, (Object[]) read(type, packet, "actions")); + assertArrayEquals(new PacketValue[]{new PacketValue(entry)}, (Object[]) read(type, packet, "entries")); + } + + @Test + void equivalentConstructorAndPropertySpellingsShareOneAccessor() { + var abilities = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.PLAYER_ABILITIES); + assertSame(abilities.getAccessor("god mode"), abilities.getAccessor("in god mode")); + assertSame(abilities.getAccessor("creative mode"), abilities.getAccessor("in creative mode")); + var time = PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.TIME_UPDATE); + assertSame(time.getAccessor("game time"), time.getAccessor("world age")); + + } + + @Test + void onlyFieldsWithoutPublicPacketEventsGettersAreCreationOnly() { + java.util.Set missing = new java.util.TreeSet<>(); + for (var type : org.crebs.skpackets.api.general.packet.PacketTypeRegistry.getAllPackets()) { + var schema = PacketFieldRegistry.INSTANCE.getSchema(type); + for (var field : schema.accessors()) { + if (field.getter() == null) missing.add(type.getName() + ":" + field.name()); + } + } + assertEquals(java.util.Set.of("ENTITY_EFFECT:flags", "WORLD_BORDER_WARNING_DELAY:delay"), missing); + } + + @SuppressWarnings("unchecked") + private static void write(PacketTypeCommon type, PacketWrapper packet, String name, Object value) { + var field = (org.crebs.skpackets.api.util.field.FieldAccessor, Object>) + PacketFieldRegistry.INSTANCE.getSchema(type).getAccessor(name); + assertNotNull(field.setter(), "Public PacketEvents setter is not exposed: " + type + " / " + name); + field.setter().accept(packet, value); + } + + private static Object read(PacketTypeCommon type, PacketWrapper packet, String name) { + var field = PacketFieldRegistry.INSTANCE.getSchema(type).getAccessor(name); + assertNotNull(field, name); + Object value = field.getter().apply(packet); + assertNotNull(value, "Constructor field has no readable value: " + type + " / " + name); + assertTrue(field.expectedType().isInstance(value), + () -> "Read value type differs from declared Skript type for " + type + " / " + name + ": " + value.getClass()); + return value; + } +} + diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/TypedTeamFieldsTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/TypedTeamFieldsTest.java new file mode 100644 index 0000000..4e3e902 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/TypedTeamFieldsTest.java @@ -0,0 +1,103 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams.CollisionRule; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams.NameTagVisibility; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.crebs.skpackets.api.util.field.FieldAccessor; +import org.crebs.skpackets.elements.team.TeamField; +import org.crebs.skpackets.elements.team.expressions.ExprTeamCollisionRule; +import org.crebs.skpackets.elements.team.expressions.ExprTeamNameTagVisibility; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.Locale; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TypedTeamFieldsTest { + @BeforeAll static void initialize() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + @ParameterizedTest @EnumSource(CollisionRule.class) + void packetBuilderCollisionRuleUsesText(CollisionRule rule) { + var packet = mock(WrapperPlayServerTeams.class); + var info = mock(WrapperPlayServerTeams.ScoreBoardTeamInfo.class); + when(packet.getTeamInfo()).thenReturn(Optional.of(info)); + when(info.getCollisionRule()).thenReturn(rule); + var field = field("collision rule"); + String text = rule.name().toLowerCase(Locale.ROOT).replace('_', ' '); + field.setter().accept(packet, text); + verify(info).setCollisionRule(rule); + verify(packet).setTeamInfo(info); + assertSame(String.class, field.expectedType()); + assertEquals(text, field.getter().apply(packet)); + assertEquals("packet team collision rule", PacketValueTypes.label(CollisionRule.class)); + } + + @ParameterizedTest @EnumSource(NameTagVisibility.class) + void packetBuilderNameTagVisibilityUsesText(NameTagVisibility visibility) { + var packet = mock(WrapperPlayServerTeams.class); + var info = mock(WrapperPlayServerTeams.ScoreBoardTeamInfo.class); + when(packet.getTeamInfo()).thenReturn(Optional.of(info)); + when(info.getTagVisibility()).thenReturn(visibility); + var field = field("name tag visibility"); + String text = visibility.name().toLowerCase(Locale.ROOT).replace('_', ' '); + field.setter().accept(packet, text); + verify(info).setTagVisibility(visibility); + verify(packet).setTeamInfo(info); + assertSame(String.class, field.expectedType()); + assertEquals(text, field.getter().apply(packet)); + } + + @Test void packetBuilderRejectsUnknownTextValues() { + var packet = mock(WrapperPlayServerTeams.class); + when(packet.getTeamInfo()).thenReturn(Optional.of(mock(WrapperPlayServerTeams.ScoreBoardTeamInfo.class))); + assertThrows(IllegalArgumentException.class, () -> field("collision rule").setter().accept(packet, "invalid collision rule")); + assertThrows(IllegalArgumentException.class, () -> field("name tag visibility").setter().accept(packet, "invalid visibility")); + } + + @ParameterizedTest @EnumSource(CollisionRule.class) + void oldTeamCollisionExpressionStillAcceptsAndReturnsText(CollisionRule rule) { + var packet = mock(WrapperPlayServerTeams.class); + var info = mock(WrapperPlayServerTeams.ScoreBoardTeamInfo.class); + when(packet.getTeamInfo()).thenReturn(Optional.of(info)); + when(info.getCollisionRule()).thenReturn(rule); + String text = rule.name().toLowerCase(Locale.ROOT).replace('_', ' '); + TeamField.COLLISION_RULE.set(packet, text); + verify(info).setCollisionRule(rule); + assertEquals(text, TeamField.COLLISION_RULE.get(packet)); + assertSame(String.class, new ExprTeamCollisionRule().getReturnType()); + } + + @ParameterizedTest @EnumSource(NameTagVisibility.class) + void oldTeamVisibilityExpressionStillAcceptsAndReturnsText(NameTagVisibility visibility) { + var packet = mock(WrapperPlayServerTeams.class); + var info = mock(WrapperPlayServerTeams.ScoreBoardTeamInfo.class); + when(packet.getTeamInfo()).thenReturn(Optional.of(info)); + when(info.getTagVisibility()).thenReturn(visibility); + String text = visibility.name().toLowerCase(Locale.ROOT).replace('_', ' '); + TeamField.NAME_TAG_VISIBILITY.set(packet, text); + verify(info).setTagVisibility(visibility); + assertEquals(text, TeamField.NAME_TAG_VISIBILITY.get(packet)); + assertSame(String.class, new ExprTeamNameTagVisibility().getReturnType()); + } + + @SuppressWarnings("unchecked") + private static FieldAccessor, Object> field(String name) { + return (FieldAccessor, Object>) PacketFieldRegistry.INSTANCE.getSchema(PacketType.Play.Server.TEAMS).getAccessor(name); + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/general/section/SecExprNewPacketTest.java b/src/test/java/org/crebs/skpackets/elements/general/section/SecExprNewPacketTest.java index ff6497f..9d3a11b 100644 --- a/src/test/java/org/crebs/skpackets/elements/general/section/SecExprNewPacketTest.java +++ b/src/test/java/org/crebs/skpackets/elements/general/section/SecExprNewPacketTest.java @@ -39,13 +39,12 @@ void constructorReturningNullProducesNoPacket() throws Exception { @Test void invalidConstructionProducesErrorAndNoPacket() throws Exception { PacketTypeCommon type = mock(PacketTypeCommon.class); - SecExprNewPacket expression = expression(type, new FieldSchema<>(type, List.of(), context -> { + SecExprNewPacket expression = spy(expression(type, new FieldSchema<>(type, List.of(), context -> { throw new IllegalArgumentException("missing entity id"); - })); - try (MockedStatic skript = mockStatic(Skript.class, withSettings().mockMaker(INLINE))) { - assertArrayEquals(new PacketWrapper[0], expression.get(mock(Event.class))); - skript.verify(() -> Skript.error("missing entity id")); - } + }))); + doNothing().when(expression).error(anyString()); + assertArrayEquals(new PacketWrapper[0], expression.get(mock(Event.class))); + verify(expression).error("missing entity id"); } private SecExprNewPacket expression(PacketTypeCommon type, FieldSchema> schema) throws Exception { diff --git a/src/test/java/org/crebs/skpackets/support/PacketEventsTestBootstrap.java b/src/test/java/org/crebs/skpackets/support/PacketEventsTestBootstrap.java index 15aeac8..ebf648d 100644 --- a/src/test/java/org/crebs/skpackets/support/PacketEventsTestBootstrap.java +++ b/src/test/java/org/crebs/skpackets/support/PacketEventsTestBootstrap.java @@ -18,8 +18,13 @@ public static synchronized void initialize() { PacketEventsSettings settings = new PacketEventsSettings(); ServerManager server = mock(ServerManager.class); when(api.getSettings()).thenReturn(settings); + when(api.getLogManager()).thenReturn(mock(com.github.retrooper.packetevents.util.LogManager.class)); when(api.getServerManager()).thenReturn(server); when(server.getVersion()).thenReturn(ServerVersion.V_26_3); + var netty = mock(com.github.retrooper.packetevents.netty.NettyManager.class); + when(api.getNettyManager()).thenReturn(netty); + when(netty.getByteBufOperator()).thenReturn(new io.github.retrooper.packetevents.impl.netty.buffer.ByteBufOperatorImpl()); + when(netty.getByteBufAllocationOperator()).thenReturn(new io.github.retrooper.packetevents.impl.netty.buffer.ByteBufAllocationOperatorImpl()); PacketEvents.setAPI(api); } } diff --git a/src/test/java/org/crebs/skpackets/support/SkriptRegistrationTestSupport.java b/src/test/java/org/crebs/skpackets/support/SkriptRegistrationTestSupport.java new file mode 100644 index 0000000..730390e --- /dev/null +++ b/src/test/java/org/crebs/skpackets/support/SkriptRegistrationTestSupport.java @@ -0,0 +1,50 @@ +package org.crebs.skpackets.support; + +import ch.njol.skript.classes.ClassInfo; +import ch.njol.skript.classes.Parser; +import ch.njol.skript.registrations.Classes; + +import static org.mockito.Mockito.*; + +/** Supplies core host metadata missing from standalone addon registration tests. */ +public final class SkriptRegistrationTestSupport { + private SkriptRegistrationTestSupport() {} + @SuppressWarnings({"rawtypes", "unchecked"}) + public static AutoCloseable initialize() throws Exception { + try (var classes = mockStatic(Classes.class, CALLS_REAL_METHODS)) { + classes.when(() -> Classes.getExactClassInfo(any(Class.class))).thenAnswer(call -> { + ClassInfo info = mock(ClassInfo.class); + when(info.getParser()).thenReturn(mock(Parser.class)); + return info; + }); + Class.forName("ch.njol.skript.SkriptConfig"); + } + for (var entry : java.util.Map.ofEntries( + java.util.Map.entry(Object.class, "object"), java.util.Map.entry(Number.class, "number"), + java.util.Map.entry(Boolean.class, "boolean"), java.util.Map.entry(String.class, "string"), + java.util.Map.entry(org.bukkit.entity.Entity.class, "entity"), java.util.Map.entry(org.bukkit.entity.Player.class, "player"), + java.util.Map.entry(org.bukkit.block.data.BlockData.class, "blockdata"), java.util.Map.entry(org.bukkit.inventory.EquipmentSlot.class, "equipmentslot"), + java.util.Map.entry(ch.njol.skript.aliases.ItemType.class, "itemtype"), java.util.Map.entry(org.bukkit.Location.class, "location")).entrySet()) { + if (Classes.getExactClassInfo(entry.getKey()) == null) + Classes.registerClass(new ClassInfo(entry.getKey(), entry.getValue()).user(entry.getValue() + "s?").parser(new Parser() { + @Override public Object parse(String text, ch.njol.skript.lang.ParseContext context) { + if (entry.getKey() == Number.class) { + try { return new java.math.BigDecimal(text); } catch (NumberFormatException ignored) { return null; } + } + if (entry.getKey() == Boolean.class) return text.equals("true") ? true : text.equals("false") ? false : null; + return null; + } + @Override public String toString(Object value, int flags) { return String.valueOf(value); } + @Override public String toVariableNameString(Object value) { return String.valueOf(value); } + })); + } + var registry = ch.njol.skript.lang.function.FunctionRegistry.class.getDeclaredField("registry"); + registry.setAccessible(true); + Object previous = registry.get(null); + registry.set(null, new ch.njol.skript.lang.function.FunctionRegistry()); + var sortedTypes = Classes.class.getDeclaredField("classInfos"); + sortedTypes.setAccessible(true); + Object previousTypes = sortedTypes.get(null); + return () -> { registry.set(null, previous); sortedTypes.set(null, previousTypes); }; + } +} From 292b5bd42e38d336d5a6dab4a34dd00960b8f6e5 Mon Sep 17 00:00:00 2001 From: CrebsTheCoder Date: Tue, 10 Nov 2026 15:56:00 +0800 Subject: [PATCH 2/4] Added a PacketAcessManager Removed reflect invocation from the normal packet field access and construction. Added direct keep-alive registration. Replaced SkBee and debug metadata reflection with direct API calls. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance/Optimisations: Instead of cloning Bukkit BlockData per position it now use integer blockstate id and persistence flag + shared immutable value within a batch also the persistence tracking updates only affected positions in concurrent sets(used to rebuild position after every change) they are now handled in bulk instead of seperately, also checks if the chunks is visible (if its sent to the player yet or naw), also it sends multiblock change packet in a 16×16×16 section Similar thing is done for fake entity but im to lazy to write just read the changes sonion. --- README.md | 7 +- .../api/entity/PacketEntityManager.java | 175 +++++++-- .../entity/RealEntityPassengerManager.java | 62 +++- .../general/packet/PacketAccessManager.java | 140 +++++++ .../packet/PacketSendOrReceiveListener.java | 32 +- .../api/simple/FakeBlockManager.java | 273 +++++++++----- .../crebs/skpackets/api/util/DebugUtil.java | 101 ++--- .../expressions/ExprFakeEntityFromUuid.java | 4 +- .../entity/FakeBaseEntityFieldRegistrar.java | 5 +- .../general/field/DirectPacketFields.java | 27 ++ .../general/field/PacketConstructor.java | 26 +- .../general/field/PacketFieldBinding.java | 24 +- .../general/field/PacketFieldRegistry.java | 17 +- .../general/field/PacketReflection.java | 19 +- .../elements/simple/effects/EffFakeBlock.java | 12 +- .../api/entity/PacketEntityManagerTest.java | 227 ++++++++++++ .../RealEntityPassengerManagerTest.java | 5 + .../packet/PacketAccessManagerTest.java | 152 ++++++++ .../api/simple/FakeBlockManagerTest.java | 348 ++++++++++++++++++ .../skpackets/api/util/DebugUtilTest.java | 39 ++ .../elements/AddonRegistrationTest.java | 3 +- .../EntityLookupExpressionsTest.java | 22 +- .../general/field/DirectPacketFieldsTest.java | 48 +++ ...PacketFieldRegistryOptionalPluginTest.java | 59 +++ .../simple/effects/SimpleEffectsTest.java | 5 +- 25 files changed, 1548 insertions(+), 284 deletions(-) create mode 100644 src/main/java/org/crebs/skpackets/api/general/packet/PacketAccessManager.java create mode 100644 src/main/java/org/crebs/skpackets/elements/general/field/DirectPacketFields.java create mode 100644 src/test/java/org/crebs/skpackets/api/entity/PacketEntityManagerTest.java create mode 100644 src/test/java/org/crebs/skpackets/api/general/packet/PacketAccessManagerTest.java create mode 100644 src/test/java/org/crebs/skpackets/api/simple/FakeBlockManagerTest.java create mode 100644 src/test/java/org/crebs/skpackets/api/util/DebugUtilTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/DirectPacketFieldsTest.java create mode 100644 src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistryOptionalPluginTest.java diff --git a/README.md b/README.md index 04ad26b..42788c4 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ command /fake-dropped-item: The persistent fake entity can be restored after supported updates and reconnects while it is still active. This example removes it after 20 seconds. +Standalone fake entities are displayed only in chunks already sent to their viewers. They reappear when those chunks return, following each player's effective view distance. Mounted entity chains keep their existing visibility behavior because a rider's stored spawn location may differ from its vehicle's location. + ### Fake Blocks Right-click a block to make it appear as gold for five seconds. Only you see the change; the real block is untouched. @@ -273,6 +275,8 @@ on right click: Persistent block overrides can survive block updates and reconnects during the current server session, until they are reset or cleared. +Block updates are grouped by chunk section. Persistent blocks outside a viewer's sent chunks stay stored and are reapplied when the chunk arrives; temporary changes are sent only if the chunk is already visible. Re-display work is combined and spread across ticks during chunk-loading bursts, without loading distant world chunks. + ### Inventory Navigation Run `/inv-nav` to display a simple navigation menu in your own crafting grid. These are fake inventory items, not changes to your real inventory. @@ -361,8 +365,7 @@ Persistence is opt-in. Add `with persistent` to a supported syntax to reapply a Reapplying an inventory or equipment override **without** `with persistent` replaces the persistent override with a one-time appearance. > [!NOTE] -> Persistence is not permanent storage. Overrides are not saved across server restarts. - +> Persistence is not permanent storage. They will disappear after a server restart. ## Player Inventory Slots The fake inventory slot syntax uses **raw slot indices**. diff --git a/src/main/java/org/crebs/skpackets/api/entity/PacketEntityManager.java b/src/main/java/org/crebs/skpackets/api/entity/PacketEntityManager.java index 3dff542..5623068 100644 --- a/src/main/java/org/crebs/skpackets/api/entity/PacketEntityManager.java +++ b/src/main/java/org/crebs/skpackets/api/entity/PacketEntityManager.java @@ -1,6 +1,8 @@ package org.crebs.skpackets.api.entity; import io.github.retrooper.packetevents.util.SpigotConversionUtil; +import io.papermc.paper.event.packet.PlayerChunkLoadEvent; +import io.papermc.paper.event.packet.PlayerChunkUnloadEvent; import me.tofaa.entitylib.wrapper.WrapperEntity; import me.tofaa.entitylib.wrapper.WrapperLivingEntity; import org.bukkit.Bukkit; @@ -21,16 +23,22 @@ import org.bukkit.plugin.java.JavaPlugin; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; public final class PacketEntityManager implements Listener, AutoCloseable { + private record Chunk(UUID world, int x, int z) {} private record Tracked( - WrapperEntity entity, UUID world, boolean persistent, Set viewers) {} + WrapperEntity entity, Chunk chunk, boolean persistent, Set viewers) {} private final Map entities = new HashMap<>(); + private final Map> byViewer = new HashMap<>(); + private final Map> byChunk = new HashMap<>(); + private final Map byUuid = new ConcurrentHashMap<>(); + private final Set pendingRestores = new HashSet<>(); private final RealEntityPassengerManager realPassengers = new RealEntityPassengerManager(); private final JavaPlugin plugin; - private boolean closed; + private volatile boolean closed; public PacketEntityManager(JavaPlugin plugin) { this.plugin = Objects.requireNonNull(plugin); @@ -98,8 +106,11 @@ public WrapperEntity spawn( throw new IllegalArgumentException( "Entity ID is already in use: " + entity.getEntityId()); } - Tracked tracked = new Tracked(entity, location.getWorld().getUID(), persistent, ids); + Tracked tracked = new Tracked(entity, chunk(location), persistent, ids); entities.put(entity.getEntityId(), tracked); + byUuid.put(entity.getUuid(), entity); + byChunk.computeIfAbsent(tracked.chunk(), ignored -> new HashSet<>()).add(entity.getEntityId()); + for (UUID id : ids) indexViewer(id, entity.getEntityId()); entity.spawn(SpigotConversionUtil.fromBukkitLocation(location)); for (Player viewer : viewers) { show(tracked, viewer); @@ -118,6 +129,34 @@ public Collection getEntities() { return entities.values().stream().map(Tracked::entity).toList(); } + public Optional getEntity(UUID uuid) { + if (closed) throw new IllegalStateException("Packet entity manager is closed"); + WrapperEntity entity = byUuid.get(uuid); + return entity != null && entity.isSpawned() ? Optional.of(entity) : Optional.empty(); + } + + private static Chunk chunk(Location location) { + return new Chunk(location.getWorld().getUID(), location.getBlockX() >> 4, location.getBlockZ() >> 4); + } + + private void indexViewer(UUID viewer, int id) { + byViewer.computeIfAbsent(viewer, ignored -> new HashSet<>()).add(id); + } + + private static void unindex(Map> index, K key, int id) { + Set ids = index.get(key); + if (ids != null && ids.remove(id) && ids.isEmpty()) index.remove(key); + } + + private void forget(Tracked tracked) { + int id = tracked.entity().getEntityId(); + entities.remove(id); + byUuid.remove(tracked.entity().getUuid(), tracked.entity()); + unindex(byChunk, tracked.chunk(), id); + for (UUID viewer : tracked.viewers()) unindex(byViewer, viewer, id); + realPassengers.remove(tracked.entity()); + } + public Set getViewers(WrapperEntity entity) { checkThread(); return Set.copyOf(requireTracked(entity).viewers()); @@ -134,7 +173,7 @@ public void addViewer(WrapperEntity entity, Player player) { if (!player.isOnline()) { throw new IllegalArgumentException("Viewer must be online"); } - tracked.viewers().add(player.getUniqueId()); + if (tracked.viewers().add(player.getUniqueId())) indexViewer(player.getUniqueId(), entity.getEntityId()); show(tracked, player); } @@ -142,10 +181,25 @@ public void removeViewer(WrapperEntity entity, UUID viewer) { checkThread(); Tracked tracked = requireTracked(entity); tracked.viewers().remove(viewer); + unindex(byViewer, viewer, entity.getEntityId()); realPassengers.removeViewer(entity, viewer); entity.removeViewer(viewer); } + public void setViewers(WrapperEntity entity, Collection viewers) { + checkThread(); + Tracked tracked = requireTracked(entity); + Map desired = new HashMap<>(); + for (Player player : viewers) { + if (!player.isOnline()) throw new IllegalArgumentException("Viewer must be online"); + desired.put(player.getUniqueId(), player); + } + for (UUID id : Set.copyOf(tracked.viewers())) + if (!desired.containsKey(id)) removeViewer(entity, id); + for (var entry : desired.entrySet()) + if (!tracked.viewers().contains(entry.getKey())) addViewer(entity, entry.getValue()); + } + public void updateRealPassengers(Entity vehicle, Collection passengers, boolean mount) { checkThread(); if (mount) { @@ -169,25 +223,34 @@ public void teleport(WrapperEntity entity, Location location) { } location.checkFinite(); UUID world = location.getWorld().getUID(); - boolean changedWorld = !tracked.world().equals(world); + Chunk destination = chunk(location); + boolean changedWorld = !tracked.chunk().world().equals(world); if (changedWorld) { realPassengers.remove(entity); for (UUID viewer : Set.copyOf(entity.getViewers())) { entity.removeViewer(viewer); } } - entity.setWorld(location.getWorld().getName()); - entity.teleport(SpigotConversionUtil.fromBukkitLocation(location)); - if (changedWorld) { - Tracked updated = new Tracked(entity, world, tracked.persistent(), tracked.viewers()); - entities.put(entity.getEntityId(), updated); - for (UUID id : updated.viewers()) { + boolean changedChunk = !tracked.chunk().equals(destination); + if (changedChunk) { + unindex(byChunk, tracked.chunk(), entity.getEntityId()); + tracked = new Tracked(entity, destination, tracked.persistent(), tracked.viewers()); + entities.put(entity.getEntityId(), tracked); + byChunk.computeIfAbsent(destination, ignored -> new HashSet<>()).add(entity.getEntityId()); + for (UUID id : Set.copyOf(entity.getViewers())) { Player player = Bukkit.getPlayer(id); - if (player != null) { - show(updated, player); + if (player == null || !visible(tracked, player)) { + realPassengers.removeViewer(entity, id); + entity.removeViewer(id); } } } + entity.setWorld(location.getWorld().getName()); + entity.teleport(SpigotConversionUtil.fromBukkitLocation(location)); + if (changedChunk) for (UUID id : tracked.viewers()) { + Player player = Bukkit.getPlayer(id); + if (player != null) show(tracked, player); + } } public void remove(WrapperEntity entity) { @@ -195,9 +258,7 @@ public void remove(WrapperEntity entity) { if (!entities.containsKey(entity.getEntityId())) { return; } - requireTracked(entity); - entities.remove(entity.getEntityId()); - realPassengers.remove(entity); + forget(requireTracked(entity)); entity.remove(); } @@ -212,35 +273,75 @@ private Tracked requireTracked(WrapperEntity entity) { private void show(Tracked tracked, Player player) { if (player.isOnline() && tracked.viewers().contains(player.getUniqueId()) - && tracked.world().equals(player.getWorld().getUID()) + && visible(tracked, player) && tracked.entity().isSpawned()) { tracked.entity().addViewer(player.getUniqueId()); } } + private boolean visible(Tracked tracked, Player player) { + Chunk chunk = tracked.chunk(); + return chunk.world().equals(player.getWorld().getUID()) + && (hasMount(tracked.entity()) + || player.isChunkSent((chunk.x() & 0xffffffffL) | ((long) chunk.z() << 32))); + } + + private boolean hasMount(WrapperEntity entity) { + return entity.getRiding() != null || !entity.getPassengers().isEmpty() || realPassengers.isMounted(entity); + } + private void restore(Player player) { + UUID id = player.getUniqueId(); + if (!pendingRestores.add(id)) return; Bukkit.getScheduler() .runTask( plugin, () -> { + pendingRestores.remove(id); if (closed || !player.isOnline()) { return; } - entities.values().removeIf(tracked -> { - if (tracked.entity().isSpawned()) return false; - realPassengers.remove(tracked.entity()); - return true; - }); - for (Tracked tracked : entities.values()) { - show(tracked, player); + for (int entityId : Set.copyOf(byViewer.getOrDefault(id, Set.of()))) { + Tracked tracked = entities.get(entityId); + if (tracked == null) continue; + if (!tracked.entity().isSpawned()) forget(tracked); + else show(tracked, player); } }); } private void hide(Player player) { realPassengers.forgetViewer(player.getUniqueId()); - for (Tracked tracked : entities.values()) { - tracked.entity().removeViewer(player.getUniqueId()); + for (int id : Set.copyOf(byViewer.getOrDefault(player.getUniqueId(), Set.of()))) { + Tracked tracked = entities.get(id); + if (tracked != null) tracked.entity().removeViewer(player.getUniqueId()); + } + } + + @EventHandler + public void onChunkLoad(PlayerChunkLoadEvent event) { + if (closed) return; + Chunk chunk = new Chunk(event.getWorld().getUID(), event.getChunk().getX(), event.getChunk().getZ()); + UUID viewer = event.getPlayer().getUniqueId(); + for (int id : Set.copyOf(byChunk.getOrDefault(chunk, Set.of()))) { + Tracked tracked = entities.get(id); + if (tracked == null || !tracked.viewers().contains(viewer)) continue; + if (!tracked.entity().isSpawned()) forget(tracked); + else tracked.entity().addViewer(viewer); + } + } + + @EventHandler + public void onChunkUnload(PlayerChunkUnloadEvent event) { + if (closed) return; + Chunk chunk = new Chunk(event.getWorld().getUID(), event.getChunk().getX(), event.getChunk().getZ()); + UUID viewer = event.getPlayer().getUniqueId(); + for (int id : Set.copyOf(byChunk.getOrDefault(chunk, Set.of()))) { + Tracked tracked = entities.get(id); + if (tracked == null || !tracked.viewers().contains(viewer)) continue; + if (hasMount(tracked.entity())) continue; + realPassengers.removeViewer(tracked.entity(), viewer); + tracked.entity().removeViewer(viewer); } } @@ -254,15 +355,17 @@ public void onQuit(PlayerQuitEvent event) { UUID id = event.getPlayer().getUniqueId(); realPassengers.forgetViewer(id); realPassengers.forgetVehicle(event.getPlayer()); - for (var iterator = entities.values().iterator(); iterator.hasNext(); ) { - Tracked tracked = iterator.next(); + for (int entityId : Set.copyOf(byViewer.getOrDefault(id, Set.of()))) { + Tracked tracked = entities.get(entityId); + if (tracked == null) continue; tracked.entity().handleViewerDisconnect(id); - if (!tracked.persistent() - && tracked.viewers().remove(id) - && tracked.viewers().isEmpty()) { - iterator.remove(); - realPassengers.remove(tracked.entity()); - tracked.entity().remove(); + if (!tracked.persistent()) { + tracked.viewers().remove(id); + unindex(byViewer, id, entityId); + if (tracked.viewers().isEmpty()) { + forget(tracked); + tracked.entity().remove(); + } } } } @@ -313,6 +416,10 @@ public void close() { tracked.entity().remove(); } entities.clear(); + byViewer.clear(); + byChunk.clear(); + byUuid.clear(); + pendingRestores.clear(); closed = true; HandlerList.unregisterAll(this); } diff --git a/src/main/java/org/crebs/skpackets/api/entity/RealEntityPassengerManager.java b/src/main/java/org/crebs/skpackets/api/entity/RealEntityPassengerManager.java index b44bb37..43649d2 100644 --- a/src/main/java/org/crebs/skpackets/api/entity/RealEntityPassengerManager.java +++ b/src/main/java/org/crebs/skpackets/api/entity/RealEntityPassengerManager.java @@ -14,6 +14,22 @@ private record Key(UUID viewer, UUID vehicle) {} private record Mount(Entity vehicle, Map passengers) {} private final Map mounts = new LinkedHashMap<>(); + private final Map> byPassenger = new HashMap<>(); + + boolean isMounted(WrapperEntity passenger) { + return byPassenger.containsKey(passenger.getEntityId()); + } + + private boolean detach(Key key, Mount mount, WrapperEntity passenger) { + if (!mount.passengers().remove(passenger.getEntityId(), passenger)) return false; + unindex(passenger.getEntityId(), key); + return true; + } + + private void unindex(int passenger, Key key) { + Set keys = byPassenger.get(passenger); + if (keys != null && keys.remove(key) && keys.isEmpty()) byPassenger.remove(passenger); + } void update(Entity vehicle, Collection passengers, boolean mount) { Map changed = new LinkedHashMap<>(); @@ -21,16 +37,19 @@ void update(Entity vehicle, Collection passengers, boolean mount) for (UUID viewer : passenger.getViewers()) { Key key = new Key(viewer, vehicle.getUniqueId()); if (mount) { - mounts.forEach((oldKey, oldMount) -> { + for (Key oldKey : Set.copyOf(byPassenger.getOrDefault(passenger.getEntityId(), Set.of()))) { + Mount oldMount = mounts.get(oldKey); if (oldKey.viewer().equals(viewer) && !oldKey.equals(key) - && oldMount.passengers().remove(passenger.getEntityId(), passenger)) { + && detach(oldKey, oldMount, passenger)) { changed.put(oldKey, oldMount); } - }); + } } Mount current = mounts.computeIfAbsent(key, ignored -> new Mount(vehicle, new LinkedHashMap<>())); - if (mount) current.passengers().put(passenger.getEntityId(), passenger); - else current.passengers().remove(passenger.getEntityId(), passenger); + if (mount) { + current.passengers().put(passenger.getEntityId(), passenger); + byPassenger.computeIfAbsent(passenger.getEntityId(), ignored -> new HashSet<>()).add(key); + } else detach(key, current, passenger); changed.put(key, current); } } @@ -39,39 +58,54 @@ void update(Entity vehicle, Collection passengers, boolean mount) void remove(WrapperEntity passenger) { Map changed = new LinkedHashMap<>(); - mounts.forEach((key, mount) -> { - if (mount.passengers().remove(passenger.getEntityId(), passenger)) changed.put(key, mount); - }); + for (Key key : Set.copyOf(byPassenger.getOrDefault(passenger.getEntityId(), Set.of()))) { + Mount mount = mounts.get(key); + if (detach(key, mount, passenger)) changed.put(key, mount); + } refresh(changed); } void removeViewer(WrapperEntity passenger, UUID viewer) { Map changed = new LinkedHashMap<>(); - mounts.forEach((key, mount) -> { - if (key.viewer().equals(viewer) && mount.passengers().remove(passenger.getEntityId(), passenger)) { + for (Key key : Set.copyOf(byPassenger.getOrDefault(passenger.getEntityId(), Set.of()))) { + Mount mount = mounts.get(key); + if (key.viewer().equals(viewer) && detach(key, mount, passenger)) { changed.put(key, mount); } - }); + } refresh(changed); } void forgetViewer(UUID viewer) { - mounts.keySet().removeIf(key -> key.viewer().equals(viewer)); + forget(key -> key.viewer().equals(viewer)); } void forgetVehicle(Entity vehicle) { - mounts.keySet().removeIf(key -> key.vehicle().equals(vehicle.getUniqueId())); + forget(key -> key.vehicle().equals(vehicle.getUniqueId())); + } + + private void forget(java.util.function.Predicate predicate) { + mounts.entrySet().removeIf(entry -> { + if (!predicate.test(entry.getKey())) return false; + for (int passenger : entry.getValue().passengers().keySet()) unindex(passenger, entry.getKey()); + return true; + }); } void clear() { Map changed = new LinkedHashMap<>(mounts); changed.values().forEach(mount -> mount.passengers().clear()); + byPassenger.clear(); refresh(changed); } private void refresh(Map changed) { changed.forEach((key, mount) -> { - mount.passengers().values().removeIf(passenger -> !passenger.isSpawned() || !passenger.getViewers().contains(key.viewer())); + mount.passengers().values().removeIf(passenger -> { + if (passenger.isSpawned() && passenger.getViewers().contains(key.viewer())) return false; + unindex(passenger.getEntityId(), key); + return true; + }); Player viewer = Bukkit.getPlayer(key.viewer()); if (viewer != null && viewer.isOnline() && viewer.getWorld().equals(mount.vehicle().getWorld())) { LinkedHashSet ids = new LinkedHashSet<>(); diff --git a/src/main/java/org/crebs/skpackets/api/general/packet/PacketAccessManager.java b/src/main/java/org/crebs/skpackets/api/general/packet/PacketAccessManager.java new file mode 100644 index 0000000..f9f0ebc --- /dev/null +++ b/src/main/java/org/crebs/skpackets/api/general/packet/PacketAccessManager.java @@ -0,0 +1,140 @@ +package org.crebs.skpackets.api.general.packet; + +import com.github.retrooper.packetevents.event.PacketReceiveEvent; +import com.github.retrooper.packetevents.event.PacketSendEvent; +import com.github.retrooper.packetevents.event.ProtocolPacketEvent; +import com.github.retrooper.packetevents.protocol.PacketSide; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientKeepAlive; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerKeepAlive; +import org.jspecify.annotations.NonNull; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Function; + +public final class PacketAccessManager { + private PacketAccessManager() {} + + private static final class Access { + final List methods; + final Map> readers = new ConcurrentHashMap<>(); + final Map> writers = new ConcurrentHashMap<>(); + final Map, Function> constructors = new ConcurrentHashMap<>(); + + Access(Class type) { + methods = Arrays.stream(type.getMethods()) + .filter(method -> !method.isBridge() && !method.isSynthetic()) + .filter(method -> !Modifier.isStatic(method.getModifiers())) + .sorted(Comparator.comparing(Method::toGenericString)).toList(); + } + } + + private static final ClassValue ACCESS = new ClassValue<>() { + @Override protected Access computeValue(@NonNull Class type) { return new Access(type); } + }; + private static final Map>> EVENT_FACTORIES = + new ConcurrentHashMap<>(); + + public static List methods(Class type) { + return ACCESS.get(type).methods; + } + + public static Function reader(Method method) { + if (Modifier.isStatic(method.getModifiers()) || method.getParameterCount() != 0 + || method.getReturnType() == void.class) { + throw new IllegalArgumentException("Expected an instance getter: " + method); + } + return ACCESS.get(method.getDeclaringClass()).readers.computeIfAbsent(method, key -> { + MethodHandle handle = unreflect(key).asType(MethodType.methodType(Object.class, Object.class)); + return target -> { + try { return (Object) handle.invokeExact(target); } + catch (Throwable failure) { throw failure("PacketEvents field access failed: " + key.getName(), failure); } + }; + }); + } + + public static BiConsumer writer(Method method) { + if (Modifier.isStatic(method.getModifiers()) || method.getParameterCount() != 1) { + throw new IllegalArgumentException("Expected an instance setter: " + method); + } + return ACCESS.get(method.getDeclaringClass()).writers.computeIfAbsent(method, key -> { + MethodHandle handle = unreflect(key).asType(MethodType.methodType(void.class, Object.class, Object.class)); + return (target, value) -> { + try { handle.invokeExact(target, value); } + catch (Throwable failure) { throw failure("PacketEvents field access failed: " + key.getName(), failure); } + }; + }); + } + + public static Function constructor(Constructor constructor) { + return ACCESS.get(constructor.getDeclaringClass()).constructors.computeIfAbsent(constructor, key -> { + final MethodHandle handle; + try { + handle = MethodHandles.publicLookup().unreflectConstructor(key).asFixedArity() + .asSpreader(Object[].class, key.getParameterCount()) + .asType(MethodType.methodType(Object.class, Object[].class)); + } catch (IllegalAccessException exception) { + throw new IllegalStateException("Cannot access PacketEvents constructor " + key, exception); + } + return arguments -> { + try { return (Object) handle.invokeExact(arguments); } + catch (Throwable failure) { throw failure("PacketEvents construction failed: " + key, failure); } + }; + }); + } + + public static void registerEventFactory(PacketTypeCommon type, + Function> factory) { + EVENT_FACTORIES.put(Objects.requireNonNull(type), Objects.requireNonNull(factory)); + } + + public static PacketWrapper wrap(PacketTypeCommon type, ProtocolPacketEvent event) { + return EVENT_FACTORIES.computeIfAbsent(type, key -> { + if (key == PacketType.Play.Server.KEEP_ALIVE) { + return packetEvent -> new WrapperPlayServerKeepAlive((PacketSendEvent) packetEvent); + } + if (key == PacketType.Play.Client.KEEP_ALIVE) { + return packetEvent -> new WrapperPlayClientKeepAlive((PacketReceiveEvent) packetEvent); + } + Class> wrapper = key.getWrapperClass(); + if (wrapper == null) return ignored -> null; + try { + var factory = constructor(wrapper.getConstructor(key.getSide() == PacketSide.SERVER + ? PacketSendEvent.class : PacketReceiveEvent.class)); + return packetEvent -> (PacketWrapper) factory.apply(new Object[] {packetEvent}); + } catch (NoSuchMethodException exception) { + throw new IllegalStateException("Cannot find event constructor for " + key, exception); + } + }).apply(event); + } + + public static void clearEventFactories() { + EVENT_FACTORIES.clear(); + } + + private static MethodHandle unreflect(Method method) { + try { return MethodHandles.publicLookup().unreflect(method).asFixedArity(); } + catch (IllegalAccessException exception) { + throw new IllegalStateException("Cannot access PacketEvents method " + method, exception); + } + } + + private static IllegalArgumentException failure(String message, Throwable cause) { + if (cause instanceof Error error) throw error; + return new IllegalArgumentException(message, cause); + } +} diff --git a/src/main/java/org/crebs/skpackets/api/general/packet/PacketSendOrReceiveListener.java b/src/main/java/org/crebs/skpackets/api/general/packet/PacketSendOrReceiveListener.java index ddbd015..33fc765 100644 --- a/src/main/java/org/crebs/skpackets/api/general/packet/PacketSendOrReceiveListener.java +++ b/src/main/java/org/crebs/skpackets/api/general/packet/PacketSendOrReceiveListener.java @@ -22,7 +22,6 @@ import com.github.retrooper.packetevents.PacketEvents; import com.github.retrooper.packetevents.event.*; -import com.github.retrooper.packetevents.protocol.PacketSide; import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; import com.github.retrooper.packetevents.wrapper.PacketWrapper; @@ -31,8 +30,6 @@ import org.crebs.skpackets.elements.general.event.EvtPacketSendOrReceive.ProcessType; import org.jspecify.annotations.NonNull; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; import java.util.EnumMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -46,9 +43,6 @@ public class PacketSendOrReceiveListener implements PacketListener { private static final Map> GLOBAL_LISTENERS = new ConcurrentHashMap<>(); - private static final Map>> - CONSTRUCTORS = new ConcurrentHashMap<>(); - private final PacketListenerPriority priority; private static final Map REGISTERED = new EnumMap<>(PacketListenerPriority.class); @@ -58,7 +52,7 @@ public static synchronized void close() { REGISTERED.clear(); ACTIVE_LISTENERS.clear(); GLOBAL_LISTENERS.clear(); - CONSTRUCTORS.clear(); + PacketAccessManager.clearEventFactories(); } private PacketSendOrReceiveListener(PacketListenerPriority priority) { @@ -178,28 +172,10 @@ private static void trigger(ProtocolPacketEvent event, PacketListenerPriority pr } private static PacketWrapper getWrapper(PacketTypeCommon type, ProtocolPacketEvent event) { - Class> clazz = type.getWrapperClass(); - if (clazz == null) { - return null; - } - try { - Constructor> constructor = CONSTRUCTORS.get(type); - if (constructor == null) { - constructor = - clazz.getConstructor( - type.getSide() == PacketSide.SERVER - ? PacketSendEvent.class - : PacketReceiveEvent.class); - CONSTRUCTORS.putIfAbsent(type, constructor); - } - return constructor.newInstance(event); - } catch (InvocationTargetException - | InstantiationException - | IllegalAccessException - | NoSuchMethodError - | NoSuchMethodException e) { - throw new IllegalStateException("Couldn't create packet for: " + type, e); + return PacketAccessManager.wrap(type, event); + } catch (RuntimeException exception) { + throw new IllegalStateException("Couldn't create packet for: " + type, exception); } } diff --git a/src/main/java/org/crebs/skpackets/api/simple/FakeBlockManager.java b/src/main/java/org/crebs/skpackets/api/simple/FakeBlockManager.java index c346806..7918513 100644 --- a/src/main/java/org/crebs/skpackets/api/simple/FakeBlockManager.java +++ b/src/main/java/org/crebs/skpackets/api/simple/FakeBlockManager.java @@ -5,6 +5,7 @@ import com.github.retrooper.packetevents.event.PacketListenerPriority; import com.github.retrooper.packetevents.event.PacketSendEvent; import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.util.Vector3i; import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBlockChange; import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerChunkData; import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerMultiBlockChange; @@ -24,6 +25,10 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +import static io.github.retrooper.packetevents.util.SpigotConversionUtil.fromBukkitBlockData; public final class FakeBlockManager extends PacketListenerAbstract implements Listener, AutoCloseable { @@ -31,15 +36,16 @@ private record Chunk(UUID world, int x, int z) {} private record Position(int x, int y, int z) {} - private record Fake(BlockData data, boolean persistent) {} + private record Fake(int stateId, boolean persistent) {} private record Refresh(UUID viewer, Chunk chunk) {} private final Map>> blocks = new HashMap<>(); private final Map worlds = new ConcurrentHashMap<>(); private final Set pending = ConcurrentHashMap.newKeySet(); - private final Map> overrides = new ConcurrentHashMap<>(); - private final Set active = ConcurrentHashMap.newKeySet(); + private final Queue refreshes = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean drainScheduled = new AtomicBoolean(); + private final Map>> overrides = new ConcurrentHashMap<>(); private final JavaPlugin plugin; private volatile boolean closed; @@ -59,30 +65,79 @@ private void checkThread() { } public void show(Player viewer, Location location, BlockData data, boolean persistent) { + show(viewer, List.of(location), data, persistent); + } + + public void show(Player viewer, Collection locations, BlockData data, boolean persistent) { checkThread(); + if (!viewer.isOnline() || locations.isEmpty()) return; + UUID world = viewer.getWorld().getUID(); + Map> grouped = new HashMap<>(); + for (Location location : locations) { + validate(location); + UUID target = location.getWorld().getUID(); + if (!persistent && !world.equals(target)) continue; + Chunk chunk = new Chunk(target, location.getBlockX() >> 4, location.getBlockZ() >> 4); + grouped.computeIfAbsent(chunk, ignored -> new HashSet<>()) + .add(new Position(location.getBlockX(), location.getBlockY(), location.getBlockZ())); + } + if (grouped.isEmpty()) return; + UUID id = viewer.getUniqueId(); + worlds.put(id, world); + Fake fake = new Fake(fromBukkitBlockData(data).getGlobalId(), persistent); + var chunks = blocks.computeIfAbsent(id, ignored -> new HashMap<>()); + var watchedChunks = persistent + ? overrides.computeIfAbsent(id, ignored -> new ConcurrentHashMap<>()) : overrides.get(id); + Map> changes = new HashMap<>(); + for (var group : grouped.entrySet()) { + Chunk chunk = group.getKey(); + var entries = chunks.computeIfAbsent(chunk, ignored -> new HashMap<>()); + Set watched = watchedChunks == null ? null : persistent + ? watchedChunks.computeIfAbsent(chunk, ignored -> ConcurrentHashMap.newKeySet()) : watchedChunks.get(chunk); + boolean visible = visible(viewer, chunk); + Map updates = visible ? new HashMap<>() : null; + for (Position position : group.getValue()) { + Fake previous = entries.put(position, fake); + if (watched != null) { + if (persistent) watched.add(position); + else watched.remove(position); + } + if (visible && (!persistent || !fake.equals(previous))) updates.put(position, fake.stateId()); + } + if (watched != null && watched.isEmpty()) watchedChunks.remove(chunk); + if (updates != null && !updates.isEmpty()) changes.put(chunk, updates); + } + if (watchedChunks != null && watchedChunks.isEmpty()) overrides.remove(id); + sendChanges(viewer, changes); + } + + private void validate(Location location) { World world = Objects.requireNonNull(location.getWorld(), "Location needs a world."); location.checkFinite(); if (location.getBlockY() < world.getMinHeight() || location.getBlockY() >= world.getMaxHeight()) throw new IllegalArgumentException("Fake block is outside the world height."); - if (!viewer.isOnline()) return; - if (!persistent && !viewer.getWorld().equals(world)) return; - UUID id = viewer.getUniqueId(); - worlds.put(id, viewer.getWorld().getUID()); - Chunk chunk = - new Chunk(world.getUID(), location.getBlockX() >> 4, location.getBlockZ() >> 4); - Position position = - new Position(location.getBlockX(), location.getBlockY(), location.getBlockZ()); - blocks.computeIfAbsent(id, ignored -> new HashMap<>()) - .computeIfAbsent(chunk, ignored -> new HashMap<>()) - .put(position, new Fake(data.clone(), persistent)); - active.add(id); - updateWatch(id, chunk); - if (viewer.getWorld().equals(world)) send(viewer, position, data); } public void reset(Player viewer, Location location) { + reset(viewer, List.of(location)); + } + + public void reset(Player viewer, Collection locations) { checkThread(); + for (Location location : locations) { + Objects.requireNonNull(location.getWorld(), "Location needs a world."); + location.checkFinite(); + } + Map> changes = new HashMap<>(); + List tiles = new ArrayList<>(); + for (Location location : locations) reset(viewer, location, changes, tiles); + sendChanges(viewer, changes); + restoreTiles(viewer, tiles); + } + + private void reset(Player viewer, Location location, Map> changes, + List tiles) { World world = Objects.requireNonNull(location.getWorld(), "Location needs a world."); var chunks = blocks.get(viewer.getUniqueId()); if (chunks == null) return; @@ -92,93 +147,111 @@ public void reset(Player viewer, Location location) { if (entries == null) return; Position pos = new Position(location.getBlockX(), location.getBlockY(), location.getBlockZ()); - if (entries.remove(pos) != null - && viewer.isOnline() - && viewer.getWorld().equals(world) - && world.isChunkLoaded(chunk.x(), chunk.z())) restore(viewer, pos); + if (entries.remove(pos) != null && visible(viewer, chunk)) + restore(viewer, chunk, pos, changes, tiles); if (entries.isEmpty()) chunks.remove(chunk); - updateWatch(viewer.getUniqueId(), chunk); + unwatch(viewer.getUniqueId(), chunk, pos); prune(viewer.getUniqueId()); } public void clear(Player viewer) { checkThread(); var chunks = blocks.remove(viewer.getUniqueId()); - active.remove(viewer.getUniqueId()); - overrides.keySet().removeIf(refresh -> refresh.viewer().equals(viewer.getUniqueId())); - if (chunks == null || !viewer.isOnline()) return; - chunks.forEach( - (chunk, entries) -> { - if (chunk.world().equals(viewer.getWorld().getUID()) - && viewer.getWorld().isChunkLoaded(chunk.x(), chunk.z())) - entries.keySet().forEach(pos -> restore(viewer, pos)); - }); + overrides.remove(viewer.getUniqueId()); + if (chunks == null) return; + if (!viewer.isOnline()) return; + Map> changes = new HashMap<>(); + List tiles = new ArrayList<>(); + UUID world = viewer.getWorld().getUID(); + for (long key : viewer.getSentChunkKeys()) { + Chunk chunk = new Chunk(world, (int) key, (int) (key >> 32)); + var entries = chunks.get(chunk); + if (entries != null && visible(viewer, chunk)) + entries.keySet().forEach(pos -> restore(viewer, chunk, pos, changes, tiles)); + } + sendChanges(viewer, changes); + restoreTiles(viewer, tiles); } - private void updateWatch(UUID id, Chunk chunk) { - var chunks = blocks.get(id); - var entries = chunks == null ? null : chunks.get(chunk); - Refresh key = new Refresh(id, chunk); - Set positions = new HashSet<>(); - if (entries != null) - entries.forEach((pos, fake) -> { - if (fake.persistent()) positions.add(pos); - }); - if (positions.isEmpty()) overrides.remove(key); - else overrides.put(key, Set.copyOf(positions)); + private void unwatch(UUID id, Chunk chunk, Position position) { + var chunks = overrides.get(id); + if (chunks == null) return; + Set positions = chunks.get(chunk); + if (positions != null) { + positions.remove(position); + if (positions.isEmpty()) chunks.remove(chunk); + } + if (chunks.isEmpty()) overrides.remove(id); } private void prune(UUID id) { var chunks = blocks.get(id); if (chunks != null && chunks.isEmpty()) { blocks.remove(id); - active.remove(id); } } - private void restore(Player viewer, Position pos) { + private static boolean visible(Player viewer, Chunk chunk) { + return viewer.isOnline() && viewer.getWorld().getUID().equals(chunk.world()) + && viewer.isChunkSent(chunkKey(chunk.x(), chunk.z())) + && viewer.getWorld().isChunkLoaded(chunk.x(), chunk.z()); + } + + private static long chunkKey(int x, int z) { + return (x & 0xffffffffL) | ((long) z << 32); + } + + private void restore(Player viewer, Chunk chunk, Position pos, + Map> changes, List tiles) { var block = viewer.getWorld().getBlockAt(pos.x(), pos.y(), pos.z()); - send(viewer, pos, block.getBlockData()); - if (block.getState() instanceof org.bukkit.block.TileState state) - viewer.sendBlockUpdate(block.getLocation(), state); + changes.computeIfAbsent(chunk, ignored -> new HashMap<>()) + .put(pos, fromBukkitBlockData(block.getBlockData()).getGlobalId()); + if (block.getState(false) instanceof org.bukkit.block.TileState state) tiles.add(state); } - private void send(Player viewer, Position pos, BlockData data) { - var packet = - new WrapperPlayServerBlockChange( - new com.github.retrooper.packetevents.util.Vector3i( - pos.x(), pos.y(), pos.z()), - io.github.retrooper.packetevents.util.SpigotConversionUtil - .fromBukkitBlockData(data)); - PacketEvents.getAPI().getPlayerManager().sendPacketSilently(viewer, packet); + private void restoreTiles(Player viewer, List tiles) { + for (var state : tiles) viewer.sendBlockUpdate(state.getLocation(), state); + } + + private void sendChanges(Player viewer, Map> changes) { + changes.forEach((chunk, entries) -> { + Map> sections = new HashMap<>(); + entries.forEach((pos, state) -> sections.computeIfAbsent(pos.y() >> 4, ignored -> new ArrayList<>()) + .add(new WrapperPlayServerMultiBlockChange.EncodedBlock(state, pos.x(), pos.y(), pos.z()))); + sections.forEach((y, updates) -> PacketEvents.getAPI().getPlayerManager().sendPacketSilently(viewer, + new WrapperPlayServerMultiBlockChange(new Vector3i(chunk.x(), y, chunk.z()), null, + updates.toArray(WrapperPlayServerMultiBlockChange.EncodedBlock[]::new)))); + }); } @Override @SuppressWarnings("ConstantValue") public void onPacketSend(@org.jetbrains.annotations.NotNull PacketSendEvent event) { if (closed || event.isCancelled()) return; + var type = event.getPacketType(); + if (type != PacketType.Play.Server.CHUNK_DATA && type != PacketType.Play.Server.BLOCK_CHANGE + && type != PacketType.Play.Server.MULTI_BLOCK_CHANGE) return; UUID id = event.getUser().getUUID(); - if (id == null || !active.contains(id)) return; + var watchedChunks = id == null ? null : overrides.get(id); + if (watchedChunks == null) return; UUID world = worlds.get(id); if (world == null) return; if (event.getPacketType() == PacketType.Play.Server.CHUNK_DATA) { var column = new WrapperPlayServerChunkData(event).getColumn(); Refresh refresh = new Refresh(id, new Chunk(world, column.getX(), column.getZ())); - if (overrides.containsKey(refresh)) + if (watchedChunks.containsKey(refresh.chunk())) event.getTasksAfterSend().add(() -> queue(refresh)); } else if (event.getPacketType() == PacketType.Play.Server.BLOCK_CHANGE) { var packet = new WrapperPlayServerBlockChange(event); var pos = packet.getBlockPosition(); - var positions = overrides.get(new Refresh(id, - new Chunk(world, pos.getX() >> 4, pos.getZ() >> 4))); + var positions = watchedChunks.get(new Chunk(world, pos.getX() >> 4, pos.getZ() >> 4)); if (positions != null && positions.contains(new Position(pos.getX(), pos.getY(), pos.getZ()))) event.setCancelled(true); } else if (event.getPacketType() == PacketType.Play.Server.MULTI_BLOCK_CHANGE) { var packet = new WrapperPlayServerMultiBlockChange(event); var chunk = packet.getChunkPosition(); - var positions = overrides.get(new Refresh(id, - new Chunk(world, chunk.getX(), chunk.getZ()))); + var positions = watchedChunks.get(new Chunk(world, chunk.getX(), chunk.getZ())); if (positions == null) return; var updates = packet.getBlocks(); List remaining = null; @@ -201,26 +274,41 @@ public void onPacketSend(@org.jetbrains.annotations.NotNull PacketSendEvent even } private void queue(Refresh refresh) { - if (closed || !overrides.containsKey(refresh) || !pending.add(refresh)) return; - Bukkit.getScheduler() - .runTask( - plugin, - () -> { - pending.remove(refresh); - if (closed) return; - Player player = Bukkit.getPlayer(refresh.viewer()); - if (player == null - || !player.getWorld().getUID().equals(refresh.chunk().world())) - return; - var chunks = blocks.get(refresh.viewer()); - if (chunks == null) return; - var entries = chunks.get(refresh.chunk()); - if (entries == null) return; - entries.forEach( - (pos, fake) -> { - if (fake.persistent()) send(player, pos, fake.data()); - }); - }); + var watchedChunks = overrides.get(refresh.viewer()); + if (closed || watchedChunks == null || !watchedChunks.containsKey(refresh.chunk()) || !pending.add(refresh)) return; + refreshes.add(refresh); + scheduleDrain(); + } + + private void scheduleDrain() { + if (!closed && drainScheduled.compareAndSet(false, true)) + Bukkit.getScheduler().runTask(plugin, this::drain); + } + + private void drain() { + try { + for (int i = 0; i < 64 && !closed; i++) { + Refresh refresh = refreshes.poll(); + if (refresh == null) break; + pending.remove(refresh); + Player player = Bukkit.getPlayer(refresh.viewer()); + if (player == null || !visible(player, refresh.chunk())) continue; + var chunks = blocks.get(refresh.viewer()); + var entries = chunks == null ? null : chunks.get(refresh.chunk()); + var watchedChunks = overrides.get(refresh.viewer()); + var positions = watchedChunks == null ? null : watchedChunks.get(refresh.chunk()); + if (entries == null || positions == null) continue; + Map changes = new HashMap<>(positions.size()); + for (Position pos : positions) { + Fake fake = entries.get(pos); + if (fake != null && fake.persistent()) changes.put(pos, fake.stateId()); + } + sendChanges(player, Map.of(refresh.chunk(), changes)); + } + } finally { + drainScheduled.set(false); + if (!refreshes.isEmpty()) scheduleDrain(); + } } private void discardTransient(UUID id) { @@ -234,7 +322,7 @@ private void discardTransient(UUID id) { @EventHandler public void onJoin(PlayerJoinEvent event) { worlds.put(event.getPlayer().getUniqueId(), event.getPlayer().getWorld().getUID()); - refreshWorld(event.getPlayer()); + refreshWorldLater(event.getPlayer()); } @EventHandler @@ -249,7 +337,7 @@ public void onWorld(PlayerChangedWorldEvent event) { Player player = event.getPlayer(); worlds.put(player.getUniqueId(), player.getWorld().getUID()); discardTransient(player.getUniqueId()); - refreshWorld(player); + refreshWorldLater(player); } @EventHandler @@ -257,21 +345,24 @@ public void onRespawn(PlayerRespawnEvent event) { UUID id = event.getPlayer().getUniqueId(); worlds.put(id, event.getRespawnLocation().getWorld().getUID()); discardTransient(id); + refreshWorldLater(event.getPlayer()); + } + + private void refreshWorldLater(Player player) { Bukkit.getScheduler() .runTask( plugin, () -> { - if (!closed && event.getPlayer().isOnline()) - refreshWorld(event.getPlayer()); + if (!closed && player.isOnline()) refreshWorld(player); }); } private void refreshWorld(Player player) { - var chunks = blocks.get(player.getUniqueId()); - if (chunks == null) return; - for (Chunk chunk : chunks.keySet()) - if (chunk.world().equals(player.getWorld().getUID())) - queue(new Refresh(player.getUniqueId(), chunk)); + UUID id = player.getUniqueId(); + if (!overrides.containsKey(id)) return; + UUID world = player.getWorld().getUID(); + for (long key : player.getSentChunkKeys()) + queue(new Refresh(id, new Chunk(world, (int) key, (int) (key >> 32)))); } @Override @@ -283,8 +374,8 @@ public void close() { HandlerList.unregisterAll(this); blocks.clear(); worlds.clear(); - active.clear(); pending.clear(); + refreshes.clear(); overrides.clear(); } } diff --git a/src/main/java/org/crebs/skpackets/api/util/DebugUtil.java b/src/main/java/org/crebs/skpackets/api/util/DebugUtil.java index 475e0ef..646a7c1 100644 --- a/src/main/java/org/crebs/skpackets/api/util/DebugUtil.java +++ b/src/main/java/org/crebs/skpackets/api/util/DebugUtil.java @@ -20,8 +20,9 @@ */ package org.crebs.skpackets.api.util; +import com.github.retrooper.packetevents.protocol.entity.data.EntityData; + import java.lang.reflect.Array; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.*; import java.util.stream.Collectors; @@ -31,6 +32,21 @@ public class DebugUtil { private static final int MAX_DEPTH = 5; private static final String INDENT_STEP = " "; + private static final ClassValue> GETTERS = new ClassValue<>() { + @Override protected List computeValue(Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .filter(method -> !method.isAnnotationPresent(Deprecated.class)) + .filter(method -> method.getParameterCount() == 0) + .filter(method -> method.getName().startsWith("get") || method.getName().startsWith("is")) + .filter(method -> !method.getName().contains("Handle") + && !method.getName().contains("copy") + && !method.getName().contains("toString") + && !method.getName().contains("PacketEventsData")) + .filter(Method::trySetAccessible) + .toList(); + } + }; + public static String getDebugString(Object obj) { if (obj == null) { return ""; @@ -54,32 +70,8 @@ private static void appendFields(Object obj, int depth, StringBuilder output) { Class currentClass = obj.getClass(); try { - for (Method method : currentClass.getDeclaredMethods()) { - if (method.isAnnotationPresent(Deprecated.class)) { - continue; - } - - String methodName = method.getName(); - - boolean isGetter = (methodName.startsWith("get") || methodName.startsWith("is")); - boolean noParameters = method.getParameterCount() == 0; - - boolean isInternal = - methodName.contains("Handle") - || methodName.contains("copy") - || methodName.contains("toString") - || methodName.contains("PacketEventsData"); - - if (isGetter && noParameters && !isInternal) { - if (!method.trySetAccessible()) { - continue; - } - - String fieldName = cleanFieldName(methodName); - Object value = method.invoke(obj); - - processValue(fieldName, value, depth, output); - } + for (Method method : GETTERS.get(currentClass)) { + processValue(cleanFieldName(method.getName()), method.invoke(obj), depth, output); } } catch (Exception e) { output.append(indent) @@ -128,18 +120,7 @@ private static void processValue( } } - boolean isMetadataList = - list.stream() - .anyMatch( - item -> - item != null - && (item.getClass() - .getName() - .contains("EntityData") - || item.getClass() - .getName() - .contains( - "AbstractEntityData"))); + boolean isMetadataList = list.stream().anyMatch(EntityData.class::isInstance); if (isMetadataList) { output.append(indent).append(fieldName).append(":\n"); @@ -267,32 +248,24 @@ private static void appendMetadataEntry(Object data, StringBuilder builder) { return; } - try { - Method getIndex = data.getClass().getMethod("getIndex"); - Method getType = data.getClass().getMethod("getType"); - Method getValue = data.getClass().getMethod("getValue"); - - Object index = getIndex.invoke(data); - Object type = getType.invoke(data); - Object value = getValue.invoke(data); - - String typeString = Objects.toString(type); - int start = typeString.indexOf('['); - int end = typeString.indexOf(']'); - if (start != -1 && end != -1 && end > start) { - typeString = typeString.substring(start + 1, end); - } - - builder.append("[") - .append(index) - .append(": ") - .append(typeString.toLowerCase(Locale.ENGLISH)) - .append(" = ") - .append(valueToString(value)) - .append("]\n"); - - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + if (!(data instanceof EntityData metadata)) { builder.append("Unparsable Data: ").append(data).append("\n"); + return; } + + String typeString = Objects.toString(metadata.getType()); + int start = typeString.indexOf('['); + int end = typeString.indexOf(']'); + if (start != -1 && end != -1 && end > start) { + typeString = typeString.substring(start + 1, end); + } + + builder.append("[") + .append(metadata.getIndex()) + .append(": ") + .append(typeString.toLowerCase(Locale.ENGLISH)) + .append(" = ") + .append(valueToString(metadata.getValue())) + .append("]\n"); } } diff --git a/src/main/java/org/crebs/skpackets/elements/entity/expressions/ExprFakeEntityFromUuid.java b/src/main/java/org/crebs/skpackets/elements/entity/expressions/ExprFakeEntityFromUuid.java index 3e3057d..24e0cd0 100644 --- a/src/main/java/org/crebs/skpackets/elements/entity/expressions/ExprFakeEntityFromUuid.java +++ b/src/main/java/org/crebs/skpackets/elements/entity/expressions/ExprFakeEntityFromUuid.java @@ -80,7 +80,9 @@ public boolean init( protected WrapperEntity @Nullable [] get(Event event) { UUID uuid = this.uuidExpr.getSingle(event); if (uuid == null) return null; - WrapperEntity entity = + var plugin = org.crebs.skpackets.SkPackets.getInstance(); + WrapperEntity entity = plugin == null ? null : plugin.getPacketEntities().getEntity(uuid).orElse(null); + if (entity == null) entity = EntityLib.get().getEntities().stream() .filter(candidate -> candidate.getUuid().equals(uuid)) .findFirst() diff --git a/src/main/java/org/crebs/skpackets/elements/entity/field/entity/FakeBaseEntityFieldRegistrar.java b/src/main/java/org/crebs/skpackets/elements/entity/field/entity/FakeBaseEntityFieldRegistrar.java index 3bb9a1c..750caa0 100644 --- a/src/main/java/org/crebs/skpackets/elements/entity/field/entity/FakeBaseEntityFieldRegistrar.java +++ b/src/main/java/org/crebs/skpackets/elements/entity/field/entity/FakeBaseEntityFieldRegistrar.java @@ -150,9 +150,6 @@ public static void finalizeEntitySetup( private static void setViewers(WrapperEntity w, Player[] viewers) { var manager = org.crebs.skpackets.SkPackets.getInstance().getPacketEntities(); - manager.getViewers(w).forEach(id -> manager.removeViewer(w, id)); - for (Player newViewer : viewers) { - manager.addViewer(w, newViewer); - } + manager.setViewers(w, java.util.Arrays.asList(viewers)); } } diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/DirectPacketFields.java b/src/main/java/org/crebs/skpackets/elements/general/field/DirectPacketFields.java new file mode 100644 index 0000000..21c78d4 --- /dev/null +++ b/src/main/java/org/crebs/skpackets/elements/general/field/DirectPacketFields.java @@ -0,0 +1,27 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientKeepAlive; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerKeepAlive; +import org.crebs.skpackets.api.util.field.FieldRegistrar; + +final class DirectPacketFields implements FieldRegistrar { + @Override public void register() { + PacketFieldRegistry.INSTANCE.builder(PacketType.Play.Server.KEEP_ALIVE, WrapperPlayServerKeepAlive.class) + .requiredField(Number.class, WrapperPlayServerKeepAlive::getId, + (packet, value) -> packet.setId(id(value)), "id") + .nativeType(long.class) + .constructor(values -> new WrapperPlayServerKeepAlive(id(values.getRequired("id", Number.class)))) + .build(); + PacketFieldRegistry.INSTANCE.builder(PacketType.Play.Client.KEEP_ALIVE, WrapperPlayClientKeepAlive.class) + .requiredField(Number.class, WrapperPlayClientKeepAlive::getId, + (packet, value) -> packet.setId(id(value)), "id") + .nativeType(long.class) + .constructor(values -> new WrapperPlayClientKeepAlive(id(values.getRequired("id", Number.class)))) + .build(); + } + + private static long id(Number value) { + return (long) PacketNumbers.convert(value, long.class, "id"); + } +} diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketConstructor.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketConstructor.java index 043d0dd..d0ced68 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/PacketConstructor.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketConstructor.java @@ -1,5 +1,7 @@ package org.crebs.skpackets.elements.general.field; +import org.crebs.skpackets.api.general.packet.PacketAccessManager; + import com.github.retrooper.packetevents.PacketEvents; import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; import com.github.retrooper.packetevents.protocol.player.ClientVersion; @@ -13,12 +15,17 @@ import java.lang.classfile.TypeAnnotation; import java.lang.invoke.MethodType; import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Type; import java.util.*; import java.util.stream.Stream; +import java.util.function.Function; + +record PacketConstructor(Constructor method, List arguments, + Function factory) { + private static final ClassValue CONSTRUCTORS = new ClassValue<>() { + @Override protected PacketConstructor computeValue(Class type) { return discover(type); } + }; -record PacketConstructor(Constructor method, List arguments) { private static final Map> PREFERRED = Map.of( "WrapperPlayClientInteractEntity", List.of("int", "InteractionHand", "Vector3d", "boolean"), "WrapperPlayServerBlockChange", List.of("Vector3i", "WrappedBlockState"), @@ -38,6 +45,10 @@ boolean injected() { } static PacketConstructor find(Class wrapper) { + return CONSTRUCTORS.get(wrapper); + } + + private static PacketConstructor discover(Class wrapper) { Map> constructors = new HashMap<>(); for (Constructor constructor : wrapper.getConstructors()) { if (constructor.isAnnotationPresent(Deprecated.class) || Arrays.stream(constructor.getParameterTypes()) @@ -59,7 +70,7 @@ static PacketConstructor find(Class wrapper) { if (!parameters[i].isNamePresent()) throw new IllegalStateException("Missing PacketEvents parameter names for " + constructor); arguments.add(new Argument(parameters[i].getName(), parameters[i].getParameterizedType(), nullable(model, i))); } - selected = new PacketConstructor(constructor, List.copyOf(arguments)); + selected = new PacketConstructor(constructor, List.copyOf(arguments), PacketAccessManager.constructor(constructor)); } if (selected != null) return selected; } catch (IOException exception) { @@ -79,16 +90,15 @@ PacketWrapper create(ConstructionContext values, Map packet = (PacketWrapper) method.newInstance(args); + PacketWrapper packet = (PacketWrapper) factory.apply(args); if (packet.getPacketTypeData().getPacketType() == null) { packet.getPacketTypeData().setPacketType(values.getKey()); packet.getPacketTypeData().setNativePacketId(values.getKey().getId(packet.getServerVersion().toClientVersion())); } return packet; - } catch (InvocationTargetException exception) { - throw new IllegalArgumentException("Cannot create " + values.getKey().getName() + ": " + exception.getCause().getMessage(), exception.getCause()); - } catch (ReflectiveOperationException exception) { - throw new IllegalStateException("Cannot invoke PacketEvents constructor " + method, exception); + } catch (IllegalArgumentException exception) { + Throwable cause = exception.getCause() == null ? exception : exception.getCause(); + throw new IllegalArgumentException("Cannot create " + values.getKey().getName() + ": " + cause.getMessage(), cause); } } diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldBinding.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldBinding.java index 1b8d326..60246c6 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldBinding.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldBinding.java @@ -1,5 +1,7 @@ package org.crebs.skpackets.elements.general.field; +import org.crebs.skpackets.api.general.packet.PacketAccessManager; + import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; import com.github.retrooper.packetevents.wrapper.PacketWrapper; import org.crebs.skpackets.api.util.field.BaseFieldRegistry; @@ -9,6 +11,8 @@ import java.lang.reflect.Method; import java.lang.reflect.Type; import java.util.*; +import java.util.function.Function; +import java.util.function.BiConsumer; record PacketFieldBinding( String name, @@ -16,8 +20,8 @@ record PacketFieldBinding( Type nativeType, boolean optional, boolean nullable, - Method getter, - Method setter, + Function getter, + BiConsumer setter, PacketFieldRule rule) { static PacketFieldBinding parameter(Class wrapper, PacketConstructor.Argument argument, List methods) { @@ -34,12 +38,20 @@ static PacketFieldBinding parameter(Class wrapper, PacketConstructor.Argument if (getter != null) names.add(PacketReflection.fieldName(getter.getName().replaceFirst("^(get|is|has)", ""))); String name = names.removeFirst(); boolean optional = argument.nullable() || PacketArguments.raw(argument.type()) == Optional.class || rule.defaultValue() != null; - return new PacketFieldBinding(name, List.copyOf(names), argument.type(), optional, argument.nullable(), getter, setter, rule); + return new PacketFieldBinding(name, List.copyOf(names), argument.type(), optional, argument.nullable(), reader(getter), writer(setter), rule); } static PacketFieldBinding extra(Method getter, Method setter) { String name = PacketReflection.fieldName(setter.getName().substring(3)); - return new PacketFieldBinding(name, List.of(), setter.getGenericParameterTypes()[0], true, false, getter, setter, PacketFieldRule.DEFAULT); + return new PacketFieldBinding(name, List.of(), setter.getGenericParameterTypes()[0], true, false, reader(getter), writer(setter), PacketFieldRule.DEFAULT); + } + + private static Function reader(Method getter) { + return getter == null ? null : PacketAccessManager.reader(getter); + } + + private static BiConsumer writer(Method setter) { + return setter == null ? null : PacketAccessManager.writer(setter); } Class skriptType() { @@ -69,13 +81,13 @@ Object constructorValue(ConstructionContext values) { } Object read(PacketWrapper packet) { - Object value = PacketReflection.invoke(getter, packet); + Object value = getter.apply(packet); if (rule.adapter() != null) return rule.adapter().reader().apply(packet, value); return typedArray(PacketArguments.toSkript(value), skriptType()); } void write(PacketWrapper packet, Object value) { - PacketReflection.invoke(setter, packet, encode(value)); + setter.accept(packet, encode(value)); } private Object encode(Object value) { diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistry.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistry.java index dbcb2e5..2236dc1 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistry.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistry.java @@ -40,6 +40,7 @@ private PacketFieldRegistry() { this.registrars.add(new org.crebs.skpackets.elements.team.TeamPacketFields()); registerNbt(); + this.registrars.add(new DirectPacketFields()); this.registrars.add(new PacketFieldRegistrar()); } @@ -48,13 +49,12 @@ private void registerNbt() { return; } try { - Class nbtApiClass = Class.forName("com.shanebeestudios.skbee.api.nbt.NBTApi"); - boolean enabled = (boolean) nbtApiClass.getMethod("isEnabled").invoke(null); - if (enabled) { - this.registrars.add(new NbtPacketFieldRegistrar()); + FieldRegistrar registrar = SkBeeHook.registrar(); + if (registrar != null) { + this.registrars.add(registrar); LogUtil.info("Hooked into SkBee NBT using NBT-API"); } - } catch (ClassNotFoundException ignored) { + } catch (NoClassDefFoundError ignored) { LogUtil.error( "SkBee NBT API not found, skPackets elements depending on NBT will not be" + " registered"); @@ -63,6 +63,13 @@ private void registerNbt() { } } + private static final class SkBeeHook { + static FieldRegistrar registrar() { + return com.shanebeestudios.skbee.api.nbt.NBTApi.isEnabled() + ? new NbtPacketFieldRegistrar() : null; + } + } + @Override public List getRegistrars() { return registrars; diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketReflection.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketReflection.java index c5655fb..bc352e3 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/PacketReflection.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketReflection.java @@ -1,15 +1,16 @@ package org.crebs.skpackets.elements.general.field; -import java.lang.reflect.*; +import org.crebs.skpackets.api.general.packet.PacketAccessManager; + +import java.lang.reflect.Method; +import java.lang.reflect.Type; import java.util.*; final class PacketReflection { private PacketReflection() {} static List methods(Class wrapper) { - return Arrays.stream(wrapper.getMethods()) - .filter(method -> !method.isBridge() && !method.isSynthetic()) - .sorted(Comparator.comparing(Method::toGenericString)).toList(); + return PacketAccessManager.methods(wrapper); } static String fieldName(String name) { @@ -70,14 +71,4 @@ static Method setter(List methods, String name, Class parameter) { .findFirst().orElse(null); } - static Object invoke(Method method, Object target, Object... args) { - try { - return method.invoke(target, args); - } catch (InvocationTargetException e) { - throw new IllegalArgumentException("PacketEvents field access failed: " + method.getName(), e.getCause()); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Cannot access PacketEvents method " + method, e); - } - } - } diff --git a/src/main/java/org/crebs/skpackets/elements/simple/effects/EffFakeBlock.java b/src/main/java/org/crebs/skpackets/elements/simple/effects/EffFakeBlock.java index 326db03..b5391a4 100644 --- a/src/main/java/org/crebs/skpackets/elements/simple/effects/EffFakeBlock.java +++ b/src/main/java/org/crebs/skpackets/elements/simple/effects/EffFakeBlock.java @@ -81,13 +81,11 @@ protected void execute(Event event) { manager.clear(viewer); continue; } - for (Location location : positions) { - try { - if (mode == 0) manager.show(viewer, location, state, keep); - else manager.reset(viewer, location); - } catch (IllegalArgumentException ex) { - Skript.error(ex.getMessage()); - } + try { + if (mode == 0) manager.show(viewer, java.util.Arrays.asList(positions), state, keep); + else manager.reset(viewer, java.util.Arrays.asList(positions)); + } catch (IllegalArgumentException ex) { + Skript.error(ex.getMessage()); } } } diff --git a/src/test/java/org/crebs/skpackets/api/entity/PacketEntityManagerTest.java b/src/test/java/org/crebs/skpackets/api/entity/PacketEntityManagerTest.java new file mode 100644 index 0000000..1b8538f --- /dev/null +++ b/src/test/java/org/crebs/skpackets/api/entity/PacketEntityManagerTest.java @@ -0,0 +1,227 @@ +package org.crebs.skpackets.api.entity; + +import io.papermc.paper.event.packet.PlayerChunkLoadEvent; +import io.papermc.paper.event.packet.PlayerChunkUnloadEvent; +import me.tofaa.entitylib.EntityLib; +import me.tofaa.entitylib.wrapper.WrapperEntity; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketEntityManagerTest { + @BeforeAll + static void bootstrap() { PacketEventsTestBootstrap.initialize(); } + + @Test + void chunkDeliveryTouchesOnlyItsEntitiesAndAuthorizedViewers() { + try (Fixture f = new Fixture()) { + when(f.player.isChunkSent(anyLong())).thenReturn(false); + WrapperEntity local = f.spawn(1, -1, -1, f.player, true); + WrapperEntity distant = f.spawn(2, 10, 10, f.player, true); + Player other = f.player(UUID.randomUUID()); + WrapperEntity privateEntity = f.spawn(3, -1, -1, other, true); + clearInvocations(local, distant, privateEntity); + f.manager.onChunkLoad(new PlayerChunkLoadEvent(f.chunk(-1, -1), f.player)); + verify(local).addViewer(f.viewer); + verifyNoInteractions(distant); + verify(privateEntity, never()).addViewer(f.viewer); + clearInvocations(local, distant, privateEntity); + f.manager.onChunkUnload(new PlayerChunkUnloadEvent(f.chunk(-1, -1), f.player)); + verify(local).removeViewer(f.viewer); + assertEquals(Set.of(f.viewer), f.manager.getViewers(local)); + verifyNoInteractions(distant); + clearInvocations(local); + f.manager.onChunkLoad(new PlayerChunkLoadEvent(f.chunk(-1, -1), f.player)); + verify(local).addViewer(f.viewer); + } + } + + @Test + void teleportMovesChunkIndexAndHidesEntitiesOutsideSentChunks() { + try (Fixture f = new Fixture()) { + WrapperEntity entity = f.spawn(1, 0, 0, f.player, true); + when(f.player.isChunkSent(anyLong())).thenReturn(false); + clearInvocations(entity); + f.manager.teleport(entity, new Location(f.world, -17, 64, 32)); + verify(entity).removeViewer(f.viewer); + clearInvocations(entity); + f.manager.onChunkLoad(new PlayerChunkLoadEvent(f.chunk(0, 0), f.player)); + verifyNoInteractions(entity); + f.manager.onChunkLoad(new PlayerChunkLoadEvent(f.chunk(-2, 2), f.player)); + verify(entity).addViewer(f.viewer); + } + } + + @Test + void settingSameViewersDoesNotDespawnOrRespawnAndChangesApplyOnlyDifferences() { + try (Fixture f = new Fixture()) { + WrapperEntity entity = f.spawn(1, 0, 0, f.player, true); + clearInvocations(entity); + f.manager.setViewers(entity, List.of(f.player, f.player)); + verify(entity, never()).addViewer(any(UUID.class)); + verify(entity, never()).removeViewer(any(UUID.class)); + Player other = f.player(UUID.randomUUID()); + f.manager.setViewers(entity, List.of(f.player, other)); + verify(entity).addViewer(other.getUniqueId()); + verify(entity, never()).removeViewer(f.viewer); + f.manager.setViewers(entity, List.of(other)); + verify(entity).removeViewer(f.viewer); + assertEquals(Set.of(other.getUniqueId()), f.manager.getViewers(entity)); + } + } + + @Test + void removalClearsChunkViewerAndUuidIndexes() { + try (Fixture f = new Fixture()) { + WrapperEntity entity = f.spawn(1, 0, 0, f.player, true); + UUID uuid = entity.getUuid(); + assertSame(entity, f.manager.getEntity(uuid).orElseThrow()); + f.manager.remove(entity); + assertTrue(f.manager.getEntity(uuid).isEmpty()); + clearInvocations(entity); + f.manager.onChunkLoad(new PlayerChunkLoadEvent(f.chunk(0, 0), f.player)); + f.manager.onJoin(new PlayerJoinEvent(f.player, (net.kyori.adventure.text.Component) null)); + f.runTasks(); + verifyNoInteractions(entity); + } + } + + @Test + void quittingRetainsPersistentViewersButCleansLastTransientViewer() { + try (Fixture f = new Fixture()) { + WrapperEntity persistent = f.spawn(1, 0, 0, f.player, true); + WrapperEntity transientEntity = f.spawn(2, 0, 0, f.player, false); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(f.player); + f.manager.onQuit(quit); + assertEquals(Set.of(f.viewer), f.manager.getViewers(persistent)); + assertTrue(f.manager.getEntity(2).isEmpty()); + verify(transientEntity).remove(); + clearInvocations(persistent, transientEntity); + f.manager.onChunkLoad(new PlayerChunkLoadEvent(f.chunk(0, 0), f.player)); + verify(persistent).addViewer(f.viewer); + verifyNoInteractions(transientEntity); + } + } + + @Test + void worldIsPartOfChunkIndexAndCrossWorldTeleportReindexesIt() { + try (Fixture f = new Fixture()) { + WrapperEntity entity = f.spawn(1, 0, 0, f.player, true); + World other = mock(World.class); + when(other.getUID()).thenReturn(UUID.randomUUID()); + when(other.getName()).thenReturn("other"); + f.manager.teleport(entity, new Location(other, 0, 64, 0)); + clearInvocations(entity); + f.manager.onChunkLoad(new PlayerChunkLoadEvent(f.chunk(0, 0), f.player)); + verifyNoInteractions(entity); + when(f.player.getWorld()).thenReturn(other); + org.bukkit.Chunk chunk = mock(org.bukkit.Chunk.class); + when(chunk.getWorld()).thenReturn(other); + f.manager.onChunkLoad(new PlayerChunkLoadEvent(chunk, f.player)); + verify(entity).addViewer(f.viewer); + } + } + + @Test + void ridersAreNotCulledUsingTheirStaleSpawnChunk() { + try (Fixture f = new Fixture()) { + WrapperEntity rider = f.spawn(1, 0, 0, f.player, true); + when(rider.getRiding()).thenReturn(mock(WrapperEntity.class)); + clearInvocations(rider); + f.manager.onChunkUnload(new PlayerChunkUnloadEvent(f.chunk(0, 0), f.player)); + verify(rider, never()).removeViewer(f.viewer); + } + } + + @Test + void uuidLookupIsSafeForAsyncCallersWhileMutationsStillRequireServerThread() throws Exception { + try (Fixture f = new Fixture()) { + WrapperEntity entity = f.spawn(1, 0, 0, f.player, true); + UUID uuid = entity.getUuid(); + try (var executor = java.util.concurrent.Executors.newSingleThreadExecutor()) { + assertSame(entity, executor.submit(() -> f.manager.getEntity(uuid).orElseThrow()).get()); + } + f.bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + assertThrows(IllegalStateException.class, () -> f.manager.remove(entity)); + } + } + + private static final class Fixture implements AutoCloseable { + final UUID viewer = UUID.randomUUID(); + final World world = mock(World.class); + final Player player; + final MockedStatic bukkit; + final MockedStatic library; + final List tasks = new ArrayList<>(); + final PacketEntityManager manager; + + Fixture() { + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.getName()).thenReturn("world"); + bukkit = mockStatic(Bukkit.class); + library = mockStatic(EntityLib.class); + library.when(EntityLib::get).thenReturn(mock(EntityLib.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + doAnswer(call -> { tasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(any(JavaPlugin.class), any(Runnable.class)); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + player = player(viewer); + manager = new PacketEntityManager(mock(JavaPlugin.class)); + } + + Player player(UUID id) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(id); + when(player.getWorld()).thenReturn(world); + when(player.isOnline()).thenReturn(true); + when(player.isChunkSent(anyLong())).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + return player; + } + + WrapperEntity spawn(int id, int chunkX, int chunkZ, Player viewer, boolean persistent) { + WrapperEntity entity = mock(WrapperEntity.class); + when(entity.getEntityId()).thenReturn(id); + when(entity.getUuid()).thenReturn(UUID.randomUUID()); + when(entity.isSpawned()).thenReturn(true); + Set actualViewers = new HashSet<>(); + when(entity.getViewers()).thenAnswer(call -> Set.copyOf(actualViewers)); + when(entity.addViewer(any(UUID.class))).thenAnswer(call -> actualViewers.add(call.getArgument(0))); + when(entity.removeViewer(any(UUID.class))).thenAnswer(call -> actualViewers.remove(call.getArgument(0))); + doAnswer(call -> { actualViewers.remove(call.getArgument(0)); return null; }) + .when(entity).handleViewerDisconnect(any(UUID.class)); + manager.spawn(entity, new Location(world, chunkX * 16, 64, chunkZ * 16), List.of(viewer), persistent); + return entity; + } + + org.bukkit.Chunk chunk(int x, int z) { + org.bukkit.Chunk chunk = mock(org.bukkit.Chunk.class); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(x); + when(chunk.getZ()).thenReturn(z); + return chunk; + } + + void runTasks() { List.copyOf(tasks).forEach(Runnable::run); tasks.clear(); } + + @Override public void close() { library.close(); bukkit.close(); } + } +} diff --git a/src/test/java/org/crebs/skpackets/api/entity/RealEntityPassengerManagerTest.java b/src/test/java/org/crebs/skpackets/api/entity/RealEntityPassengerManagerTest.java index a4c9828..9b26ec7 100644 --- a/src/test/java/org/crebs/skpackets/api/entity/RealEntityPassengerManagerTest.java +++ b/src/test/java/org/crebs/skpackets/api/entity/RealEntityPassengerManagerTest.java @@ -21,6 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.Mockito.*; class RealEntityPassengerManagerTest { @@ -122,10 +124,13 @@ void discardedViewerAndVehicleStateDoesNotBringBackOldRiders() { WrapperEntity first = fixture.passenger(11, fixture.player1Id); WrapperEntity second = fixture.passenger(12, fixture.player1Id); fixture.manager.update(fixture.vehicle, List.of(first), true); + assertTrue(fixture.manager.isMounted(first)); fixture.manager.forgetViewer(fixture.player1Id); + assertFalse(fixture.manager.isMounted(first)); fixture.manager.update(fixture.vehicle, List.of(second), true); fixture.assertLast(fixture.player1Id, 1, 99, 12); fixture.manager.forgetVehicle(fixture.vehicle); + assertFalse(fixture.manager.isMounted(second)); fixture.manager.update(fixture.vehicle, List.of(first), true); fixture.assertLast(fixture.player1Id, 1, 99, 11); } diff --git a/src/test/java/org/crebs/skpackets/api/general/packet/PacketAccessManagerTest.java b/src/test/java/org/crebs/skpackets/api/general/packet/PacketAccessManagerTest.java new file mode 100644 index 0000000..9967172 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/api/general/packet/PacketAccessManagerTest.java @@ -0,0 +1,152 @@ +package org.crebs.skpackets.api.general.packet; + +import com.github.retrooper.packetevents.event.PacketReceiveEvent; +import com.github.retrooper.packetevents.event.PacketSendEvent; +import com.github.retrooper.packetevents.event.ProtocolPacketEvent; +import com.github.retrooper.packetevents.manager.server.ServerVersion; +import com.github.retrooper.packetevents.protocol.PacketSide; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.protocol.player.User; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerKeepAlive; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientKeepAlive; +import com.github.retrooper.packetevents.wrapper.configuration.server.WrapperConfigServerKeepAlive; +import com.github.retrooper.packetevents.wrapper.configuration.client.WrapperConfigClientKeepAlive; +import io.netty.buffer.Unpooled; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketAccessManagerTest { + @BeforeAll static void initialize() { PacketEventsTestBootstrap.initialize(); } + @AfterEach void clearFactories() { PacketAccessManager.clearEventFactories(); } + + @Test void bindsPrimitiveAndNullableFieldsOnce() throws Exception { + Probe probe = new Probe(1); + var getter = Probe.class.getMethod("getValue"); + var setter = Probe.class.getMethod("setValue", int.class); + var read = PacketAccessManager.reader(getter); + var write = PacketAccessManager.writer(setter); + assertSame(read, PacketAccessManager.reader(getter)); + assertSame(write, PacketAccessManager.writer(setter)); + write.accept(probe, 42); + assertEquals(42, read.apply(probe)); + var nullable = PacketAccessManager.writer(Probe.class.getMethod("setText", String.class)); + nullable.accept(probe, null); + assertNull(PacketAccessManager.reader(Probe.class.getMethod("getText")).apply(probe)); + } + + @Test void preservesArraysAndVarargsAsSingleArguments() throws Exception { + String[] values = {"a", "b"}; + var constructor = Probe.class.getConstructor(String[].class); + var factory = PacketAccessManager.constructor(constructor); + assertSame(factory, PacketAccessManager.constructor(constructor)); + Probe probe = (Probe) factory.apply(new Object[] {values}); + assertSame(values, probe.names); + String[] replacement = {"c"}; + PacketAccessManager.writer(Probe.class.getMethod("setNames", String[].class)).accept(probe, replacement); + assertSame(replacement, PacketAccessManager.reader(Probe.class.getMethod("getNames")).apply(probe)); + } + + @Test void retainsUnderlyingFailures() throws Exception { + var read = PacketAccessManager.reader(Probe.class.getMethod("getFailure")); + assertInstanceOf(IOException.class, assertThrows(IllegalArgumentException.class, + () -> read.apply(new Probe(0))).getCause()); + var factory = PacketAccessManager.constructor(Probe.class.getConstructor(int.class)); + var failure = assertThrows(IllegalArgumentException.class, () -> factory.apply(new Object[] {-1})); + assertEquals("negative", failure.getCause().getMessage()); + var fatal = PacketAccessManager.reader(Probe.class.getMethod("getFatal")); + assertThrows(AssertionError.class, () -> fatal.apply(new Probe(0))); + } + + @Test void wrapsBothEventDirectionsWithAutomaticFallback() { + PacketTypeCommon server = type(PacketSide.SERVER); + PacketTypeCommon client = type(PacketSide.CLIENT); + assertTrue(((EventWrapper) PacketAccessManager.wrap(server, mock(PacketSendEvent.class))).sent); + assertFalse(((EventWrapper) PacketAccessManager.wrap(client, mock(PacketReceiveEvent.class))).sent); + } + + @Test void directFactoryOverridesFallbackAndIsClearedOnClose() { + PacketTypeCommon type = type(PacketSide.SERVER); + var event = mock(PacketSendEvent.class); + PacketWrapper fallback = PacketAccessManager.wrap(type, event); + PacketAccessManager.registerEventFactory(type, ignored -> fallback); + assertSame(fallback, PacketAccessManager.wrap(type, event)); + PacketAccessManager.clearEventFactories(); + assertNotSame(fallback, PacketAccessManager.wrap(type, event)); + } + + @Test void unsupportedPacketHasNoWrapper() { + assertNull(PacketAccessManager.wrap(mock(PacketTypeCommon.class), mock(PacketSendEvent.class))); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void decodesRealPacketsThroughDirectAndDiscoveredFactories(boolean server) { + for (boolean configuration : new boolean[] {false, true}) { + PacketTypeCommon type = configuration + ? server ? PacketType.Configuration.Server.KEEP_ALIVE : PacketType.Configuration.Client.KEEP_ALIVE + : server ? PacketType.Play.Server.KEEP_ALIVE : PacketType.Play.Client.KEEP_ALIVE; + ProtocolPacketEvent event = server ? mock(PacketSendEvent.class) : mock(PacketReceiveEvent.class); + var buffer = Unpooled.buffer().writeLong(Long.MAX_VALUE); + try { + User user = mock(User.class); + when(user.getClientVersion()).thenReturn(ServerVersion.V_26_3.toClientVersion()); + when(event.getUser()).thenReturn(user); + when(event.getServerVersion()).thenReturn(ServerVersion.V_26_3); + when(event.getByteBuf()).thenReturn(buffer); + when(event.getPacketType()).thenReturn(type); + var packet = PacketAccessManager.wrap(type, event); + long id; + if (packet instanceof WrapperPlayServerKeepAlive keepAlive) id = keepAlive.getId(); + else if (packet instanceof WrapperPlayClientKeepAlive keepAlive) id = keepAlive.getId(); + else if (packet instanceof WrapperConfigServerKeepAlive keepAlive) id = keepAlive.getId(); + else id = ((WrapperConfigClientKeepAlive) packet).getId(); + assertEquals(Long.MAX_VALUE, id); + assertEquals(0, buffer.readableBytes()); + assertEquals(type, packet.getPacketTypeData().getPacketType()); + verify(event).setLastUsedWrapper(packet); + } finally { buffer.release(); } + } + } + + private static PacketTypeCommon type(PacketSide side) { + PacketTypeCommon type = mock(PacketTypeCommon.class); + doReturn(EventWrapper.class).when(type).getWrapperClass(); + when(type.getSide()).thenReturn(side); + return type; + } + + public static class EventWrapper extends PacketWrapper { + final boolean sent; + public EventWrapper(PacketSendEvent event) { super(PacketType.Play.Server.KEEP_ALIVE); sent = true; } + public EventWrapper(PacketReceiveEvent event) { super(PacketType.Play.Client.KEEP_ALIVE); sent = false; } + } + + public static class Probe { + private int value; + private String text = "initial"; + private String[] names; + public Probe(int value) { + if (value < 0) throw new IllegalArgumentException("negative"); + this.value = value; + } + public Probe(String... names) { this.names = names; } + public int getValue() { return value; } + public void setValue(int value) { this.value = value; } + public String getText() { return text; } + public void setText(String value) { text = value; } + public String[] getNames() { return names; } + public void setNames(String... values) { names = values; } + public String getFailure() throws IOException { throw new IOException("failure"); } + public String getFatal() { throw new AssertionError("fatal"); } + } +} diff --git a/src/test/java/org/crebs/skpackets/api/simple/FakeBlockManagerTest.java b/src/test/java/org/crebs/skpackets/api/simple/FakeBlockManagerTest.java new file mode 100644 index 0000000..89db311 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/api/simple/FakeBlockManagerTest.java @@ -0,0 +1,348 @@ +package org.crebs.skpackets.api.simple; + +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.PacketEventsAPI; +import com.github.retrooper.packetevents.event.PacketSendEvent; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.world.states.WrappedBlockState; +import com.github.retrooper.packetevents.util.Vector3i; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBlockChange; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerChunkData; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerMultiBlockChange; +import io.github.retrooper.packetevents.util.SpigotConversionUtil; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FakeBlockManagerTest { + @BeforeAll + static void bootstrap() { PacketEventsTestBootstrap.initialize(); } + + @Test + void bulkEditConvertsOnceAndSendsOnePacketPerSectionIncludingNegativeCoordinates() { + try (Fixture f = new Fixture()) { + f.manager.show(f.player, List.of(f.pos(-1, -1, -1), f.pos(-2, -2, -2), + f.pos(-1, 0, -1), f.pos(0, 0, 0)), f.data, true); + assertEquals(3, f.sent.size()); + var negative = f.sent.stream().filter(p -> p.getChunkPosition().equals(new Vector3i(-1, -1, -1))) + .findFirst().orElseThrow(); + assertEquals(2, negative.getBlocks().length); + assertArrayEquals(new int[]{-2, -1}, Arrays.stream(negative.getBlocks()).mapToInt(b -> b.getX()).sorted().toArray()); + assertTrue(Arrays.stream(negative.getBlocks()).allMatch(b -> b.getBlockId() == 42 && b.getY() < 0)); + f.conversion.verify(() -> SpigotConversionUtil.fromBukkitBlockData(f.data), times(1)); + } + } + + @Test + void distantBlocksWaitForTheirChunkAndChunkRefreshesShareOneTask() { + try (Fixture f = new Fixture()) { + when(f.player.isChunkSent(anyLong())).thenReturn(false); + f.manager.show(f.player, List.of(f.pos(0, 0, 0), f.pos(16, 0, 0)), f.data, true); + assertTrue(f.sent.isEmpty()); + f.chunk(0, 0); f.chunk(0, 0); f.chunk(1, 0); + assertEquals(1, f.tasks.size()); + when(f.player.isChunkSent(anyLong())).thenReturn(true); + f.runTasks(); + assertEquals(2, f.sent.size()); + f.conversion.verify(() -> SpigotConversionUtil.fromBukkitBlockData(f.data), times(1)); + } + } + + @Test + void resetBeforeQueuedRefreshPreventsResurrectionAndDoesNotLoadDistantChunks() { + try (Fixture f = new Fixture()) { + when(f.player.isChunkSent(anyLong())).thenReturn(false); + f.manager.show(f.player, f.pos(0, 0, 0), f.data, true); + f.chunk(0, 0); + f.manager.reset(f.player, f.pos(0, 0, 0)); + when(f.player.isChunkSent(anyLong())).thenReturn(true); + f.runTasks(); + assertTrue(f.sent.isEmpty()); + verify(f.world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + } + } + + @Test + void onlyPersistentPositionsBlockRealUpdatesAndReplacingThemUpdatesTheWatch() { + try (Fixture f = new Fixture()) { + f.manager.show(f.player, f.pos(0, 0, 0), f.data, true); + assertTrue(f.blockUpdate(0, 0, 0)); + assertFalse(f.blockUpdate(1, 0, 0)); + f.manager.show(f.player, f.pos(0, 0, 0), f.data, false); + assertFalse(f.blockUpdate(0, 0, 0)); + f.sent.clear(); + f.manager.show(f.player, f.pos(0, 0, 0), f.data, false); + assertEquals(1, f.sent.size()); + } + } + + @Test + void unloadedChunksAreSkippedWhenQueuedWorkRunsAndReappliedOnNextDelivery() { + try (Fixture f = new Fixture()) { + f.manager.show(f.player, f.pos(0, 0, 0), f.data, true); + f.sent.clear(); + f.chunk(0, 0); + when(f.player.isChunkSent(anyLong())).thenReturn(false); + f.runTasks(); + assertTrue(f.sent.isEmpty()); + when(f.player.isChunkSent(anyLong())).thenReturn(true); + f.chunk(0, 0); + f.runTasks(); + assertEquals(1, f.sent.size()); + } + } + + @Test + void invalidBulkEditDoesNotPartiallyChangeStorage() { + try (Fixture f = new Fixture()) { + assertThrows(IllegalArgumentException.class, () -> f.manager.show(f.player, + List.of(f.pos(0, 0, 0), f.pos(1, 400, 0)), f.data, true)); + assertFalse(f.blockUpdate(0, 0, 0)); + assertTrue(f.sent.isEmpty()); + } + } + + @Test + void refreshBurstIsBoundedAndRemainingChunksRunOnFollowingTick() { + try (Fixture f = new Fixture()) { + List positions = new ArrayList<>(); + for (int x = 0; x < 100; x++) positions.add(f.pos(x * 16, 0, 0)); + f.manager.show(f.player, positions, f.data, true); + f.sent.clear(); + for (int x = 0; x < 100; x++) f.chunk(x, 0); + assertEquals(1, f.tasks.size()); + f.runTasks(); + assertFalse(f.sent.isEmpty()); + assertTrue(f.sent.size() < 100); + assertEquals(1, f.tasks.size()); + f.runTasks(); + assertEquals(100, f.sent.size()); + assertTrue(f.tasks.isEmpty()); + } + } + + @Test + void multiBlockUpdatesKeepUnrelatedRealChanges() { + try (Fixture f = new Fixture()) { + f.manager.show(f.player, f.pos(-1, -1, -1), f.data, true); + var fake = new WrapperPlayServerMultiBlockChange.EncodedBlock(1, -1, -1, -1); + var real = new WrapperPlayServerMultiBlockChange.EncodedBlock(2, -2, -1, -1); + var event = f.event(PacketType.Play.Server.MULTI_BLOCK_CHANGE); + try (var wrappers = mockConstruction(WrapperPlayServerMultiBlockChange.class, (packet, context) -> { + when(packet.getChunkPosition()).thenReturn(new Vector3i(-1, -1, -1)); + when(packet.getBlocks()).thenReturn(new WrapperPlayServerMultiBlockChange.EncodedBlock[]{fake, real}); + })) { + f.manager.onPacketSend(event); + verify(wrappers.constructed().getFirst()).setBlocks(argThat(blocks -> + blocks.length == 1 && blocks[0] == real)); + verify(event).markForReEncode(true); + verify(event, never()).setCancelled(true); + } + } + } + + @Test + void clearBatchesRealBlocksAndRestoresTileDataWithoutLeavingAnOverride() { + try (Fixture f = new Fixture()) { + var first = f.pos(0, 0, 0); + var second = f.pos(1, 0, 0); + f.manager.show(f.player, List.of(first, second), f.data, true); + BlockData realData = mock(BlockData.class); + var realState = mock(WrappedBlockState.class); + when(realState.getGlobalId()).thenReturn(7); + f.conversion.when(() -> SpigotConversionUtil.fromBukkitBlockData(realData)).thenReturn(realState); + var tile = mock(org.bukkit.block.TileState.class); + var tileBlock = mock(org.bukkit.block.Block.class); + when(tileBlock.getState(false)).thenReturn(tile); + when(tile.getLocation()).thenReturn(first); + when(tileBlock.getBlockData()).thenReturn(realData); + var plainBlock = mock(org.bukkit.block.Block.class); + when(plainBlock.getBlockData()).thenReturn(realData); + when(f.world.getBlockAt(0, 0, 0)).thenReturn(tileBlock); + when(f.world.getBlockAt(1, 0, 0)).thenReturn(plainBlock); + f.sent.clear(); + f.manager.clear(f.player); + assertEquals(1, f.sent.size()); + assertEquals(2, f.sent.getFirst().getBlocks().length); + assertTrue(Arrays.stream(f.sent.getFirst().getBlocks()).allMatch(b -> b.getBlockId() == 7)); + verify(f.player).sendBlockUpdate(first, tile); + assertFalse(f.blockUpdate(0, 0, 0)); + f.chunk(0, 0); + assertTrue(f.tasks.isEmpty()); + } + } + + @Test + void bulkVisibilityIsCheckedOncePerChunkAndDuplicatePositionsAreSentOnce() { + try (Fixture f = new Fixture()) { + List positions = new ArrayList<>(); + for (int y = 0; y < 32; y++) { + positions.add(f.pos(0, y, 0)); + positions.add(f.pos(0, y, 0)); + } + clearInvocations(f.player); + f.manager.show(f.player, positions, f.data, true); + verify(f.player, times(1)).isChunkSent(0L); + assertEquals(32, f.sent.stream().mapToInt(p -> p.getBlocks().length).sum()); + } + } + + @Test + void clearingLargeDistantOverridesRestoresOnlySentChunks() { + try (Fixture f = new Fixture()) { + f.manager.show(f.player, List.of(f.pos(1600, 0, 0), f.pos(3200, 0, 0)), f.data, true); + f.sent.clear(); + clearInvocations(f.player, f.world); + f.manager.clear(f.player); + assertTrue(f.sent.isEmpty()); + verify(f.player, never()).isChunkSent(anyLong()); + verify(f.world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + } + } + + @Test + void reconnectWaitsForChunkLoaderBeforeReadingSentChunksAndRestoresPersistentBlocks() { + try (Fixture f = new Fixture()) { + f.manager.show(f.player, f.pos(0, 0, 0), f.data, true); + f.sent.clear(); + when(f.player.getSentChunkKeys()).thenThrow(new NullPointerException("chunk loader not initialized")); + clearInvocations(f.player); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(f.player); + assertDoesNotThrow(() -> f.manager.onJoin(event)); + verify(f.player, never()).getSentChunkKeys(); + assertEquals(1, f.tasks.size()); + doReturn(Set.of(0L)).when(f.player).getSentChunkKeys(); + f.runTasks(); + f.runTasks(); + assertEquals(1, f.sent.size()); + } + } + + @Test + void worldChangeDefersSentChunkAccessAndQuitBeforeRefreshSkipsIt() { + try (Fixture f = new Fixture()) { + f.manager.show(f.player, f.pos(0, 0, 0), f.data, true); + f.sent.clear(); + when(f.player.getSentChunkKeys()).thenThrow(new NullPointerException("chunk loader not initialized")); + clearInvocations(f.player); + PlayerChangedWorldEvent event = mock(PlayerChangedWorldEvent.class); + when(event.getPlayer()).thenReturn(f.player); + assertDoesNotThrow(() -> f.manager.onWorld(event)); + verify(f.player, never()).getSentChunkKeys(); + when(f.player.isOnline()).thenReturn(false); + assertDoesNotThrow(f::runTasks); + verify(f.player, never()).getSentChunkKeys(); + assertTrue(f.sent.isEmpty()); + } + } + + private static final class Fixture implements AutoCloseable { + final World world = mock(World.class); + final Player player = mock(Player.class); + final UUID viewer = UUID.randomUUID(); + final BlockData data = mock(BlockData.class); + final List sent = new ArrayList<>(); + final List tasks = new ArrayList<>(); + final MockedStatic bukkit; + final MockedStatic packets; + final MockedStatic conversion; + final FakeBlockManager manager; + + Fixture() { + var api = mock(PacketEventsAPI.class, RETURNS_DEEP_STUBS); + var settings = PacketEvents.getAPI().getSettings(); + var version = PacketEvents.getAPI().getServerManager().getVersion(); + when(api.getSettings()).thenReturn(settings); + when(api.getServerManager().getVersion()).thenReturn(version); + var playerManager = api.getPlayerManager(); + doAnswer(call -> { sent.add(call.getArgument(1)); return null; }) + .when(playerManager).sendPacketSilently(any(Player.class), any(PacketWrapper.class)); + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.getMinHeight()).thenReturn(-64); + when(world.getMaxHeight()).thenReturn(320); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(player.getUniqueId()).thenReturn(viewer); + when(player.getWorld()).thenReturn(world); + when(player.isOnline()).thenReturn(true); + when(player.isChunkSent(anyLong())).thenReturn(true); + when(player.getSentChunkKeys()).thenReturn(Set.of(0L)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + doAnswer(call -> { tasks.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(any(JavaPlugin.class), any(Runnable.class)); + bukkit = mockStatic(Bukkit.class); + packets = mockStatic(PacketEvents.class); + conversion = mockStatic(SpigotConversionUtil.class); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.getPlayer(viewer)).thenReturn(player); + packets.when(PacketEvents::getAPI).thenReturn(api); + var state = mock(WrappedBlockState.class); + when(state.getGlobalId()).thenReturn(42); + conversion.when(() -> SpigotConversionUtil.fromBukkitBlockData(data)).thenReturn(state); + manager = new FakeBlockManager(mock(JavaPlugin.class)); + } + + Location pos(int x, int y, int z) { return new Location(world, x, y, z); } + + PacketSendEvent event(PacketType.Play.Server type) { + var event = mock(PacketSendEvent.class, RETURNS_DEEP_STUBS); + when(event.getPacketType()).thenReturn(type); + when(event.getUser().getUUID()).thenReturn(viewer); + return event; + } + + void chunk(int x, int z) { + var event = event(PacketType.Play.Server.CHUNK_DATA); + List after = new ArrayList<>(); + when(event.getTasksAfterSend()).thenReturn(after); + try (var ignored = mockConstruction(WrapperPlayServerChunkData.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), (wrapper, context) -> { + when(wrapper.getColumn().getX()).thenReturn(x); + when(wrapper.getColumn().getZ()).thenReturn(z); + })) { + manager.onPacketSend(event); + } + after.forEach(Runnable::run); + } + + boolean blockUpdate(int x, int y, int z) { + var event = event(PacketType.Play.Server.BLOCK_CHANGE); + try (var ignored = mockConstruction(WrapperPlayServerBlockChange.class, (wrapper, context) -> + when(wrapper.getBlockPosition()).thenReturn(new Vector3i(x, y, z)))) { + manager.onPacketSend(event); + } + return mockingDetails(event).getInvocations().stream() + .anyMatch(call -> call.getMethod().getName().equals("setCancelled") && Boolean.TRUE.equals(call.getArgument(0))); + } + + void runTasks() { + var batch = List.copyOf(tasks); + tasks.clear(); + batch.forEach(Runnable::run); + } + + @Override public void close() { + conversion.close(); packets.close(); bukkit.close(); + } + } +} diff --git a/src/test/java/org/crebs/skpackets/api/util/DebugUtilTest.java b/src/test/java/org/crebs/skpackets/api/util/DebugUtilTest.java new file mode 100644 index 0000000..c7eac7c --- /dev/null +++ b/src/test/java/org/crebs/skpackets/api/util/DebugUtilTest.java @@ -0,0 +1,39 @@ +package org.crebs.skpackets.api.util; + +import com.github.retrooper.packetevents.protocol.entity.data.EntityData; +import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DebugUtilTest { + @BeforeAll static void initialize() { PacketEventsTestBootstrap.initialize(); } + + @Test void formatsTypedMetadataIncludingNullAndMixedEntries() { + Holder holder = new Holder(Arrays.asList(new EntityData<>(2, EntityDataTypes.STRING, "hello"), null, "other")); + String output = DebugUtil.getDebugString(holder); + assertTrue(output.contains("[2:"), output); + assertTrue(output.contains(" = hello]"), output); + assertTrue(output.contains(" metadata entry"), output); + assertTrue(output.contains("Unparsable Data: other"), output); + assertFalse(output.contains("Reflection Error"), output); + } + + @Test void repeatedDebugReadsStillUseCurrentValues() { + Holder holder = new Holder(List.of("first")); + assertTrue(DebugUtil.getDebugString(holder).contains("first")); + holder.entries = List.of("second"); + assertTrue(DebugUtil.getDebugString(holder).contains("second")); + } + + public static class Holder { + private List entries; + Holder(List entries) { this.entries = entries; } + public List getEntries() { return entries; } + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/AddonRegistrationTest.java b/src/test/java/org/crebs/skpackets/elements/AddonRegistrationTest.java index 4569fa8..3c4357f 100644 --- a/src/test/java/org/crebs/skpackets/elements/AddonRegistrationTest.java +++ b/src/test/java/org/crebs/skpackets/elements/AddonRegistrationTest.java @@ -38,8 +38,7 @@ class AddonRegistrationTest { @Test void registersAllModulesWithoutDuplicateTypes() throws Exception { PacketEventsTestBootstrap.initialize(); - // Skript requires the host name to differ from the addon name. - Skript skript = Skript.of(AddonRegistrationTest.class, "skPackets-test"); + Skript skript = Skript.of(AddonRegistrationTest.class, "skPackets-test"); // name can't be the same as addon name skript.storeRegistry(EventValueRegistry.class, EventValueRegistry.empty(skript)); try (var snapshots = new RegistrySnapshots( Classes.class, Comparators.class, Converters.class, diff --git a/src/test/java/org/crebs/skpackets/elements/entity/expressions/EntityLookupExpressionsTest.java b/src/test/java/org/crebs/skpackets/elements/entity/expressions/EntityLookupExpressionsTest.java index 274b1e8..2f8ef0a 100644 --- a/src/test/java/org/crebs/skpackets/elements/entity/expressions/EntityLookupExpressionsTest.java +++ b/src/test/java/org/crebs/skpackets/elements/entity/expressions/EntityLookupExpressionsTest.java @@ -77,7 +77,8 @@ void lookupByUuidSearchesTheWholeCollection() { var expression = new ExprFakeEntityFromUuid(); Expression[] inputs = {input(wanted)}; assertTrue(expression.init(inputs, 0, Kleenean.FALSE, new ParseResult("", inputs))); - try (var library = mockStatic(EntityLib.class, withSettings().mockMaker(MockMakers.INLINE))) { + try (var plugin = mockStatic(org.crebs.skpackets.SkPackets.class, withSettings().mockMaker(MockMakers.INLINE)); + var library = mockStatic(EntityLib.class, withSettings().mockMaker(MockMakers.INLINE))) { library.when(EntityLib::get).thenReturn(lib); assertArrayEquals(new WrapperEntity[]{second}, expression.get(event)); inputs[0] = input(UUID.randomUUID()); @@ -92,6 +93,25 @@ void lookupByUuidSearchesTheWholeCollection() { assertTrue(expression.isSingle()); } + @Test + void managedUuidLookupDoesNotScanEntityLib() { + UUID wanted = UUID.randomUUID(); + WrapperEntity entity = mock(WrapperEntity.class); + var manager = mock(org.crebs.skpackets.api.entity.PacketEntityManager.class); + var plugin = mock(org.crebs.skpackets.SkPackets.class); + when(plugin.getPacketEntities()).thenReturn(manager); + when(manager.getEntity(wanted)).thenReturn(java.util.Optional.of(entity)); + var expression = new ExprFakeEntityFromUuid(); + Expression[] inputs = {input(wanted)}; + expression.init(inputs, 0, Kleenean.FALSE, new ParseResult("", inputs)); + try (var plugins = mockStatic(org.crebs.skpackets.SkPackets.class); + var library = mockStatic(EntityLib.class)) { + plugins.when(org.crebs.skpackets.SkPackets::getInstance).thenReturn(plugin); + assertArrayEquals(new WrapperEntity[]{entity}, expression.get(event)); + library.verifyNoInteractions(); + } + } + @Test void allEntitiesReflectsCurrentLibraryContents() { EntityLib lib = mock(EntityLib.class, withSettings().mockMaker(MockMakers.INLINE)); diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/DirectPacketFieldsTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/DirectPacketFieldsTest.java new file mode 100644 index 0000000..8b0cc80 --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/DirectPacketFieldsTest.java @@ -0,0 +1,48 @@ +package org.crebs.skpackets.elements.general.field; + +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.crebs.skpackets.support.PacketEventsTestBootstrap; +import org.crebs.skpackets.api.util.field.FieldAccessor; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DirectPacketFieldsTest { + @BeforeAll static void initialize() { + PacketEventsTestBootstrap.initialize(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + PacketFieldRegistry.INSTANCE.registerAll(); + } + } + + static Stream types() { + return Stream.of(PacketType.Play.Server.KEEP_ALIVE, PacketType.Play.Client.KEEP_ALIVE); + } + + @ParameterizedTest @MethodSource("types") + @SuppressWarnings("unchecked") + void directFieldsPreserveLongRangeAndNumericValidation(PacketTypeCommon type) { + var schema = PacketFieldRegistry.INSTANCE.getSchema(type); + var field = (FieldAccessor, Number>) schema.getAccessor("id"); + var packet = PacketCreationCoverageTest.create(type, Map.of("id", Long.MAX_VALUE)); + assertEquals(Long.MAX_VALUE, field.getter().apply(packet)); + assertEquals(long.class, field.nativeType()); + field.setter().accept(packet, Long.MIN_VALUE); + assertEquals(Long.MIN_VALUE, field.getter().apply(packet)); + assertThrows(IllegalArgumentException.class, () -> field.setter().accept(packet, new BigDecimal("1.5"))); + assertThrows(IllegalArgumentException.class, () -> PacketCreationCoverageTest.create(type, + Map.of("id", new BigDecimal("9223372036854775808")))); + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistryOptionalPluginTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistryOptionalPluginTest.java new file mode 100644 index 0000000..956952f --- /dev/null +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketFieldRegistryOptionalPluginTest.java @@ -0,0 +1,59 @@ +package org.crebs.skpackets.elements.general.field; + +import org.bukkit.Bukkit; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PacketFieldRegistryOptionalPluginTest { + @ParameterizedTest @ValueSource(booleans = {false, true}) + void missingSkBeeClassesDoNotPreventRegistryLoading(boolean pluginEnabled) throws Exception { + var plugins = mock(PluginManager.class); + when(plugins.isPluginEnabled("SkBee")).thenReturn(pluginEnabled); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getConsoleSender).thenReturn(mock(ConsoleCommandSender.class)); + var loader = new WithoutSkBee(); + Class registryClass = loader.loadClass(PacketFieldRegistry.class.getName()); + Object registry = registryClass.getField("INSTANCE").get(null); + var registrars = (List) registryClass.getMethod("getRegistrars").invoke(registry); + assertTrue(registrars.stream().noneMatch(registrar -> registrar instanceof NbtPacketFieldRegistrar)); + assertEquals(pluginEnabled, loader.requestedSkBee); + } + } + private static final class WithoutSkBee extends ClassLoader { + boolean requestedSkBee; + WithoutSkBee() { super(PacketFieldRegistryOptionalPluginTest.class.getClassLoader()); } + + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + if (name.startsWith("com.shanebeestudios.skbee.")) { + requestedSkBee = true; + throw new ClassNotFoundException(name); + } + String registry = PacketFieldRegistry.class.getName(); + if (!name.equals(registry) && !name.startsWith(registry + "$") + && !name.equals(DirectPacketFields.class.getName())) return super.loadClass(name, resolve); + Class result = findLoadedClass(name); + if (result == null) { + try (var input = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { + if (input == null) throw new ClassNotFoundException(name); + byte[] bytes = input.readAllBytes(); + result = defineClass(name, bytes, 0, bytes.length); + } catch (IOException exception) { + throw new ClassNotFoundException(name, exception); + } + } + if (resolve) resolveClass(result); + return result; + } + } + } +} diff --git a/src/test/java/org/crebs/skpackets/elements/simple/effects/SimpleEffectsTest.java b/src/test/java/org/crebs/skpackets/elements/simple/effects/SimpleEffectsTest.java index a12f421..21d793c 100644 --- a/src/test/java/org/crebs/skpackets/elements/simple/effects/SimpleEffectsTest.java +++ b/src/test/java/org/crebs/skpackets/elements/simple/effects/SimpleEffectsTest.java @@ -81,8 +81,7 @@ void fakeBlocksDispatchEveryLocationAndViewer(boolean persistent) { effect.execute(null); for (Player viewer : new Player[]{player1, player2}) { - verify(manager).show(viewer, a, data, persistent); - verify(manager).show(viewer, b, data, persistent); + verify(manager).show(viewer, List.of(a, b), data, persistent); } verifyNoMoreInteractions(manager); @@ -101,7 +100,7 @@ void fakeBlocksResetClearAndMissingData() { EffFakeBlock reset = new EffFakeBlock(); init(reset, 1, false, values(location), values(player)); reset.execute(null); - verify(manager).reset(player, location); + verify(manager).reset(player, List.of(location)); EffFakeBlock clear = new EffFakeBlock(); init(clear, 2, false, values(player)); clear.execute(null); From 7a2a9529f51e1fdb7b5802aa11d3bf884559bd58 Mon Sep 17 00:00:00 2001 From: CrebsTheCoder Date: Tue, 10 Nov 2026 16:07:32 +0800 Subject: [PATCH 3/4] Fix: Added some null checks and validation to prevent npe from qodana reports --- .../general/field/PacketFieldSections.java | 10 ++++--- .../general/field/PacketValueCodecs.java | 18 ++++++++++--- .../general/field/PacketValueTypes.java | 4 +-- .../general/field/PacketArgumentsTest.java | 27 +++++++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldSections.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldSections.java index 516f11f..7a6072f 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldSections.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketFieldSections.java @@ -72,11 +72,15 @@ private static Expression nested(Type type, SectionNode section, String name) if (!(node instanceof SectionNode child)) { error("'" + name + "' requires a section per list entry."); return null; } - if (!labels.add(child.getKey().toLowerCase(Locale.ROOT))) { - error("Duplicate list entry in '" + name + "': " + child.getKey()); return null; + String label = child.getKey(); + if (label == null) { + error("List entries in '" + name + "' require a label."); return null; + } + if (!labels.add(label.toLowerCase(Locale.ROOT))) { + error("Duplicate list entry in '" + name + "': " + label); return null; } Map> record = new LinkedHashMap<>(); - if (!parse(shape.fields(), child, record, name + "." + child.getKey())) return null; + if (!parse(shape.fields(), child, record, name + "." + label)) return null; records.add(record); } } else { diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodecs.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodecs.java index 78b27e4..ecedd7e 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodecs.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueCodecs.java @@ -88,9 +88,9 @@ private PacketValueCodecs() {} value -> SpigotConversionUtil.fromBukkitEntityType(EntityUtils.toBukkitEntityType(value))), of(PotionType.class, org.bukkit.potion.PotionEffectType.class, SpigotConversionUtil::toBukkitPotionEffectType, SpigotConversionUtil::fromBukkitPotionEffectType), - of(com.github.retrooper.packetevents.protocol.item.type.ItemType.class, ItemType.class, + new PacketValueCodec<>(com.github.retrooper.packetevents.protocol.item.type.ItemType.class, ItemType.class, value -> new ItemType(SpigotConversionUtil.toBukkitItemMaterial(value)), - value -> SpigotConversionUtil.fromBukkitItemMaterial(value.getRandom().getType())), + PacketValueCodecs::itemType), of(SoundCategory.class, org.bukkit.SoundCategory.class, PacketValueCodecs::soundCategory, PacketValueCodecs::soundCategory), of(HumanoidArm.class, org.bukkit.inventory.MainHand.class, @@ -98,7 +98,7 @@ private PacketValueCodecs() {} of(EntityPose.class, org.bukkit.entity.Pose.class, SpigotConversionUtil::toBukkitPose, SpigotConversionUtil::fromBukkitPose), of(ResourceLocation.class, NamespacedKey.class, - value -> NamespacedKey.fromString(value.toString()), value -> new ResourceLocation(value.toString())), + PacketValueCodecs::namespacedKey, value -> new ResourceLocation(value.toString())), of(BossBar.Color.class, Color.class, value -> BossBarUtils.rgbFromBarColor(BarColor.valueOf(value.name())), PacketValueCodecs::bossBarColor), of(BossBar.Overlay.class, BarStyle.class, @@ -129,6 +129,18 @@ private PacketValueCodecs() {} return null; } + private static NamespacedKey namespacedKey(ResourceLocation value) { + NamespacedKey key = NamespacedKey.fromString(value.toString()); + if (key == null) throw new IllegalArgumentException("Invalid resource key: " + value); + return key; + } + + private static com.github.retrooper.packetevents.protocol.item.type.ItemType itemType(ItemType value, String name) { + org.bukkit.inventory.ItemStack item = value.getRandom(); + if (item == null) throw new IllegalArgumentException("Packet field '" + name + "' requires an item type that can produce an item."); + return SpigotConversionUtil.fromBukkitItemMaterial(item.getType()); + } + private static org.bukkit.inventory.EquipmentSlot equipmentSlot(EquipmentSlot value) { return switch (value) { case MAIN_HAND -> org.bukkit.inventory.EquipmentSlot.HAND; diff --git a/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueTypes.java b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueTypes.java index 7456b9b..60e7eb0 100644 --- a/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueTypes.java +++ b/src/main/java/org/crebs/skpackets/elements/general/field/PacketValueTypes.java @@ -33,9 +33,9 @@ public static List literalNames(Class type) { Parser parser = info == null ? null : info.getParser(); List names = new ArrayList<>(); for (Object constant : type.getEnumConstants()) { - if (type == org.bukkit.block.BlockFace.class && !Set.of(org.bukkit.block.BlockFace.DOWN, + if (constant instanceof org.bukkit.block.BlockFace face && !Set.of(org.bukkit.block.BlockFace.DOWN, org.bukkit.block.BlockFace.UP, org.bukkit.block.BlockFace.NORTH, org.bukkit.block.BlockFace.SOUTH, - org.bukkit.block.BlockFace.WEST, org.bukkit.block.BlockFace.EAST, org.bukkit.block.BlockFace.SELF).contains(constant)) continue; + org.bukkit.block.BlockFace.WEST, org.bukkit.block.BlockFace.EAST, org.bukkit.block.BlockFace.SELF).contains(face)) continue; String name; if (parser != null) name = parser.toString(constant, 0).replace('_', ' '); else { diff --git a/src/test/java/org/crebs/skpackets/elements/general/field/PacketArgumentsTest.java b/src/test/java/org/crebs/skpackets/elements/general/field/PacketArgumentsTest.java index 8462359..aca6580 100644 --- a/src/test/java/org/crebs/skpackets/elements/general/field/PacketArgumentsTest.java +++ b/src/test/java/org/crebs/skpackets/elements/general/field/PacketArgumentsTest.java @@ -12,6 +12,7 @@ import java.util.*; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; class PacketArgumentsTest { List ids; @@ -90,6 +91,32 @@ void mainHandUsesNativeValues(org.bukkit.inventory.MainHand hand) { assertEquals(Long.MAX_VALUE, PacketArguments.convert(Long.MAX_VALUE, long.class, "id")); } + @Test void resourceKeysRoundTripWithoutLosingNamespace() { + var key = new org.bukkit.NamespacedKey("example", "some/path"); + Object packetKey = PacketArguments.convert(key, + com.github.retrooper.packetevents.resources.ResourceLocation.class, "key"); + assertEquals(key, PacketArguments.toSkript(packetKey)); + } + + @Test void rejectsResourceKeysThatBukkitCannotRepresent() { + var key = mock(com.github.retrooper.packetevents.resources.ResourceLocation.class); + when(key.toString()).thenReturn("invalid key"); + var error = assertThrows(IllegalArgumentException.class, () -> PacketArguments.toSkript(key)); + assertEquals("Invalid resource key: invalid key", error.getMessage()); + } + + @Test void rejectsItemTypesThatCannotProduceAnItem() throws ClassNotFoundException { + try (var skript = mockStatic(ch.njol.skript.Skript.class)) { + Class.forName(ch.njol.skript.aliases.ItemType.class.getName()); + } + var item = mock(ch.njol.skript.aliases.ItemType.class); + when(item.getRandom()).thenReturn(null); + var error = assertThrows(IllegalArgumentException.class, () -> PacketArguments.convert(item, + com.github.retrooper.packetevents.protocol.item.type.ItemType.class, "item")); + assertTrue(error.getMessage().contains("Packet field 'item'")); + assertTrue(error.getMessage().contains("can produce an item")); + } + @Test void leavesQueuedModuleTypesToTheirExistingRegistrations() { assertTrue(PacketValueTypes.managedByModule(InteractionHand.class)); assertTrue(PacketValueTypes.managedByModule(org.crebs.skpackets.api.entity.metadata.EntityMeta.class)); From 07c5cb6ea3b26768ec401b3ca7c237e3697ba74d Mon Sep 17 00:00:00 2001 From: CrebsTheCoder Date: Wed, 11 Nov 2026 22:37:53 +0800 Subject: [PATCH 4/4] Prepare for 1.0.2 and added Bstats cuz i love stats --- build.gradle.kts | 4 +++- src/main/java/org/crebs/skpackets/Metrics.java | 4 ---- src/main/java/org/crebs/skpackets/SkPackets.java | 7 +++++++ src/main/resources/lang/english.lang | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) delete mode 100644 src/main/java/org/crebs/skpackets/Metrics.java diff --git a/build.gradle.kts b/build.gradle.kts index e4a90e7..ff2c59a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "org.crebs" -version = "1.0.1" +version = "1.0.2" val mockitoAgent by configurations.creating @@ -27,6 +27,7 @@ repositories { } dependencies { + implementation("org.bstats:bstats-bukkit:3.2.1") testImplementation(platform("org.junit:junit-bom:6.0.0")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -76,6 +77,7 @@ tasks.processResources { tasks.shadowJar { archiveClassifier.set("") + relocate("org.bstats", "org.crebs.skpackets.libs.bstats") relocate("me.tofaa.entitylib", "org.crebs.skpackets.libs.entitylib") relocate("com.github.shanebeee.skr", "org.crebs.skpackets.libs.skr") mergeServiceFiles() diff --git a/src/main/java/org/crebs/skpackets/Metrics.java b/src/main/java/org/crebs/skpackets/Metrics.java deleted file mode 100644 index f190925..0000000 --- a/src/main/java/org/crebs/skpackets/Metrics.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.crebs.skpackets; - -public class Metrics { -} diff --git a/src/main/java/org/crebs/skpackets/SkPackets.java b/src/main/java/org/crebs/skpackets/SkPackets.java index ae2d48c..37b0cec 100644 --- a/src/main/java/org/crebs/skpackets/SkPackets.java +++ b/src/main/java/org/crebs/skpackets/SkPackets.java @@ -1,6 +1,7 @@ package org.crebs.skpackets; import com.github.shanebeee.skr.Registration; +import org.bstats.bukkit.Metrics; import org.bukkit.plugin.java.JavaPlugin; import org.crebs.skpackets.api.entity.PacketEntityManager; import org.crebs.skpackets.api.simple.FakeBlockManager; @@ -44,6 +45,7 @@ public PacketTeamManager getPacketTeams() { private ElementRuntime elementRuntime; private Registration registration; private org.crebs.skpackets.api.update.UpdateChecker updateChecker; + private Metrics metrics; public static SkPackets getInstance() { if (instance == null) { @@ -92,12 +94,17 @@ public void onEnable() { registration.finalizeRegistration(); ElementCounts.log(registration, getServer().getConsoleSender()); saveDefaultConfig(); + metrics = new Metrics(this, 35259); updateChecker = new org.crebs.skpackets.api.update.UpdateChecker(this); updateChecker.start(); } @Override public void onDisable() { + if (metrics != null) { + metrics.shutdown(); + metrics = null; + } if (updateChecker != null) { updateChecker.close(); updateChecker = null; diff --git a/src/main/resources/lang/english.lang b/src/main/resources/lang/english.lang index 10a2d29..7dbbbe7 100644 --- a/src/main/resources/lang/english.lang +++ b/src/main/resources/lang/english.lang @@ -1,4 +1,4 @@ -version: 1.0.0 +version: $version types: packetentity: packet entit¦y¦ies