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
MarketAlcherPluginowns 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 providesMarketAlcherConfigthrough RuneLite'sConfigManagerfor backing config storage and injection.MarketAlcherControlleris the pure state machine; takes aMarketAlcherRuntimeso logic is testable without a live client.RuneLiteMarketAlcherRuntimeis the live adapter to RuneLite:sdk.queryhelpers,InteractionApi.actions.*,WikiMarketClient.WikiMarketClient+WikiMarketParserfetch and parse OSRS Wikimappingand5mendpoints 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,FailedOfferCooldownsare concurrent in-memory state the controller and the snapshot path read concurrently.MarketAlcherSnapshotis 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
mappingand5mendpoints 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
WARNvia slf4j (visible in RuneLite's log output regardless ofdebugLogging), and surfaced in the window status bar asError: <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 normalMappings: N | Prices: M | Nature rune: X gp | Target: Yline. - 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
mappingendpoint alone is sufficient for the table to populate; empty5mresponses 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
Statuscolumn 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
lastSuccessfulRefreshorlastErrorchanges - 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, statusMagic level below 55. - Not on Standard spellbook →
STOPPED, statusWrong 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 →
STOPPEDwith statusStopped: 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()orcastHighAlch()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
PendingPurchasecarrying the requested quantity, aplacedAttimestamp, and baseline counts of the noted item in inventory and the unnoted item in bank at submit time. recordObservedPurchaseIfFilledruns every tick, counts the actual delta from baseline, and recordsmin(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:
cancelPendingOrdersalways returnstrueafter 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_TARGETis suppressed (statusWaiting 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_TARGETcomes fromMarketSupplyPlanner.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 × 2rule. 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 toSkillsActions.getLevel(Skill.MAGIC)and avoids directclient.getRealSkillLevel()calls.SkillsActionswraps the call withGameStateGuard.localPlayerReady(), returning 0 safely during login transitions instead of reading stale client state.isOnStandardSpellbook()delegates toMagicActions.getSpellBook() == SpellBook.STANDARD.isDialogOpen()/dismissDialog()delegate toDialogActions.isOpen(),DialogActions.canContinue(), andDialogActions.continueSpace().inventoryFreeSlots()delegates toInventoryActions.freeSlots().
Concurrency model
MarketAlcherController.config,currentTarget, andstatusarevolatile- written from the game thread, read from the executor thread viaonDataLoaded → refreshUi → snapshot().RuneLiteMarketAlcherRuntime.refreshTaskisvolatile, andrefreshMarketData()synchronizes on a dedicated lock object so concurrent EDT (Refresh Prices button) + game-thread (LOAD_MARKET state) fetch attempts cannot both submit a task.BuyLimitTrackerandFailedOfferCooldownsuseConcurrentHashMap.BuyLimitTracker.LimitEntryis immutable; updates happen viamap.put(key, new LimitEntry(...))sogetRemainingLimitreads from the executor thread cannot observe torn state.RejectedExecutionExceptionfromexecutor.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
-
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.
- Contains high-frequency settings for quick runtime control:
-
Target Browser Window (Settings Tab):
- A dedicated scrollable tab inside the main detached
MarketAlcherWindowcontaining 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.
- A dedicated scrollable tab inside the main detached
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
MarketAlcherControllerupdates its active runtime config on every game tick and is notified instantly uponConfigChangedevents.
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 Targetallow 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:
- 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.
- Check RuneLite logs for
MarketAlcher fetch failed:orMarketAlcher fetch IO error for …WARN entries. These appear regardless ofdebugLogging. - If a
NoSuchMethodErrormentionsorg.json, the runtime is loading an older version of the library than the test classpath compiled against. Use theIterator<String> keys = obj.keys()API; do not rely onobj.keySet().
Market Alcher Controller State Machine
The following diagram visualizes the priority-based state machine that governs the Market Alcher controller: