From 24d6841d03d4075d64d545316689a9ab870ff6a1 Mon Sep 17 00:00:00 2001 From: rxm Date: Fri, 19 Jun 2026 22:11:37 +0200 Subject: [PATCH] fix(watch): keep no-farmable grace alive across transient active-drop blips When watching a target whose drops are not watchable (claimable/blocked/ upcoming), useStallRecovery cleared the no-farmable grace marker on every pass through the no-progress recovery branch -- i.e. whenever activeDropInfo was momentarily present. An oscillating active-drop signal therefore kept restarting the 30s grace, so decideWatchingNoFarmable's give-up path (cooldown + retarget + stop + stall-stop) never fired and the engine wedged in "watching, but target currently not watchable" indefinitely, with a frozen no-progress tracker. Preserve the grace marker across a transient activeDropInfo while the target stays unwatchable; clear it only once canWatchTarget is true again. Extracted as the pure reconcileNoFarmableOnActiveDrop() and unit-tested, including the flicker regression. Co-Authored-By: Claude Opus 4.8 --- .../shared/hooks/watch/useStallRecovery.ts | 10 ++++- .../hooks/watch/watchStallRecovery.test.ts | 44 +++++++++++++++++++ .../shared/hooks/watch/watchStallRecovery.ts | 18 ++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/renderer/shared/hooks/watch/useStallRecovery.ts b/src/renderer/shared/hooks/watch/useStallRecovery.ts index 7c4e3e5..6be4a6e 100644 --- a/src/renderer/shared/hooks/watch/useStallRecovery.ts +++ b/src/renderer/shared/hooks/watch/useStallRecovery.ts @@ -11,6 +11,7 @@ import { decideIdleNoFarmable, decideNoProgressRecovery, decideWatchingNoFarmable, + reconcileNoFarmableOnActiveDrop, type NoFarmableMarker, type StallRecoveryAction, type WatchConfirmationProbe, @@ -227,7 +228,14 @@ export function useStallRecovery({ runActions(decision.actions); return; } - noFarmableDropRef.current = null; + // Keep the no-farmable grace alive across a transient activeDropInfo blip + // while the target is still unwatchable; only clear it once the target is + // genuinely watchable again. Prevents an oscillating active-drop signal from + // resetting the grace forever (engine wedged in "watching, not watchable"). + noFarmableDropRef.current = reconcileNoFarmableOnActiveDrop( + noFarmableDropRef.current, + canWatchTarget, + ); if (!activeDropInfo) { watchStallTrackerRef.current = null; watchConfirmationProbeRef.current = null; diff --git a/src/renderer/shared/hooks/watch/watchStallRecovery.test.ts b/src/renderer/shared/hooks/watch/watchStallRecovery.test.ts index 893e20e..5b8097f 100644 --- a/src/renderer/shared/hooks/watch/watchStallRecovery.test.ts +++ b/src/renderer/shared/hooks/watch/watchStallRecovery.test.ts @@ -9,6 +9,7 @@ import { NO_FARMABLE_DROP_GRACE_MS, NO_FARMABLE_GAME_COOLDOWN_MS, pickStallRecoveryChannel, + reconcileNoFarmableOnActiveDrop, shouldProbeNoProgressConfirmation, STALL_CONFIRMATION_PROBE_COOLDOWN_MS, STALL_MAX_CHANNEL_RECOVERY_ATTEMPTS, @@ -595,6 +596,49 @@ describe("decideWatchingNoFarmable", () => { }); }); +describe("reconcileNoFarmableOnActiveDrop (no-farmable wedge)", () => { + it("preserves the grace marker when an active drop blips in while the target stays unwatchable", () => { + const marker = { key: "Rust", sinceAt: 1 }; + // canWatchTarget=false: a transient activeDropInfo must NOT reset the grace. + expect(reconcileNoFarmableOnActiveDrop(marker, false)).toBe(marker); + }); + + it("clears the marker once the target is genuinely watchable again", () => { + expect(reconcileNoFarmableOnActiveDrop({ key: "Rust", sinceAt: 1 }, true)).toBeNull(); + }); + + it("stays null when there is no marker to preserve", () => { + expect(reconcileNoFarmableOnActiveDrop(null, false)).toBeNull(); + }); + + it("still escalates after the grace period despite a transient active-drop blip", () => { + const start = 100_000; + // tick 1 — no active drop, target unwatchable: seed the grace marker. + const seeded = decideWatchingNoFarmable({ + ...watchingBase, + now: start, + noFarmable: null, + }).noFarmable; + expect(seeded).toEqual({ key: "Rust", sinceAt: start }); + + // tick 2 — an active drop blips in while still unwatchable; the executor + // reconciles the marker here. With the fix it must be preserved, otherwise + // the 30s grace never completes and the engine wedges in + // "watching, but target not watchable". + const afterBlip = reconcileNoFarmableOnActiveDrop(seeded, false); + expect(afterBlip).toEqual({ key: "Rust", sinceAt: start }); + + // tick 3 — active drop gone, grace window elapsed: give-up must fire. + const result = decideWatchingNoFarmable({ + ...watchingBase, + now: start + NO_FARMABLE_DROP_GRACE_MS + 1, + noFarmable: afterBlip, + }); + expect(result.actions.map((a) => a.kind)).toContain("stop-watching"); + expect(result.resetStallTracking).toBe(true); + }); +}); + const activeDrop = (over: Partial> = {}) => ({ id: "d1", diff --git a/src/renderer/shared/hooks/watch/watchStallRecovery.ts b/src/renderer/shared/hooks/watch/watchStallRecovery.ts index 519cf4e..efbd95b 100644 --- a/src/renderer/shared/hooks/watch/watchStallRecovery.ts +++ b/src/renderer/shared/hooks/watch/watchStallRecovery.ts @@ -36,6 +36,24 @@ export type StallRecoveryAction = /** "Watching with no farmable drop" grace-period marker (was noFarmableDropRef). */ export type NoFarmableMarker = { key: string; sinceAt: number }; +/** + * Recovery-branch upkeep for the no-farmable grace marker. + * + * `useStallRecovery` enters the no-progress recovery branch whenever an + * `activeDropInfo` is present. Clearing the no-farmable grace there is only safe + * when the target is genuinely watchable again (`canWatchTarget`). While the + * target stays unwatchable, a *transient* `activeDropInfo` blip (e.g. an + * upcoming/unlinked drop surfacing for a tick) must NOT reset the grace — + * otherwise an oscillating active-drop signal keeps restarting the + * `NO_FARMABLE_DROP_GRACE_MS` window and `decideWatchingNoFarmable`'s give-up + * path never fires, wedging the engine in "watching, but target currently not + * watchable". + */ +export const reconcileNoFarmableOnActiveDrop = ( + marker: NoFarmableMarker | null, + canWatchTarget: boolean, +): NoFarmableMarker | null => (canWatchTarget ? null : marker); + export type IdleNoFarmableInput = { allowWatching: boolean; autoSelectEnabled: boolean;