Skip to main content

Market Alcher

MarketAlcherPlugin ports the N3BuilderF2P market-driven high alcher into the current com.n3plugins.* RuneLite suite. The manifest registers it in runelite-plugin.properties, uses config group n3marketalcher, and ships disabled by default.

For the DreamBot standalone comparison and parity backlog, see docs/market-alcher-dreambot-comparison.md.

Architecture overview

  • MarketAlcherPlugin owns lifecycle and the Swing UI (sidebar panel with Quick Settings + detached target browser window containing a comprehensive Settings tab). The custom UI is the primary configuration surface; the plugin still provides MarketAlcherConfig through RuneLite's ConfigManager for backing config storage and injection.
  • MarketAlcherController is the pure state machine; takes a MarketAlcherRuntime so logic is testable without a live client.
  • RuneLiteMarketAlcherRuntime is the live adapter to RuneLite: sdk.query helpers, InteractionApi.actions.*, WikiMarketClient.
  • WikiMarketClient + WikiMarketParser fetch and parse OSRS Wiki mapping and 5m endpoints via OkHttp with connection pooling. Responses are Caffeine-cached per endpoint (1 hour for mapping, 5 minutes for prices), so redundant or force-triggered fetches within the TTL are free.
  • BuyLimitTracker, FailedOfferCooldowns are concurrent in-memory state the controller and the snapshot path read concurrently.
  • MarketAlcherSnapshot is the immutable view passed to the UI on each refresh; carries mappings/prices counts, current target, last fetch error, and per-row diagnostics.

