From 051b75b0a5d7138e39f2d3b6a0994854cac6e807 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:25:43 +0200 Subject: [PATCH 1/2] fix(FarmTreeRun): treat null object composition as empty patch in isPatchEmpty (1.2.4) Rs2GameObject.getObjectComposition is nullable and returns null on a client-thread read timeout or a null impostor while the patch object transforms after planting, which threw an NPE from handlePlantingTree. A null composition now reads as an empty patch so the post-plant wait keeps polling and planting is retried instead of the patch being skipped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../farmtreerun/FarmTreeRunPlugin.java | 2 +- .../farmtreerun/FarmTreeRunScript.java | 8 +++- .../FarmTreeRunPatchEmptyTest.java | 40 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/test/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPatchEmptyTest.java diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPlugin.java index 2780851b2f..ca5b2be30c 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPlugin.java @@ -30,7 +30,7 @@ ) @Slf4j public class FarmTreeRunPlugin extends Plugin { - public static final String version = "1.2.3"; + public static final String version = "1.2.4"; @Inject private FarmTreeRunConfig config; @Provides diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunScript.java b/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunScript.java index f349d18695..601482018a 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunScript.java @@ -1207,8 +1207,12 @@ private boolean isCompostEnabled(FarmTreeRunConfig config) { } private boolean isPatchEmpty(Patch patch) { - String name = Rs2GameObject.getObjectComposition(patch.getId()).getName().toLowerCase(); - return name.endsWith("patch"); + return isEmptyPatchComposition(Rs2GameObject.getObjectComposition(patch.getId())); + } + + static boolean isEmptyPatchComposition(ObjectComposition composition) { + if (composition == null || composition.getName() == null) return true; + return composition.getName().toLowerCase().endsWith("patch"); } private Integer getLinkedId(int id) { diff --git a/src/test/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPatchEmptyTest.java b/src/test/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPatchEmptyTest.java new file mode 100644 index 0000000000..71e73d2a36 --- /dev/null +++ b/src/test/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPatchEmptyTest.java @@ -0,0 +1,40 @@ +package net.runelite.client.plugins.microbot.farmtreerun; + +import net.runelite.api.ObjectComposition; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class FarmTreeRunPatchEmptyTest { + + private static ObjectComposition named(String name) { + ObjectComposition composition = mock(ObjectComposition.class); + when(composition.getName()).thenReturn(name); + return composition; + } + + @Test + void nullCompositionIsTreatedAsEmptyInsteadOfThrowing() { + assertTrue(FarmTreeRunScript.isEmptyPatchComposition(null)); + } + + @Test + void nullNameIsTreatedAsEmpty() { + assertTrue(FarmTreeRunScript.isEmptyPatchComposition(named(null))); + } + + @Test + void patchNameIsEmpty() { + assertTrue(FarmTreeRunScript.isEmptyPatchComposition(named("Tree patch"))); + assertTrue(FarmTreeRunScript.isEmptyPatchComposition(named("Fruit Tree Patch"))); + } + + @Test + void grownTreeIsNotEmpty() { + assertFalse(FarmTreeRunScript.isEmptyPatchComposition(named("Magic tree"))); + assertFalse(FarmTreeRunScript.isEmptyPatchComposition(named("Papaya tree"))); + } +} From 7193da8f554a4307040133cce315b10810fbdc60 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:31:12 +0200 Subject: [PATCH 2/2] fix(FarmTreeRun): guard null tree composition in handlePatch and skip composition read when protection is off (1.2.5) handlePatch dereferenced Rs2GameObject.findObjectComposition(...).getActions() without a null check; the composition is null on the same client-thread read failure or null impostor that caused the isPatchEmpty NPE. Return false so the loop retries on the next tick. handlePayment's protection opt-out checks no longer read the patch composition, so a transient null can no longer fall through to paying the gardener when protection is disabled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../farmtreerun/FarmTreeRunPlugin.java | 2 +- .../farmtreerun/FarmTreeRunScript.java | 12 ++- .../FarmTreeRunHandlePatchTest.java | 99 +++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 src/test/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunHandlePatchTest.java diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPlugin.java index ca5b2be30c..276d08f405 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunPlugin.java @@ -30,7 +30,7 @@ ) @Slf4j public class FarmTreeRunPlugin extends Plugin { - public static final String version = "1.2.4"; + public static final String version = "1.2.5"; @Inject private FarmTreeRunConfig config; @Provides diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunScript.java b/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunScript.java index 601482018a..119f417a7e 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunScript.java @@ -868,7 +868,11 @@ private boolean handlePatch(FarmTreeRunConfig config, Patch patch) { // Gagex named actions differently, sometimes it's Pick-fruit and sometimes Pick-banana. // Also seen "Chop down" and "Chop-down". - List exactTreeActions = Arrays.stream(Rs2GameObject.findObjectComposition(treePatch.getId()).getActions()).collect(Collectors.toList()); + ObjectComposition treeComposition = Rs2GameObject.findObjectComposition(treePatch.getId()); + if (treeComposition == null || treeComposition.getActions() == null) { + return false; + } + List exactTreeActions = Arrays.stream(treeComposition.getActions()).collect(Collectors.toList()); for (String action : exactTreeActions) { if (action == null) continue; @@ -954,13 +958,13 @@ private void handleNotingFruit(Patch patch) { * @return {@code true} if payment was successful, else {@code false} */ private boolean handlePayment(FarmTreeRunConfig config, Patch patch, PaymentKind action) { - if (isTreePatch(patch) && !isPatchEmpty(patch) && !shouldProtectTree(config) && action != PaymentKind.CLEAR) + if (isTreePatch(patch) && !shouldProtectTree(config) && action != PaymentKind.CLEAR) return true; - if (isFruitTreePatch(patch) && !isPatchEmpty(patch) && !shouldProtectFruitTree(config) && action != PaymentKind.CLEAR) + if (isFruitTreePatch(patch) && !shouldProtectFruitTree(config) && action != PaymentKind.CLEAR) return true; - if (isHardTreePatch(patch) && !isPatchEmpty(patch) && !shouldProtectHardTree(config) && action != PaymentKind.CLEAR) + if (isHardTreePatch(patch) && !shouldProtectHardTree(config) && action != PaymentKind.CLEAR) return true; int paymentId = isHardTreePatch(patch) ? config.selectedHardTree().getPaymentId() diff --git a/src/test/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunHandlePatchTest.java b/src/test/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunHandlePatchTest.java new file mode 100644 index 0000000000..59f54c92d9 --- /dev/null +++ b/src/test/java/net/runelite/client/plugins/microbot/farmtreerun/FarmTreeRunHandlePatchTest.java @@ -0,0 +1,99 @@ +package net.runelite.client.plugins.microbot.farmtreerun; + +import net.runelite.api.GameObject; +import net.runelite.api.ObjectComposition; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.Script; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.ScheduledFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +class FarmTreeRunHandlePatchTest { + + private static FarmTreeRunScript runningScript(FarmTreeRunConfig config) throws Exception { + FarmTreeRunScript script = new FarmTreeRunScript(mock(FarmTreeRunPlugin.class), config); + ScheduledFuture future = mock(ScheduledFuture.class); + when(future.isDone()).thenReturn(false); + Field field = Script.class.getDeclaredField("mainScheduledFuture"); + field.setAccessible(true); + field.set(script, future); + return script; + } + + @Test + void nullTreeCompositionSkipsPatchInsteadOfThrowing() throws Exception { + FarmTreeRunConfig config = mock(FarmTreeRunConfig.class); + FarmTreeRunScript script = runningScript(config); + GameObject treePatch = mock(GameObject.class); + when(treePatch.getId()).thenReturn(8392); + try (MockedStatic microbot = mockStatic(Microbot.class); + MockedStatic gameObject = mockStatic(Rs2GameObject.class)) { + microbot.when(Microbot::isLoggedIn).thenReturn(true); + gameObject.when(() -> Rs2GameObject.findObjectByImposter(anyInt(), anyString(), eq(false))).thenReturn(treePatch); + gameObject.when(() -> Rs2GameObject.findObjectComposition(anyInt())).thenReturn(null); + + Method handlePatch = FarmTreeRunScript.class.getDeclaredMethod("handlePatch", FarmTreeRunConfig.class, FarmTreeRunScript.Patch.class); + handlePatch.setAccessible(true); + Object result = handlePatch.invoke(script, config, FarmTreeRunScript.Patch.GNOME_STRONGHOLD_TREE_PATCH); + + assertEquals(Boolean.FALSE, result); + } + } + + @Test + void nullTreeActionsSkipsPatchInsteadOfThrowing() throws Exception { + FarmTreeRunConfig config = mock(FarmTreeRunConfig.class); + FarmTreeRunScript script = runningScript(config); + GameObject treePatch = mock(GameObject.class); + when(treePatch.getId()).thenReturn(8392); + ObjectComposition composition = mock(ObjectComposition.class); + when(composition.getActions()).thenReturn(null); + try (MockedStatic microbot = mockStatic(Microbot.class); + MockedStatic gameObject = mockStatic(Rs2GameObject.class)) { + microbot.when(Microbot::isLoggedIn).thenReturn(true); + gameObject.when(() -> Rs2GameObject.findObjectByImposter(anyInt(), anyString(), eq(false))).thenReturn(treePatch); + gameObject.when(() -> Rs2GameObject.findObjectComposition(anyInt())).thenReturn(composition); + + Method handlePatch = FarmTreeRunScript.class.getDeclaredMethod("handlePatch", FarmTreeRunConfig.class, FarmTreeRunScript.Patch.class); + handlePatch.setAccessible(true); + Object result = handlePatch.invoke(script, config, FarmTreeRunScript.Patch.GNOME_STRONGHOLD_TREE_PATCH); + + assertEquals(Boolean.FALSE, result); + } + } + + @Test + void disabledProtectionSkipsPaymentWithoutReadingComposition() throws Exception { + FarmTreeRunConfig config = mock(FarmTreeRunConfig.class); + when(config.protectTrees()).thenReturn(false); + FarmTreeRunScript script = runningScript(config); + try (MockedStatic gameObject = mockStatic(Rs2GameObject.class); + MockedStatic npc = mockStatic(Rs2Npc.class)) { + gameObject.when(() -> Rs2GameObject.getObjectComposition(anyInt())).thenReturn(null); + + Class paymentKind = Class.forName(FarmTreeRunScript.class.getName() + "$PaymentKind"); + Object protect = java.util.Arrays.stream(paymentKind.getEnumConstants()).filter(k -> k.toString().equals("PROTECT")).findFirst().orElseThrow(); + Method handlePayment = FarmTreeRunScript.class.getDeclaredMethod("handlePayment", FarmTreeRunConfig.class, FarmTreeRunScript.Patch.class, paymentKind); + handlePayment.setAccessible(true); + Object result = handlePayment.invoke(script, config, FarmTreeRunScript.Patch.GNOME_STRONGHOLD_TREE_PATCH, protect); + + assertEquals(Boolean.TRUE, result); + gameObject.verify(() -> Rs2GameObject.getObjectComposition(anyInt()), never()); + npc.verify(() -> Rs2Npc.getNearestNpcWithAction(anyString()), never()); + } + } +}