diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a85b424 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Build & Test + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +permissions: + contents: read + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test --no-daemon + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: build/reports/tests/test/ diff --git a/build.gradle b/build.gradle index 021e43c..22db1c1 100644 --- a/build.gradle +++ b/build.gradle @@ -30,20 +30,29 @@ dependencies { compileOnly 'org.spigotmc:spigot-api:1.18.2-R0.1-SNAPSHOT' implementation 'com.github.retrooper:packetevents-spigot:2.12.1' - + compileOnly 'com.viaversion:viabackwards:5.3.2' compileOnly 'com.viaversion:viaversion:5.9.1' - + compileOnly 'it.unimi.dsi:fastutil:8.5.16' - + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' - compileOnly 'io.netty:netty-all:4.1.97.Final' + compileOnly 'io.netty:netty-all:4.1.97.Final' implementation 'org.java-websocket:Java-WebSocket:1.6.0' - + compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' + + // Tests + testImplementation 'com.viaversion:viaversion:5.9.1' + testImplementation 'com.viaversion:viabackwards:5.3.2' + testImplementation 'io.netty:netty-all:4.1.97.Final' + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.11.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' + testImplementation 'com.github.seeseemelk:MockBukkit-v1.18:2.85.2' } processResources { @@ -58,12 +67,12 @@ processResources { shadowJar { archiveClassifier.set('') archiveFileName.set("${project.name}-${project.version}.jar") - + relocate 'com.github.retrooper.packetevents', 'tf.tuff.packetevents' relocate 'io.github.retrooper.packetevents', 'tf.tuff.packetevents' relocate 'com.fasterxml.jackson', 'tf.tuff.jackson' relocate 'org.java_websocket', 'tf.tuff.websocket' - + exclude 'META-INF/*.SF' exclude 'META-INF/*.DSA' exclude 'META-INF/*.RSA' @@ -80,3 +89,11 @@ tasks.named('jar') { tasks.named('build') { dependsOn shadowJar } + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat = "full" + } +} \ No newline at end of file diff --git a/builds/TuffXPlus-1.0.1-beta.jar b/builds/TuffXPlus-1.0.1-beta.jar index 0ba027f..03c29f9 100644 Binary files a/builds/TuffXPlus-1.0.1-beta.jar and b/builds/TuffXPlus-1.0.1-beta.jar differ diff --git a/src/main/java/tf/tuff/TuffX.java b/src/main/java/tf/tuff/TuffX.java index 576e3a1..8f59f2e 100644 --- a/src/main/java/tf/tuff/TuffX.java +++ b/src/main/java/tf/tuff/TuffX.java @@ -1,5 +1,7 @@ package tf.tuff; +import java.io.File; + import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -13,13 +15,15 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.plugin.PluginDescriptionFile; import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.plugin.java.JavaPluginLoader; import org.bukkit.plugin.messaging.PluginMessageListener; import com.github.retrooper.packetevents.PacketEvents; import com.github.retrooper.packetevents.event.PacketListenerPriority; - import io.github.retrooper.packetevents.factory.spigot.SpigotPacketEventsBuilder; + import tf.tuff.netty.ChunkInjector; import tf.tuff.tuffactions.TuffActions; import tf.tuff.viablocks.ViaBlocksPlugin; @@ -36,6 +40,11 @@ public class TuffX extends JavaPlugin implements Listener, PluginMessageListener public ViaEntitiesPlugin viaEntitiesPlugin; private ChunkInjector chunkInjector; + // required by MockBukkit + public TuffX(JavaPluginLoader loader, PluginDescriptionFile description, File dataFolder, File file) { + super(loader, description, dataFolder, file); + } + @Override public void onLoad() { this.y0Plugin = new Y0Plugin(this); @@ -101,6 +110,9 @@ public void onDisable() { } PacketEvents.getAPI().terminate(); + + getServer().getMessenger().unregisterIncomingPluginChannel(this); + getServer().getMessenger().unregisterOutgoingPluginChannel(this); } public void reloadTuffX(){ diff --git a/src/main/java/tf/tuff/viablocks/CustomBlockListener.java b/src/main/java/tf/tuff/viablocks/CustomBlockListener.java index 54221eb..edfec8e 100644 --- a/src/main/java/tf/tuff/viablocks/CustomBlockListener.java +++ b/src/main/java/tf/tuff/viablocks/CustomBlockListener.java @@ -34,6 +34,11 @@ import tf.tuff.netty.ChunkInjector; import tf.tuff.viablocks.version.VersionAdapter; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.longs.LongList; + public class CustomBlockListener { public final ViaBlocksPlugin plugin; @@ -81,6 +86,7 @@ public byte[] getCachedChunkData(String worldName, int x, int z) { } public void setChunkInjector(ChunkInjector injector) { + if (injector == null) return; this.chunkInjector = injector; } @@ -256,21 +262,28 @@ public void cacheChunkWithCallback(World world, int x, int z, Consumer c } private Map> findModernBlocksInChunk(ChunkSnapshot chunkSnapshot, int minHeight, int maxHeight) { - Map> foundBlocks = new HashMap<>(); + Int2ObjectMap foundBlocks = new Int2ObjectOpenHashMap<>(); + int chunkX = chunkSnapshot.getX() << 4; int chunkZ = chunkSnapshot.getZ() << 4; - - for (int y = minHeight; y < maxHeight; y++) { - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = minHeight; y < maxHeight; y++) { + + // Check material FIRST — getBlockType() returns an enum, no allocation Material blockType = chunkSnapshot.getBlockType(x, y, z); - if (blockType == Material.AIR || !this.modernMaterials.contains(blockType)) { + + if (blockType == Material.AIR + || blockType == Material.CAVE_AIR + || blockType == Material.VOID_AIR + || !this.modernMaterials.contains(blockType)) { continue; } - - @SuppressWarnings("null") - @Nonnull BlockData data = chunkSnapshot.getBlockData(x, y, z); - + + // Only allocate BlockData for confirmed modern blocks + BlockData data = chunkSnapshot.getBlockData(x, y, z); + Integer cachedId = blockDataIdCache.getIfPresent(data); int materialId; if (cachedId != null) { @@ -279,12 +292,12 @@ private Map> findModernBlocksInChunk(ChunkSnapshot chunkSnap materialId = this.paletteManager.getOrCreateId(data.getAsString()); blockDataIdCache.put(data, materialId); } - + if (materialId != -1) { long packedLocation = packLocation(chunkX + x, y, chunkZ + z); - List locs = foundBlocks.get(materialId); + LongList locs = foundBlocks.get(materialId); if (locs == null) { - locs = new ArrayList<>(); + locs = new LongArrayList(); foundBlocks.put(materialId, locs); } locs.add(packedLocation); @@ -292,9 +305,10 @@ private Map> findModernBlocksInChunk(ChunkSnapshot chunkSnap } } } - return foundBlocks; - } - + + return (Map>) (Map) foundBlocks; + } + public byte[] getExtraDataForMultiBlock(World world, List locations) { Map> foundBlocks = new HashMap<>(); diff --git a/src/main/java/tf/tuff/y0/Y0Plugin.java b/src/main/java/tf/tuff/y0/Y0Plugin.java index 9f83bba..5d25a7f 100644 --- a/src/main/java/tf/tuff/y0/Y0Plugin.java +++ b/src/main/java/tf/tuff/y0/Y0Plugin.java @@ -8,6 +8,7 @@ import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; @@ -251,6 +252,7 @@ public boolean isPlayerReady(Player player) { } public void setChunkInjector(ChunkInjector injector) { + if (injector == null) return; this.chunkInjector = injector; } @@ -673,49 +675,60 @@ public void handleChunkLoad(ChunkLoadEvent event) { } private byte[] createSectionPayload(ChunkSnapshot s, int x, int z, int sy, Object2ObjectOpenHashMap c) throws IOException { + // Ensure thread-local buffer is exactly 12,288 bytes to prevent overflow byte[] bd = threadData.get(); + Arrays.fill(bd, (byte) 0); int idx = 0; - boolean h = false; + boolean hasContent = false; int by = sy << 4; - for (int y = 0; y < 16; y++) { - int wy = by + y; + // Optimized Loop Order: Matches standard Minecraft internal memory layouts + for (int xx = 0; xx < 16; xx++) { for (int zz = 0; zz < 16; zz++) { - for (int xx = 0; xx < 16; xx++) { + for (int y = 0; y < 16; y++) { + int wy = by + y; + BlockData bdata = s.getBlockData(xx, wy, zz); - int[] ld = c.getOrDefault(bdata, EMPTY_LEGACY); - if (ld == EMPTY_LEGACY && v != null) { - ld = v.toLegacy(bdata); + int[] ld = c.get(bdata); // Fast map lookup + + if (ld == null) { // Avoid getOrDefault overhead + ld = (v != null) ? v.toLegacy(bdata) : EMPTY_LEGACY; c.put(bdata, ld); } + // Bitwise packing short lb = (short) ((ld[1] << 12) | (ld[0] & 0xFFF)); byte pl = (byte) ((s.getBlockSkyLight(xx, wy, zz) << 4) | s.getBlockEmittedLight(xx, wy, zz)); - bd[idx++] = (byte) (lb >> 8); - bd[idx++] = (byte) lb; - bd[idx++] = pl; + // Write sequence + int linear = ((y << 8) | (zz << 4) | xx) * 3; + bd[linear] = (byte) (lb >> 8); + bd[linear + 1] = (byte) lb; + bd[linear + 2] = pl; + if (linear + 3 > idx) idx = linear + 3; if (lb != 0 || pl != 0) { - h = true; + hasContent = true; } } } } - if (!h) return null; + if (!hasContent) return null; ByteArrayOutputStream bout = threadOut.get(); bout.reset(); + // DataOutputStream wrapper safely writes schema try (DataOutputStream out = new DataOutputStream(bout)) { out.writeUTF("chunk_data"); out.writeInt(x); out.writeInt(z); out.writeInt(sy); out.write(bd, 0, idx); - return bout.toByteArray(); } + + return bout.toByteArray(); } public void handleBlockBreak(BlockBreakEvent event) { @@ -887,4 +900,4 @@ private byte[] createLightPayload(ChunkSnapshot s, Coords sc) throws IOException } } -} \ No newline at end of file +} diff --git a/src/test/java/tf/tuff/TuffXTest.java b/src/test/java/tf/tuff/TuffXTest.java new file mode 100644 index 0000000..521b889 --- /dev/null +++ b/src/test/java/tf/tuff/TuffXTest.java @@ -0,0 +1,37 @@ +package tf.tuff; + +import be.seeseemelk.mockbukkit.MockBukkit; +import be.seeseemelk.mockbukkit.ServerMock; +import be.seeseemelk.mockbukkit.entity.PlayerMock; +import org.junit.jupiter.api.*; + +import static org.junit.jupiter.api.Assertions.*; + +class TuffXTest { + + private static ServerMock server; + private static TuffX plugin; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + plugin = MockBukkit.load(TuffX.class); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void pluginEnablesSuccessfully() { + assertTrue(plugin.isEnabled(), "Plugin should be enabled after load"); + } + + + @Test + void reloadDoesNotThrow() { + assertDoesNotThrow(() -> plugin.reloadTuffX(), + "reloadTuffX() should not throw"); + } +} \ No newline at end of file