Core behavior

  • Fetches OSRS Wiki mapping and 5m endpoints on a single-threaded background executor. Data is fetched and cached independently of the automation toggle so the UI populates as soon as a fetch completes.
  • Registers with Break Handler on startup and is active there only while automation is enabled and the controller is in an active automation state, not idle or stopped. A due or active break sets the controller's break pause, releases the input lock, and still lets the controller tick observe GE/alch progress without issuing new actions.
  • HTTP failures, parser errors, and any other unchecked exception in the fetch task are caught, logged at WARN via slf4j (visible in RuneLite's log output regardless of debugLogging), and surfaced in the window status bar as Error: <ExceptionClass>: <message>.
  • The window status bar distinguishes three states: Loading market data... (no fetch has succeeded and no error), Error: … (last attempt failed), and the normal Mappings: N | Prices: M | Nature rune: X gp | Target: Y line.
  • Refreshes successful market data every 5 minutes. Failed fetches retry on exponential backoff (10s → 20s → … capped at 5 minutes) so a wiki outage cannot trigger sustained per-10-second hammering.
  • The mapping endpoint alone is sufficient for the table to populate; empty 5m responses no longer trigger fast-retry loops.
  • Ranks alch targets with AlchemyCandidateSelector. Members-only items are excluded by default and become eligible when members mode is enabled.
  • Tracks base item IDs (used for GE buys and bank lookups) separately from noted item IDs (used for inventory alch detection).
  • Auto-opens the Swing target browser; lists every alchable mapping with live price/profit columns and a Status column explaining why each row is currently usable or not (Profitable, Preferred, No price, Low volume, Low profit, Buy limit exhausted, Cooldown, Members disabled, Blacklisted, Protected).
  • Preferred items bias selection but do not block fallback. If no preferred item is currently profitable, the controller falls back to the highest-scoring non-preferred candidate.
  • Target ranking supports Balanced, Profit, and XP Throughput selection modes. Preferred item IDs still take precedence before the selected ranking mode is applied.
  • Inventory alching is ownership-aware by default: items bought, withdrawn, or actively selected by the plugin may be alched, while unrelated profitable inventory items are ignored unless Opportunistic inventory alching is explicitly enabled.
  • The table model only re-renders when the snapshot's lastSuccessfulRefresh or lastError changes - sort and filter interaction stays responsive on the ~3000-row table. The status bar still updates every game tick.

State machine

The controller's determineState() returns one of:

LOAD_MARKET → STOPPED? →
EQUIP_FIRE_STAFF (any of 11 fire-supplying staves count)
BANK_SUPPLIES (only when bank can provide nature runes or the noted target)
CLOSE_BANK (bank is open with no work to do - must close before alching)
ALCH (have runes + alchable in inventory, bank is closed)
BUY_NATURE_RUNES (no runes in inventory, no pending offer)
BUY_ALCH_TARGET (need alchable, no pending offer)

Supply decisions are centralized through MarketSupplyPlanner: inventory checks use an InventoryPlan snapshot, bank restock calculates rune/target-note needs and free-slot caps before dispatch, and alch-target GE buys use AlchemyPurchasePlan plus PurchaseBudget to reserve pending offer spend, carried nature runes, target costs, nature-rune costs, a 2000 gp floor, and the configured max-concurrent per-offer budget. Bank supply recovery runs as a named TaskPipeline; the alch target still withdraws by explicit noted-bank actions because the generic loadout model cannot safely represent unnoted bank ID → noted inventory ID yet. Target purchase sizing uses the configured fixed batch size by default. When Adaptive batch sizing is enabled, MarketSupplyPlanner.BatchSizing caps buy quantity by recent five-minute volume between the configured adaptive min/max values, then shrinks retry quantities after partial-fill timeouts recorded in FailedOfferCooldowns.

High alchemy runs as a staged interface flow. If the GE is open, the controller closes it before spell work. If High Alch is not visible, it opens the Magic tab. When the spell is visible, it selects High Alch, returns, and lets the client switch to the inventory before clicking the target item on the next tick. If the high alchemy spell becomes manually deselected (e.g. by closing the interface or clicking elsewhere), the controller detects this, resets its internal selection state, and automatically re-selects the spell on the next tick to prevent lockout loops.

Stop conditions (checked in order inside determineState()):

  • Magic level below 55 → STOPPED, status Magic level below 55.
  • Not on Standard spellbook → STOPPED, status Wrong spellbook (Standard required). Lunar, Arceuus, and Ancient spellbooks would cause every alch tick to silently fail; this guard surfaces the problem and avoids an indefinite loop.
  • ≥5 consecutive failed buy attempts → STOPPED with status Stopped: N consecutive buy failures (manual intervention required).

stateStatus() discriminates the spellbook stop from the level stop: if getMagicLevel() >= 55 when STOPPED, the spellbook guard fired; otherwise the level was too low.

Dialog dismissal

onGameTick() checks runtime.isDialogOpen() after recordObservedAlchProgress() and before the state switch. If a dialog is present (level-up, item warning, NPC) the controller calls runtime.dismissDialog() - trying continueSpace() when canContinue(), otherwise a no-op

  • and returns early. This prevents spamming openBank() or castHighAlch() into a locked UI while still allowing alch progress to be observed on the tick the dialog opened.

Pending purchases and buy-limit accounting

  • Each successful GE buy stamps a PendingPurchase carrying the requested quantity, a placedAt timestamp, and baseline counts of the noted item in inventory and the unnoted item in bank at submit time.
  • recordObservedPurchaseIfFilled runs every tick, counts the actual delta from baseline, and records min(actualDelta, requestedQuantity) toward the 4-hour buy limit - partial fills no longer over-count.
  • A pending purchase that does not fill within the configured no-progress timeout (default 90 seconds) expires. Rather than cooling down immediately, the controller applies Adaptive Buy Logic:
    • It calculates an adjusted retry price (increments of 55gp times the failure count, up to a configured profit margin floor).
    • If a profitable retry price is available, it cancels the timed-out offer and records a failure (but skips cooldown via shouldCooldown = false). The item remains selectable for immediate retry at the increased price.
    • If no profitable retry price is available, or if the max retry count (lockout threshold) is reached, it places the item on a standard cooldown.
    • Separate-Tick Invariant: cancelPendingOrders always returns true after processing any cancellation (regardless of whether the pending had a live GE slot), so the cancel and the retry buy are always separate tick actions. This prevents same-tick re-buy race conditions.
  • Same-Tick Collect Delay: When a buy offer fills (partially or fully), the controller postpones issuing any collect actions until the next game tick. This avoids race conditions by ensuring the client has processed the offer update before sending collect packets.
  • BUY_ALCH_TARGET is suppressed (status Waiting for offer fill) while a pending purchase for the current target is in flight, so the controller does not silently re-buy and over-spend.
  • The buy quantity per BUY_ALCH_TARGET comes from MarketSupplyPlanner.purchasePlans: fixed or adaptive batch sizing, candidate remaining limit, live buy-limit recheck, carried nature runes, pending target spend, reserved gp, and per-offer spend caps all participate before the shared GE buy pipeline is submitted. The live controller reads the live tracker on every tick; the candidate's stored remaining-limit only reflects selection time. Bank withdrawal planning, not GE target buying, applies inventory free-slot caps for noted target recovery. If the live remaining hits 0, the item is cooldowned, the target cleared, and the GE interface is closed to prevent it from idling open.
  • Nature rune GE restocks use Nature Rune Restock Quantity rather than a hidden batchSize × 2 rule. Buying nature runes, buying a fire staff, and buying alch targets can each be disabled independently.

Coin balance precheck

For alch targets, MarketSupplyPlanner.purchasePlans performs the first budget pass with the 2000 gp reserve, pending target offers, carried nature runes, and per-offer cap. buyItem still reads runtime.inventoryCount(995) immediately before submitting any GE offer as the final guard. If priceEach × quantity > coins, the buy quantity is reduced to coins / priceEach when that still buys at least one item. If the player cannot afford even one item, the buy is suppressed and the status bar shows Insufficient coins for X (need N).

Fire-staff handling

MarketAlcherController.FIRE_STAFF_IDS recognizes 11 valid fire-supplying weapons:

  • Staff of fire (1387)
  • Fire battlestaff (1393)
  • Mystic fire staff (1401)
  • Lava battlestaff (3053), Mystic lava staff (3054)
  • Steam battlestaff (11787), Mystic steam staff (12795)
  • Smoke battlestaff (11998), Mystic smoke staff (12000)
  • Tome of fire (charged) (20714)
  • Tumeken's shadow (25731)

hasFireStaffEquipped() returns true if any of these is equipped. equipFireStaff searches inventory and bank for any variant before falling back to buying the cheap Staff of fire.

Alch counter

alchs and estimatedProfit are incremented by observation, not by click-result. recordObservedAlchProgress runs at the start of each tick and counts the decrease in inventoryCount(notedItemId) since the previous tick. castHighAlch only initiates the cast - it no longer increments counters speculatively, so the displayed value tracks actual spell completions.

castHighAlch captures the InteractionResult from highAlchInventoryItem() and logs debug("High alch failed: {} - {}", result.getStatus(), result.getMessage()) when it fails. Enable debugLogging to surface cast failures (e.g. WIDGET_NOT_FOUND if the alch spell widget is missing) without waiting for the alch counter to stall.

Runtime API implementation notes

  • getMagicLevel() delegates to SkillsActions.getLevel(Skill.MAGIC) and avoids direct client.getRealSkillLevel() calls. SkillsActions wraps the call with GameStateGuard.localPlayerReady(), returning 0 safely during login transitions instead of reading stale client state.
  • isOnStandardSpellbook() delegates to MagicActions.getSpellBook() == SpellBook.STANDARD.
  • isDialogOpen() / dismissDialog() delegate to DialogActions.isOpen(), DialogActions.canContinue(), and DialogActions.continueSpace().
  • inventoryFreeSlots() delegates to InventoryActions.freeSlots().

Concurrency model

  • MarketAlcherController.config, currentTarget, and status are volatile - written from the game thread, read from the executor thread via onDataLoaded → refreshUi → snapshot().
  • RuneLiteMarketAlcherRuntime.refreshTask is volatile, and refreshMarketData() synchronizes on a dedicated lock object so concurrent EDT (Refresh Prices button) + game-thread (LOAD_MARKET state) fetch attempts cannot both submit a task.
  • BuyLimitTracker and FailedOfferCooldowns use ConcurrentHashMap. BuyLimitTracker.LimitEntry is immutable; updates happen via map.put(key, new LimitEntry(...)) so getRemainingLimit reads from the executor thread cannot observe torn state.
  • RejectedExecutionException from executor.submit (e.g. during shutdown) is caught and recorded as a fetch error.

Config

Configuration options are managed primarily through custom UI components. The plugin also exposes a standard MarketAlcherConfig provider so RuneLite can back the values through ConfigManager; runtime operation should still prefer the sidebar Quick Settings and the target browser Settings tab.

UI Locations

  1. Sidebar Plugin Panel (Quick Settings):

    • Contains high-frequency settings for quick runtime control:
      • Enable automation: Checkbox to activate/deactivate the automation state machine.
      • Minimum profit: Spinner to adjust the profit threshold (GP) per alch.
      • Batch size: Spinner to set the target batch size.
    • Implementation Note: These components are persistent. To avoid losing keyboard focus or cursor selections, the panel text and component states are modified directly on game ticks instead of tearing down and rebuilding the panel layout.
  2. Target Browser Window (Settings Tab):

    • A dedicated scrollable tab inside the main detached MarketAlcherWindow containing all configuration properties, organized into styled sub-panels:
      • General & Automation: Enable automation, Include members items, Show overlay, Debug logging, Buy nature runes, Buy fire staff, Buy alch targets, Opportunistic inventory alching.
      • Price & Volume Filters: Selection Mode, Minimum Profit, Minimum 5m Volume, Batch Size, Adaptive Batch Sizing, Adaptive Min Batch Size, Adaptive Max Batch Size, Retry Batch Shrink (%).
      • Grand Exchange: GE Cooldown Ticks (wait between sub-actions, 1-5), Max Concurrent Buy Orders (1-3 distinct targets), Nature Rune Restock Quantity, GE no-progress Timeout (ms), GE submit Timeout (ms).
      • Retries & Lockouts: Max Retries before Lockout, Max Retry Offer Increase (gp), Min Retry Profit Floor (gp), Short Cooldown duration (ms), Long Cooldown duration (ms).
      • Lists & Exclusions: Blacklist (comma-separated item names to exclude from search), Protected Items (comma-separated item names in inventory to never alch), Preferred Item IDs.
    • Save Settings Button: Validates input and persists configuration changes back to the RuneLite ConfigManager.

Bidirectional Sync and Propagation

  • Changing high-frequency settings in the sidebar panel immediately updates the corresponding input fields in the target browser window (guarded by focus checks to avoid interrupting active typing).
  • Saving changes in the Target Browser Settings tab immediately propagates the new values to the sidebar panel controls and triggers a green success confirmation status.
  • The MarketAlcherController updates its active runtime config on every game tick and is notified instantly upon ConfigChanged events.

Target browser

  • Auto-opens with the Market Alcher module. Reopen from the Market Alcher sidebar entry.
  • Targets Tab:
    • Displays a tabular list of every alchable mapping with live price/profit/volume/status columns.
    • Preserves user filters, sorting, and row selections across market updates using a throttled table model.
    • Refresh Prices: Queues manual market fetch and UI reload.
    • Save Preferences: Persists checked/preferred rows.
    • Select Visible / Clear Visible / Focus Current Target allow fast list manipulations.
  • Stats Tab:
    • Contains account info, session/lifetime alch count, estimated profit trackers, per-item breakdowns, and a live decision log.
  • Settings Tab:
    • Hosts the complete configuration panel (described under the Config section above).
  • Settings are dynamic: changing parameters does not require restarting the module.

Validation

.\gradlew.bat test --tests com.n3plugins.marketalcher.* --console plain
.\gradlew.bat test --tests com.n3plugins.InteractionApi.actions.* --console plain

The marketalcher test suite covers: pure market model; supply/purchase planning; descriptor registration; controller state-machine routing including staff recovery, noted-target handling, market refresh, config updates, buy-limit observation, bank-mode failure handling; pending-purchase re-entry prevention; pending-purchase timeout + failure recording; partial-fill accounting; insufficient-coin status surfacing; CLOSE_BANK routing; fire-staff variant detection; consecutive-buy-failure stop; GE tick gate; snapshot propagation of lastFetchError; delta-based alch counter; wrong-spellbook stop; dialog dismissal early-return; noted withdrawal free-slot cap; budget-reserved and adaptive target purchase planning; failed alch result handling; adaptive buy retry pricing with separate-tick cancel/re-buy invariant; same-tick fill collect delay; spell manual deselection recovery; GE closure on non-buying and limit-exhausted states.

Runtime caveat

Live RuneLite verification is still required for: Grand Exchange offer fill detection in real GE state; high-alchemy widget-on-widget timing; bank/GE close action resolution on the user's specific client revision; behaviour of the alch counter when a cast is interrupted by movement or an interface change.

Ironman and other GE-restricted accounts are not detected explicitly; the controller may reach STOPPED only after repeated buy failures. Use manual automationEnabled off on accounts that cannot trade on the GE.

Diagnostic recipe

If the table is blank or the bot is not progressing:

  1. Open the Market Alcher window - read the status bar.
    • Loading market data... → wait; first fetch in progress.
    • Error: <type>: <msg> → the surfaced exception class names the transport failure (IOException, SocketTimeoutException, UnknownHostException, SSLHandshakeException, NoSuchMethodError, etc.).
    • Insufficient coins for X (need N) → not enough gold for even one item.
    • Waiting for offer fill → pending GE offer; will time out after the configured no-progress timeout and cooldown the item if it doesn't fill.
    • Buy limit exhausted → 4-hour limit hit; will cool down and rotate.
    • Wrong spellbook (Standard required) → switch to Standard spellbook; plugin cannot alch on Lunar, Arceuus, or Ancient.
    • Stopped: N consecutive buy failures → manual intervention; check GE state, account state, coin balance.
  2. Check RuneLite logs for MarketAlcher fetch failed: or MarketAlcher fetch IO error for … WARN entries. These appear regardless of debugLogging.
  3. If a NoSuchMethodError mentions org.json, the runtime is loading an older version of the library than the test classpath compiled against. Use the Iterator<String> keys = obj.keys() API; do not rely on obj.keySet().

Market Alcher Controller State Machine

The following diagram visualizes the priority-based state machine that governs the Market Alcher controller: