diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..72169a5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Build & Test + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - 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 9772c92..6261da4 100644 --- a/build.gradle +++ b/build.gradle @@ -24,26 +24,30 @@ repositories { maven { url = 'https://repo.codemc.io/repository/maven-snapshots/' } maven { url = 'https://jitpack.io' } maven { url = 'https://repo.dmulloy2.net/repository/public/' } + maven { url = 'https://repo.seeseemelk.be/repository/maven-public/' } // MockBukkit } dependencies { compileOnly 'org.spigotmc:spigot-api:1.18.2-R0.1-SNAPSHOT' implementation 'com.github.retrooper:packetevents-spigot:2.11.1' - + compileOnly 'com.viaversion:viabackwards:5.3.2' compileOnly 'com.viaversion:viaversion:5.4.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.5.4' - + compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testImplementation 'com.github.seeseemelk:MockBukkit-v1.18:3.86.0' } processResources { @@ -58,12 +62,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 +84,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.0-patch-2.jar b/builds/TuffXPlus-1.0.0-patch-2.jar new file mode 100644 index 0000000..6c19e54 Binary files /dev/null and b/builds/TuffXPlus-1.0.0-patch-2.jar differ diff --git a/builds/TuffXPlus-1.0.0-patch.jar b/builds/TuffXPlus-1.0.0-patch.jar new file mode 100644 index 0000000..bacde0d Binary files /dev/null and b/builds/TuffXPlus-1.0.0-patch.jar differ diff --git a/builds/TuffXPlus-1.0.1-beta.jar b/builds/TuffXPlus-1.0.1-beta.jar index 14e6a99..8741814 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 d2a3e35..2e95f32 100644 --- a/src/main/java/tf/tuff/TuffX.java +++ b/src/main/java/tf/tuff/TuffX.java @@ -35,7 +35,12 @@ public class TuffX extends JavaPlugin implements Listener, PluginMessageListener public ViaBlocksPlugin viaBlocksPlugin; public TuffActions tuffActions; public ViaEntitiesPlugin viaEntitiesPlugin; + public String latestAvailableVersion = null; + private ChunkInjector chunkInjector; + private static final String CURRENT_VERSION = "1.0.0-patch"; + private static final String BUILDS_API_URL = "https://api.github.com/repos/Trainboy15/TuffXPlus/contents/builds"; + @Override public void onLoad() { @@ -101,6 +106,8 @@ public void onDisable() { } PacketEvents.getAPI().terminate(); + + getServer().getMessenger().unregisterIncomingPluginChannel(this); } public void reloadTuffX(){ @@ -143,6 +150,25 @@ public boolean TuffXCommand(CommandSender sender, Command command, String label, return true; } + + // Simple version comparator — handles semver-style and suffix strings + private int compareVersions(String a, String b) { + // Strip non-numeric suffixes for comparison (e.g. "1.0.0-patch" → "1.0.0") + String[] partsA = a.replaceAll("-.*", "").split("\\."); + String[] partsB = b.replaceAll("-.*", "").split("\\."); + int len = Math.max(partsA.length, partsB.length); + for (int i = 0; i < len; i++) { + int numA = i < partsA.length ? parseIntSafe(partsA[i]) : 0; + int numB = i < partsB.length ? parseIntSafe(partsB[i]) : 0; + if (numA != numB) return Integer.compare(numA, numB); + } + return 0; + } + + private int parseIntSafe(String s) { + try { return Integer.parseInt(s); } catch (NumberFormatException e) { return 0; } + } + @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { if (command.getName().equalsIgnoreCase("tuffx")) return TuffXCommand(sender, command, label, args); @@ -180,6 +206,15 @@ public void onBlockFade(BlockFadeEvent e) { @EventHandler(priority = EventPriority.MONITOR) public void onPlayerJoin(PlayerJoinEvent e) { y0Plugin.handlePlayerJoin(e); + + if (latestAvailableVersion != null) { + Player p = e.getPlayer(); + if (p.hasPermission("tuffx.admin") || p.isOp()) { + p.sendMessage("§e[TuffX] §fA new version is available: §a" + latestAvailableVersion + + " §f(running §c" + CURRENT_VERSION + "§f)"); + p.sendMessage("§e[TuffX] §fDownload: §bhttps://github.com/Trainboy15/TuffXPlus/tree/main/builds"); + } + } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) diff --git a/src/main/java/tf/tuff/viablocks/CustomBlockListener.java b/src/main/java/tf/tuff/viablocks/CustomBlockListener.java index 2ac645a..f25c15e 100644 --- a/src/main/java/tf/tuff/viablocks/CustomBlockListener.java +++ b/src/main/java/tf/tuff/viablocks/CustomBlockListener.java @@ -35,6 +35,11 @@ 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; @@ -83,6 +88,7 @@ public byte[] getCachedChunkData(String worldName, int x, int z) { } public void setChunkInjector(tf.tuff.netty.ChunkInjector injector) { + if (injector == null) { return; } this.chunkInjector = injector; } @@ -258,21 +264,30 @@ 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++) { + int worldX = chunkX + x; + for (int z = 0; z < 16; z++) { + int worldZ = chunkZ + 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) { @@ -281,12 +296,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); + long packedLocation = packLocation(worldX, y, worldZ); + LongList locs = foundBlocks.get(materialId); if (locs == null) { - locs = new ArrayList<>(); + locs = new LongArrayList(); foundBlocks.put(materialId, locs); } locs.add(packedLocation); @@ -294,9 +309,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 a875f8b..1fc96dd 100644 --- a/src/main/java/tf/tuff/y0/Y0Plugin.java +++ b/src/main/java/tf/tuff/y0/Y0Plugin.java @@ -265,6 +265,7 @@ public boolean isPlayerReady(Player p) { } public void setChunkInjector(tf.tuff.netty.ChunkInjector injector) { + if (injector == null) return; this.chunkInjector = injector; } @@ -689,49 +690,57 @@ public void handleChunkLoad(org.bukkit.event.world.ChunkLoadEvent e) { } private byte[] csp(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 = tlbd.get(); 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)); + // Write sequence bd[idx++] = (byte) (lb >> 8); bd[idx++] = (byte) lb; bd[idx++] = pl; if (lb != 0 || pl != 0) { - h = true; + hasContent = true; } } } } - if (!h) return null; + if (!hasContent) return null; ByteArrayOutputStream b = tlos.get(); b.reset(); + // DataOutputStream wrapper safely writes schema try (DataOutputStream o = new DataOutputStream(b)) { o.writeUTF("chunk_data"); o.writeInt(x); o.writeInt(z); o.writeInt(sy); o.write(bd, 0, idx); - return b.toByteArray(); } + + return b.toByteArray(); } public void handleBlockBreak(BlockBreakEvent e) { @@ -903,4 +912,4 @@ private byte[] clp(ChunkSnapshot s, CSC sc) throws IOException { } } -} \ No newline at end of file +} diff --git a/src/test/TuffXTest.java b/src/test/TuffXTest.java new file mode 100644 index 0000000..73deef4 --- /dev/null +++ b/src/test/TuffXTest.java @@ -0,0 +1,66 @@ +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 latestVersionIsNullOnStartup() { + assertNull(plugin.latestAvailableVersion, + "No update should be detected synchronously at startup"); + } + + @Test + void reloadDoesNotThrow() { + assertDoesNotThrow(() -> plugin.reloadTuffX(), + "reloadTuffX() should not throw"); + } + + @Test + void opReceivesUpdateMessageWhenUpdateAvailable() { + plugin.latestAvailableVersion = "2.0.0"; + + PlayerMock player = server.addPlayer(); + player.setOp(true); + player.assertNoMoreSaid(); + + player.disconnect(); + server.addPlayer(player.getName()); + + player.assertSaid("§e[TuffX] §fA new version is available: §a2.0.0 §f(running §c1.0.0-patch§f)"); + } + + @Test + void nonOpDoesNotReceiveUpdateMessage() { + plugin.latestAvailableVersion = "2.0.0"; + + PlayerMock player = server.addPlayer(); + player.setOp(false); + + player.assertNoMoreSaid(); + } +} \ No newline at end of file