159 Commits
Author SHA1 Message Date
max bittkerandClaude Opus 5.5 54f3e62084 feat(webclient): publish nearby players' combat targets
NearbyPlayer gains combatTargetType/combatTargetIndex/inCombat, populated only
while the player's combatCycle corroborates the faced entity. BotState carries
combatTargetTelemetryVersion: 1 so consumers can tell supporting clients apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:30:48 -04:00
max bittkerandClaude Opus 5.5 df3d8888ae feat(sdk,mcp): cancellable execute_code, bot.mineRock, bounded walkTo
execute_code
- Cancellation now reaches inside the SDK via an AsyncLocalStorage scope:
  timeout, MCP cancel, or a newer call rejects pending waits and blocks
  further action packets, including those inside walkTo().
- One execution per bot: a new call supersedes the running one and starts
  once it has unwound; disconnect_bot does the same.
- Cancelled user code parks on bot/sdk calls instead of throwing, so a
  catch-all loop can't spin and starve the server.
- Console capture is routed per execution, so concurrent bots no longer
  clobber each other's output; leftover logs go to stderr.
- Stale world state warns and still runs, so a stale client can be diagnosed.
- Credentials are redacted from logs, results and launch messages.

SDK
- bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that
  success can mean the action only started.
- walkTo() takes options.timeout (default 120s), refuses stale state, and
  reports the real last position.
- runScript uses the same cancellation scope for timeouts and signals.

Docs trimmed of duplicated or stale execute_code text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:19 -04:00
max bittkerandClaude Opus 5.5 ce65d8e745 fix(mcp): keep world state on execute_code errors; trim the per-call dump
execute_code dropped the console output and world state whenever the code
threw or timed out, returning only the error. That's when the agent most
needs to see it died, a dialog is open, or it got "I can't reach that" -
in runebench runs a bot was killed mid-timeout and the result just said
"timed out". Errors now return console output, the error with up to six
stack frames, and the world state, flagged isError.

The world state appended to every call (~560 tokens) now uses a brief mode
that folds untrained skills into one line and drops NPC indexes and
NPC/object action lists, cutting about 25% per call measured over 44.5k
runebench blocks. The CLI keeps the full output.

The code parameter's example no longer suggests `return sdk.getState()`,
which duplicated the appended dump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:49:54 -04:00
max bittker 8b7be3de4b fix(sdk): reject text dialogue clicks after the page changes 2026-10-11 01:10:51 -04:00
max bittker 286035ba61 fix(runner): release cleanup timers without terminating later runs 2026-10-11 00:49:30 -04:00
max bittkerandClaude Opus 5.5 ee40a15619 sdk: regenerate collision data from the 289 prod server
The previous export came from a local server after test bots had opened
and closed doors; this one is a fresh prod world.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:11:59 -04:00
max bittkerandClaude Opus 5.5 3b2be2bc2d sdk: make skipTutorial survive a fresh login's settling window
On a fresh login the SDK can attach before the character design modal
arrives (a throttled background tab widens this), and the first talk to
the guide can come back "I can't reach that!". skipTutorial checked for
the modal once and failed after a single talk attempt with a timeout.

When the guide is nearby, wait briefly for the design modal and accept
it, retry the talk up to three times, and name the last game message if
it still fails. Off Tutorial Island it returns immediately as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:41:36 -04:00
max bittkerandClaude Opus 5.5 882699169a Move custom GE interface and pvp_death_mark to reserved id ranges
Upstream pack ids jump at every revision bump, which is where ours were
appended, so each upgrade renumbered them. Move the two kinds that can
cheaply sit above any upstream revision (377-wip tops out at interface
18785, varp 724):

- GE interface components 11942-12437 -> 40000-40495. The interface
  format lists ids explicitly, so the gap costs nothing.
- pvp_death_mark varp 394 -> 1000. Saves store varps by id; a fixed id
  ends save migrations. The pre-289 358 migration targets it by name.

The GE booth loc and teller npc stay tail-appended: locs allocate an
engine object per id, and npc types are 11 bits in 289's NPC_INFO.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:20:12 -04:00
max bittkerandClaude Opus 5.5 2725c3b25b sdk: follow 289 GE interface id, regenerate collision data
The GE root component moved to 11942 in the 289 content packs.
collision-data.json is re-exported from a 289 members build: 136.7k zones
(was 123.6k), 1.27M tiles, 2658 doors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:53:05 -04:00
max bittkerandClaude Opus 5.5 1ea0edff1f fix(runner): fail loudly when no game client is attached; document starting one
Following the README, `bun bots/<name>/script.ts` with no browser tab or lite
runner attached printed "Connected", ran every action against nothing, and
exited 0. runScript never launches a client (autoLaunchBrowser: false), and
the README said the SDK would.

There are two ways that happened:
- No client ever attached: the gateway has no state, connect() times out its
  ready wait and then "continues anyway".
- A client attached earlier but has gone: the gateway replays the state it
  cached, so the ready check passes on a snapshot minutes old.

runScript now waits up to 10s for `tick` to advance after connecting. It
compares ticks, not getStateAge(), because that mixes gateway and local
clocks; a live run measured -343ms. If no tick arrives it throws an error
that names both ways to start a client, and a failed connect now sets
exitCode 1.

The README Getting Started and CLAUDE.md now include the lite runner step,
the README no longer claims scripts launch a client, and the CLAUDE.md
troubleshooting entry no longer points at an option runScript doesn't expose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:16:33 -04:00
max bittkerandClaude Opus 5.5 ca34325e7a feat(gateway,sdk): compressed direct gateway listener; private fleet gateways
The gateway can also listen on GATEWAY_PUBLIC_PORT (on Fly:
wss://rs-sdk-demo.fly.dev:7443 -> 7781) with permessage-deflate. On live
bot state frames that is ~16% of the uncompressed bytes at ~6 ms CPU per MB,
spent in the gateway process rather than on the game server's tick thread.
The main port is unchanged and uncompressed, so the engine's /gateway relay
and every existing SDK and runner behave exactly as before.

SDK connect() and the lite runner check the direct port (3 s) and use it when
it answers, otherwise they keep wss://.../gateway; a failed connect checks
again. GATEWAY_DIRECT=false opts out. HTTP endpoints keep the relayed origin.

GATEWAY_HOSTNAME lets a fleet run a private loopback gateway (sdk/README.md).
sdk/chat.ts and the MCP server now honor a bot.env GATEWAY_URL, as cli.ts and
runScript already did. The Dockerfile sed that forced 0.0.0.0 is replaced by
that option's default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:46:40 -04:00
max bittkerandClaude Opus 5.5 9dc413c490 fix(mcp): cap execute_code console capture so runaway loops can't OOM the sandbox
execute_code collected console output in an unbounded array. A script whose
catch block logs and loops without awaiting (e.g. retrying a method that
throws synchronously) never yields, so the execution timeout can't fire and
the array grows until the process runs out of memory. Two RuneBench trials
(Sonnet 5.5, hitpoints) most likely lost their sandbox this way looping on the
removed bot.attackNpc; a surviving run logged the same error 16,210 times.

ConsoleCapture keeps the first 300 and last 300 lines, clips lines at 2000
chars, and reports how many lines were omitted, so memory stays constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:42:07 -04:00
max bittkerandClaude Fable 5.1 4c2069cc0c fix(sdk,content): tradeoff-free fixes from the 2026-09-21 bug-report inbox
- walkTo: honor the caller's tolerance on the final waypoint (was hardcoded
  2, so tolerance 0/1 could never be met) and judge distance in tiles
  (Chebyshev) so a diagonal neighbour counts as adjacent
- interactLoc: a same-named fallback loc must offer the requested op,
  otherwise dispatch the caller's own loc instead of a different id
- talkTo: refuse reachable:false targets instead of queueing a talk the
  server retries forever
- eatFood: target optional; no target eats the first item with Eat, not slot 0
- depositItem(pattern, -1): keep going past a per-id failure, report aggregate
- closeBank: also close when bank.isOpen outlives interface.isOpen by a frame
- openShop: return success when a shop is already open
- pickupItem: baseline the inventory count before dispatch (static map
  spawns never leave the ground mirror, so the count is the only signal)
- pickpocketNpc: match Steal-from; craftJewelry: dragonstone slot + reject
  unknown gems; craftLeather: hard/dragon leathers, named product wins
- create-bot --local: write localhost:8888 + GATEWAY_URL; cli hint names
  the lite runner; getInventory docs say it is compacted
- cheat_help: every debugproc listed with its required ::~ prefix

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 14:04:08 -04:00
max bittkerandClaude Code 002303875c feat(market): add sell orders view and default to 7-day window
- New "Sell orders" tab / sort=sellRecent: items with open asks, newest
  listing first, showing ask, sell qty, sell value and last listed time.
- MarketOverview and item quotes now include sellValue and lastSellAt.
- SDK MARKET_SORTS (and so the get_market MCP tool) accept sellRecent.
- Market list and API default to days=7 instead of 24h.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 15:05:09 -04:00
max bittker 69d5b709c8 feat: ship Grand Exchange with durable Fly market storage 2026-09-07 23:28:51 -04:00
max bittkerandClaude Fable 5.1 db1f30e1c1 fix(sdk,engine,webclient): tradeoff-free fixes from the 2026-09-01 bug-report harvest
Engine
- World.processLogins takeover: closeModal() before save(), so items parked in
  temp invs (tradeoffer/dueloffer/duelwinnings/partyroom_tempinv) return to the
  pack before the snapshot. Logging in from a second client mid-trade deleted
  the offer and left the partner's screen stuck; reproduced locally before and
  after.
- Web server proxies /state/:bot, /chat/:bot, POST /say/:bot, POST /dm/:bot to
  the gateway (cli.ts / chat.ts HTTP fast paths 404'd on deployments that
  front the gateway through the engine web port).

SDK
- interactNpc/useItemOnNpc/talk path re-find an entity target by server index
  after walking (ActionHelpers.refindNpc) instead of by name, so a chosen
  fishing spot is not swapped for the nearest same-named one.
- runner: log a failed initial connect and tear down the orphan BotSDK (its
  reconnect timers kept the process alive as a zombie); connect race 30s -> 90s
  to cover a cold browser launch; browserLaunchTimeout default 30000 as documented.
- mcp/api: bot.env repo-root fallback was one directory short.
- cli.ts honours GATEWAY_URL from bot.env like the runner does.
- shortestNameMatch falls back to a substring match when a literal name with
  regex metacharacters ("Serum 207 (1)") matches nothing as a regex (+test).
- eatFood accepts Drink; openBank skips "Closed bank booth" and prefers a booth
  with a Bank/Use op; attack re-finds a bare {index} handle instead of throwing;
  depositItem(number) returns an invalid-target result instead of a TypeError.
- ActionDispatchError sets its name via defineProperty (hosts that freeze
  Error.prototype made the assignment throw from ws.onclose).

Webclient
- Client.say() routes '::' to CLIENT_CHEAT like the keyboard path and the lite
  client, instead of speaking cheats aloud as public chat.
- Client.isBankOpen() returns false when no main modal is open (component 2006
  linkObjType is never cleared, so state.bank.isOpen stuck true all session).
- acceptCharacterDesign (lite + browser) prefers the CC_ACCEPT_DESIGN button
  inside the open modal; other kits carry the same clientcode with no trigger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:25:33 -04:00
max bittkerandClaude Fable 5 9cd3d7019a fix(sdk,mcp,gateway): market-bench audit fixes - trade binding, serve window, walkTo guards
From the market-split-n24 audit (24 bots, 64 sdk-kind problems):

- tradeWith verifies the partner: declines a session open with a different
  player (a pending request racing yours used to be silently adopted -
  bots completed trades with the wrong counterparty) and re-pursues the
  requested one until the deadline; trade() gains retryOnBusy
- serveTrades maxTrades counts only completed trades (a lowballer's
  declined session used to consume a maxTrades:1 window); new rejectAfterMs
  declines offers that stay unacceptable; accept() predicate evaluated on
  offer change + every ~2s instead of every tick (console-spam fix)
- trade completion waits for the inventory delta to land before reporting
  received items as missing; TradeResult items carry an `amount` alias
- walkTo rejects non-numeric coordinates (walkTo(undefined, undefined)
  used to march bots into scorpions/wolves) and aborts when the player
  dies en route; NearbyPlayer gains worldX/worldZ aliases
- interactLoc/interactNpc reject null targets (used to hit a random loc);
  find* patterns get a clear error for non-string/RegExp input
- formatter no longer flags the open trade window (3323) as a blocking
  modal to close - that advice cost a bot a 100gp advance
- Spells component ids (HIGH_ALCHEMY etc.) exported from sdk/spells.ts
  and injected as an execute_code global
- MCP execute_code transpiles TS (the tool advertised TS but rejected
  type syntax), formats Errors in console capture (was '{}'), and warns
  about ~60s MCP client caps with code continuing to run
- gateway serializes HTTP says per bot: concurrent PMs clobbered
  currentActionId, so delivered messages reported false "Say timed out"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:47:32 -04:00
max bittkerandClaude Fable 5 042ad988c0 feat(sdk,webclient,gateway): private message (DM) chat between players
The engine already supported MESSAGE_PRIVATE end-to-end; this adds the
send path and tooling:

- sendPrivateMessage on browser Client + LiteClient (userhash + WordPack
  payload, local type-6 echo), new 'privateMessage' bot action
- sdk.sendPrivateMessage(name, msg) + sdk.dm(name, text) (chunked like say)
- gateway: observe mode whitelists privateMessage alongside say; new
  POST /dm/:username endpoint - a separate path from /say on purpose, so
  old gateways' non-JSON banner makes the CLI fall back to the WS path
  (cleanly rejected) instead of leaking a DM as public chat
- chat CLI: bun sdk/chat.ts <bot> --to <player> "msg"
- engine: FriendServer PM handler logs private_chat best-effort instead of
  letting a missing account row (local easy-startup) kill the relay

PMs deliver to any online player - no friends list needed. One social
packet (say or PM) per tick; dm() paces its chunks. Delivery requires
FRIEND_SERVER=true (prod fly.toml already sets it; local quickstart
defaults off and drops PMs silently).

Verified E2E on the local stack: both directions, 3-chunk long message,
observe-mode WS path, non-chat actions still rejected in observe mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:37:12 -04:00
max bittkerandClaude e960b3a8d2 fix(sdk,webclient): classify every trade close as completed vs declined
- acceptTrade/trade/serveTrades: a closed screen is now settled from the
  'Accepted trade.' message or the inventory delta, including closes that
  race our own timeout decline; report possiblyDropped overflow.
- trade(): separate requestTimeout (30s) from the open-session timeout.
- declineTrade retries once across the offer->confirm blip.
- closeBotModal always sends CLOSE_MODAL (local modal ids can lag the server).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 15:33:56 -04:00
max bittkerandClaude ff60e8dee6 feat(gateway,cli): GET /state/:bot; sdk/cli.ts state dumps over HTTP first
The chat CLI moved to gateway HTTP endpoints (98b28b1ca) but the state
CLI still opened a fresh observer WebSocket per invocation - the last
per-call TLS handshake in the split benchmark topology, and the source
of the remaining 'Failed to connect to wss://' errors there.

- gateway: GET /state/:bot answers from the last held frame with
  stateAge + botStatus (404 JSON when nothing has registered).
- cli.ts: try /state first with a bounded fetch (+1 retry), fall back
  to the observer socket only for gateways that predate the endpoint.
  Output unchanged.
- tests: endpoint + CLI end-to-end (no observer session left behind).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 10:52:08 -04:00
max bittkerandClaude Fable 5 d80066c78a fix(sdk,clients): bug-report harvest 2026-08-16 - dialog choice regression, false failures, Windows paths
78 new reports since 8/12 triaged; the tradeoff-free fixes:

- dismissBlockingUI no longer clicks multi-option choice dialogs. The 8/12
  fix (9afd6d961) made it click options[0].index, which ANSWERS a p_choice
  as option 1 before every bot action - the wrong quest branch ran and the
  caller's intended click was silently refused as stale. Root-caused via
  live repro; the published index/componentId mapping was never wrong.
  Blocks-quests class (Restless Ghost, Rune Mysteries, Al Kharid gate).
- interactLoc/useItemOnLoc/useItemOnNpc/equipItem accept real evidence:
  same-level teleports (dungeon trapdoors, z +/-6400), inventory deltas
  (cow milking, flour bin), late equipment arrival. No more false failures
  on actions that worked.
- 'interactLoc froze the event loop' root cause was lazy synchronous
  initPathfinding() (~30s on slow hosts) killing the socket mid-action:
  runner + MCP server now warm it before connecting. Runner also exits
  non-zero on signals (128+n), bounds disconnect, force-exits a wedged
  event loop, and honors a second signal immediately.
- ground items on blocked tiles: browser client now mirrors the authentic
  OP_OBJ handler (retry route with 1x1 footprint, dispatch regardless);
  lite ground-item op dispatches unconditionally too. Unblocks Monk's
  Friend / Plague City / Witch's House pickups.
- interactLoc/useItemOnLoc re-find an entity target by (id,x,z) identity,
  not name - same-named neighbours (Varrock cart crates) can't win.
- lite runner/swarm repoRoot via fileURLToPath - Windows and
  space-containing clones can run headless again (4 reports).
- smithAtAnvil: longest-key product match ('mithril longsword' no longer
  smiths a sword), metal-prefix bar selection, honest smithed-product
  report; castSpell resolves known spell names, rejects unknown ones, and
  no longer reports a never-dispatched cast as a splash; buyFromShop fails
  fast with out_of_stock/no_inventory_space; depositItem -1 with an
  alternation pattern deposits every matching id; openShop falls back to
  the nearest Trade-option NPC when called with no target; cli.ts honors
  blank SERVER= as local instead of diagnosing against the demo gateway.
- NearbyNpc publishes tileX/tileZ (authoritative route tile) and size;
  x/z are interpolated render coords, off by 1-3 tiles on movers and
  floor(size/2) on multi-tile NPCs. Also lands the isSkulled/
  autoRetaliateEnabled collector for the already-published type decls.
- Melzar's Maze funexit doors added to ONE_WAY_DOORS (verified in
  collision data) so walkTo/attack stop stranding bots.

Checked: imp red-bead drop table is fair (5/128, reachable branch) - the
report was 1-in-18 variance, no fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:51:08 -04:00
max bittkerandClaude Fable 5 1e344ace90 types: declare isSkulled/autoRetaliateEnabled on BotWorldState
The pre-commit API-docs hook already baked these optional fields into
API.md from the working tree; commit the declarations so main's api-docs
test stays green. (Client-side population is separate WIP.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:30:17 -04:00
max bittkerandClaude Fable 5 98b28b1ca3 feat(chat): HTTP chat endpoints on gateway; chat CLI bounded budgets, no more silent hangs
The chat CLI opened a fresh observer WebSocket per invocation. Over a TLS
tunnel (split-topology benchmark runs) that handshake is flaky, and a send
against a dead game-client relay burned the SDK's 60s actionTimeout per
80-char chunk - multi-chunk sends stalled 120s+ with zero output.

- gateway: GET /chat/:bot serves recent chat from a per-bot accumulated
  ChatHistory (fed from state snapshots); POST /say/:bot relays through the
  bot's game client with a hard per-chunk deadline (default 10s) and aborts
  remaining chunks on first failure. Same authenticateSDK gate as sdk_connect.
- sdk/chat.ts: HTTP-first with per-attempt timeouts + one retry on network
  failure, a 90s force-exit backstop on every non-watch path, and clear
  non-zero exits. Falls back to the observer WebSocket (now with bounded
  connectTimeout/actionTimeout) for gateways without the endpoints; --watch
  keeps the long-lived observer connection.
- chunkMessage moved to sdk/chunking.ts so the gateway can share it without
  importing the full SDK; re-exported from sdk/index.
- tests: sdk/test/gateway-http-chat.test.ts runs the real gateway subprocess
  with a scripted bot client, covering read/send/chunking/409/timeout paths
  and the CLI end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:29:53 -04:00
max bittkerandClaude Fable 5 2ae032c998 docs(wiki,sdk): shop-page coordinates, decoy-furnace learning, disconnectAfter doc
From the GLM 5.3 zero-score run analysis (rs-bench2 2026-08-14):

- generate-shops.ts now emits a Coordinates row from owner NPC map
  spawns, so agents can walk to a shop instead of sweeping the area
  name (Dommik wild-goose chase). 105/117 pages gain coordinates.
- learnings/smithing.md sample used the bare nearest-name furnace
  match that selects the inert decoy loc; now uses {withOption}.
- runner.ts disconnectAfter docstring claimed default false; the
  actual default is true for runner-opened connections, which is
  what tears down a shared MCP client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:04:16 -04:00
max bittkerandClaude Fable 5 9afd6d9613 fix(sdk): dismissBlockingUI clicks published dialog index; lite recovers from redeploy CRC staleness
Two fixes from the 2026-08-12 bug-report harvest:

- dismissBlockingUI hardcoded sendClickDialog(0), but continue-dialogs
  publish their option at server index 1, so any level-up or info dialog
  silently wedged every subsequent BotActions call. Now clicks the first
  published option's index (bare 0 only for optionless dialogs).

- loadCache memoized /crc checksums for the process lifetime, so a shard
  that outlived a server content redeploy presented stale CRCs on every
  reconnect and got login response 6 forever. startSession now invalidates
  the cache and retries once when the re-fetched checksums differ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:47:04 -04:00
max bittkerandClaude Fable 5 fa3f4bd8e5 fix(sdk): bound the whole connect handshake, settle on early close
connect()'s only timer was cleared on socket open, so a gateway that
accepted the socket but never answered sdk_connect (dead session,
half-open TCP during an outage) parked connect() forever while every
liveness signal read healthy - observed as multi-minute babysitter
stalls that survived server recovery (bug report mscll1sc). A socket
that closed mid-handshake leaked the pending promise the same way.

One connectTimeout deadline (default 30s, above the gateway's 15s auth
bound) now covers socket open plus the sdk_connected/sdk_error answer,
and onclose rejects a still-pending handshake. Both guards are scoped
to pre-handshake, so post-connect close/reconnect semantics are
unchanged. A mock-gateway test pins all four handshake outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 13:01:22 -04:00
max bittkerandClaude Fable 5 0fccd0cc77 feat(sdk): withdraw-as-note support in bot.withdrawItem
withdrawItem gains {asNote: true}: the bank_main note/item toggle
(varp 115 %bankcert, components 5386/5387) is synced to the requested
mode before the withdrawal - and synced back on the next plain call, so
a sticky server-side toggle can't silently change what arrives.
Completion is detected by item name in note mode, because the noted
arrival is the cert obj: a different id whose published name ObjType
copies from the linked item. Fleet hauling previously had to click raw
components and gave up on result verification (bug report msnqt2yt).
BankState now publishes noteMode for observability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:52:52 -04:00
max bittkerandClaude Fable 5 47bf4ee5d1 fix(client): route non-inventory useInventoryItem to INV_BUTTON
Both clients sent OPHELD1-5 no matter which interfaceId was passed, but
the engine only wires OPHELD to the main inventory (3214) - item options
on any other component (tradeside:inv, bank_side:inv, ...) are
inv_button triggers. The packet passed the wire and the handler silently
dropped it, which broke trade offers for weeks with zero error surface
(bug report msplxq69). Non-3214 components now dispatch INV_BUTTON1-5
via clickInterfaceIop, interfaceId is plumbed through the gateway action
and sdk.sendUseItem, and an encoding test pins both packet families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:49:26 -04:00
max bittkerandClaude Fable 5 035c64a9ad feat(sdk): publish plane on NearbyLoc, refuse stale cross-plane loc refs
Scans are already scoped to the player's plane, but a NearbyLoc had no
level field, so a reference carried across a climb/descend clicked
through to whatever occupies those coordinates on the wrong floor
(Black Knights' Fortress ladder report). Publish the observation plane
on every scanned loc, and make resolveLocation re-resolve a passed-in
loc whose plane no longer matches the player - failing loudly with
loc_not_found instead of interacting with the wrong floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:43:05 -04:00
max bittkerandClaude Fable 5 931ebe0514 feat(sdk): publish npc type id on NearbyNpc
Filters written as npc.id === configId were comparing undefined === n and
failing silently - NearbyNpc only carried the world slot (index) and name,
unlike every other published entity. Surface NpcType.id (npc.pack config
id) on both the collector and the SDK type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:35:26 -04:00
max bittkerandClaude Fable 5 dfac800e21 feat(lite): in-process fast-KBD attack/prayer hooks in the gateway runner
Policy-gated fast path for King Black Dragon fights: the lite runner emits
the KBD attack op (and protect-prayer on/off flips) in-process on the same
publication that reveals the spawn/removal, instead of round-tripping
through the gateway controller. Enabled only when bots/logs/
fleet-runtime.mode contains the fast-kbd mode string; state publications
carry fastKbdAttackTick/fastKbdPrayerOn/fastKbdPrayerOff so the controller
can observe what was already dispatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:58:45 -04:00
max bittkerandClaude Fable 5 d447f76077 feat(sdk): option-aware loc resolution fixes the duplicate-furnace trap
Overlapping duplicate locs are real content: the Lumbridge furnace pairs an
inert, option-less 'Furnace' loc with the one offering Smelt, and plain
name matching returns whichever is nearer - so interactLoc(/furnace/i,
'smelt') dead-ended with 'No matching option on Furnace' while a usable
furnace stood one tile away (bug reports 2026-07-27 and 2026-08-08; the
latter's fork-only bot.smelt helper broke on exactly this).

findNearbyLoc now takes {withOption} to consider only locs publishing a
matching interaction option, and interactLoc resolves its target through
that filter whenever the option is named (falling back to plain matching so
the no-matching-option error still names what it found).

Verified live at the Lumbridge furnace: both duplicate locs confirmed
( (3225,3256) with no options, (3226,3255) with Smelt ), withOption picks
the smeltable one, and interactLoc(/furnace/i, 'smelt') opens the smelting
interface.

The same reports' bot.mine default-target complaint targets a helper that
only exists in a fork, not in this repo - nothing to fix here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:29:04 -04:00
max bittkerandClaude Fable 5 b3c19c5abf fix(sdk): walkTo fails fast on dead-end partial paths instead of looping
findPath computes reachedDestination and walkTo ignored it: a partial path
(route stops short, e.g. indoors where the local mask routes through a door
into a room the destination is not reachable from) was walked to its dead
end, re-queried from there with the identical endpoint, and walked again -
the reported Varrock-house-to-bank case measured 6/23 indoor walk successes
with every failure ending on the same tile (bug report 2026-08-10).

A partial path is still walked once - doors open and zones allocate along
the way - but when the re-query dead-ends at the same tile and no door can
be opened, walkTo now returns failure naming the shortfall and where the
path stops.

Iteration progress is now measured as closing distance-to-destination
(monotone best) instead of distance moved, so an oscillating route can no
longer reset the stuck counter forever by pacing back and forth.

Covered by sdk/test/walkto-partial-path.test.ts; normal outdoor walks
verified live against the local engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:25:53 -04:00
max bittkerandClaude Fable 5 da424c7ab8 fix(sdk): pickupItem no longer reports success when someone else wins the item
The wait predicate treated the ground publication disappearing as
completion, then returned success (and handed back any inventory item of
that id the bot already carried) even when the calling account gained
nothing - exactly the trap chopTree already guards against. Contested
fleet loot had to diff exact item counts around every call to know who won
(bug report 2026-08-10).

Success is now gaining the item, tracked by exact id count around the
pickup; ground disappearance only stops the wait. A short grace covers the
inventory add landing a tick after the ground removal. Losing the race
returns a new 'taken_by_other' reason.

Verified live on the local engine: drop + re-pickup still reports success
with the gained item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:15:45 -04:00
max bittkerandClaude Fable 5 c4c22b395c fix(sdk): allow bank withdraw/deposit amounts above 10k
validateActionQuantity's 10k cap exists to bound actions that expand into
repeated packets, but bank withdraw/deposit serialize the whole amount into
a single Withdraw-X/Deposit-X count-dialog packet. Applying the cap there
made withdrawItem(coins, 20000) return invalid_amount without sending
anything - an ordinary stackable withdrawal silently no-opped (bug report
msolats3, 2026-08-11).

Bank ops now validate against MAX_BANK_ACTION_QUANTITY (2^31-2; 2^31-1 is
the client's Withdraw-All encoding, so it stays unreachable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:09:32 -04:00
max bittkerandClaude Fable 5 2fa65b8342 feat(sdk): player-to-player trade API
Three layers over the game's request/offer/accept/confirm trade flow:

Layer 0 - state and primitives. StateCollector publishes a `trade` slice
(screen, partner, both offers, both accept flags) read from the
trademain/tradeconfirm interfaces; accept flags come from the server-set
status text, the only place the game reports them. Sends reuse existing
packets: request is interactPlayer op 4 (requesting someone who already
requested you IS the accept), offers are INV_BUTTON with automatic
Offer-X count-dialog handling, accept is IF_BUTTON, decline is
close-modal. waitForTradeRequest surfaces chat type 4, which the default
chat readers filter out.

Layer 1 - bot.trade(target, {give, want, accept}). Opens the session,
offers, holds accept until the partner's offer satisfies the policy, and
re-verifies on the confirm screen before the irreversible second accept.
Offer changes reset both accepts server-side, so the confirm screen's
offers are final - re-checking there makes offer-switching unable to slip
past. Reports the observed inventory delta rather than assuming.

Layer 2 - bot.serveTrades(policy). The receiving half of a muling setup,
as an explicit loop that owns the bot rather than a background hook.

Two engine behaviours the porcelain has to work around, both found by
running it against two live bots:

- Accepts are gated on tick advancement. Every executed action publishes
  a snapshot with an unchanged tick, so a retry loop waiting on "any
  state change" spins at full speed, exhausts the engine's per-tick
  packet budget, and backlogs that client's own update stream by minutes
  while the trade advances server-side.

- "Trade closed" needs two ticks of confirmation. openMainModal writes an
  IfClose for the side modal before the IfOpenMain of the confirm screen,
  so a single closed-looking snapshot mid-trade is the offer->confirm
  transition, not a decline.

Verified end to end against a local server with two lite clients: gift,
mutual exchange with both sides enforcing `want`, and an unmet-want
decline. Adds 15 unit tests covering the pure helpers and the porcelain
flows including the confirm-screen re-verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 23:06:19 -04:00
max bittkerandClaude Fable 5 515f29ded5 feat(chat): observe-mode say, sdk/chat.ts CLI, 400-char messages
- Gateway: observe-mode sessions may send 'say' (and nothing else), so
  chat tools can talk through a bot without pre-empting its controller
- sdk/chat.ts: send / read / --watch chat over an observe connection
- maxMessageLength default 80 -> 400; engine WordPack caps follow the
  config instead of hardcoding 80
- 240-byte packed budget in both WordPack copies + byte-aware SDK
  chunking: all chat frames carry a 1-byte length and digit/punctuation
  chars pack at ~1 byte each, so long non-prose text would wrap the
  length byte and corrupt the stream
- chat handlers use Packet.alloc(1): the 100-byte alloc(0) tier threw
  RangeError on >~200-char messages and kicked the player with no log
- say() paces multi-chunk sends with wall-clock sleep in observe mode
  (a 'wait' action is rejected there and would occupy the bot executor)
- lite runner: GATEWAY_URL override (SERVER doubles as game origin and
  gateway address, which broke for localhost:8888)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 19:05:32 -04:00
max bittkerandClaude Fable 5 c651b47810 fix(bot): expire stuck active actions; phantom-door walkTo; no browser fallback
- ActionQueue.expireActive() releases an active entry whose deadline
  elapsed - a lost completion previously occupied `current` forever and
  blocked every later SDK action. Wired into both the lite runner and
  the browser BotOverlay tick, reporting queue_expired to the controller.
- walkTo treats a door open result of not_found as a phantom door (in
  static collision but absent from the live map) and keeps walking
  instead of endlessly re-planning.
- Standalone scripts no longer auto-launch a browser when a headless
  session is unavailable (autoLaunchBrowser: false in the runner).
- Document lite-client paper cuts observed during fleet runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:17:49 -04:00
max bittkerandClaude Opus 5 28acd81cb0 feat(sdk): surface server op rejections; reachability-filtered find*
Two silent failure modes cost bots a 10s timeout each and were
indistinguishable from "the action ran and did nothing".

Op rejections. When the server refuses an op (stunned, mid-action, target
out of view, modal open underneath) every Op*Handler answers with
UNSET_MAP_FLAG and nothing else - no message, no error. Client.ts and the
lite client now both count those packets, StateCollector turns unsets
that arrive while the player is standing still into
state.opFeedback.opRejectedCount, and ActionHelpers exposes the
snapshot/compare pair. interactNpc and pickpocketNpc use it to bail with
reason 'rejected' instead of waiting out the timeout. opFeedback is
optional throughout - an older connected client just goes quiet, and the
helpers report "no rejection".

Pickpocket acking. "You attempt to pick..." is printed by ~pick_pocket
before its first p_delay, so it is the earliest evidence the op actually
ran - an attempt earlier than xp or a stun. Without it, a lost op looked
identical to a slow one. Attempts that neither ack nor reject within 3
idle ticks (measured from the last step, since the client walks before
writing OPNPC) now return reason 'not_started'. Also fixes "you fail to
pick" being read as a timeout rather than a stun, and checks xp before
messages so a successful pick is never misreported.

find* reachability. All four find* lookups take {reachable: true}, which
returns null rather than a target the client cannot route to - the case
where the client silently writes no packet at all. Default behaviour is
unchanged: an unreachable match still wins if it is the only one.

PATCHES.md gains entries for the lite client, the ClientStream.isClosed
getter, the LoopCycle split, and the mapFlagUnsetCount counter, which is
now duplicated in both clients and would go quiet without a loud failure
if a sync reverted it. bun run test covers src/lite too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:55:45 -04:00
max bittkerandClaude Fable 5 3f27a1b9e1 feat(state): publish reachable flag on nearby entities; fix result false-negatives
Silent cant_reach (ms2cczk7/ms2coo7g) killed at the source: StateCollector
now stamps `reachable` on nearbyNpcs/nearbyPlayers/nearbyLocs/groundItems
via a ReachProbe flood fill over the same collision map the dispatch paths
route over. find* helpers prefer reachable matches, the CLI formatter tags
[UNREACHABLE], and NPC dispatch routes with the NPC's real footprint so a
2x2 target is approachable from any side (probe and dispatch must agree).

Result false-negatives (ms7lmyi6, ms3cw2xr):
- interactLoc counts a player.level change (staircase/ladder) as success
- smithAtAnvil propagates the real sendUseItemOnLoc reason (cant_reach)
  instead of conflating every failure into no_anvil
- talkTo/interactNpc/interactLoc give late server responses a short grace
  window instead of failing one tick before the dialog arrives

Also lands the shared halves the lite client builds on: opFeedback
(UNSET_MAP_FLAG counter for silently-dropped ops), ClientStream.isClosed,
and StateCollector.setReachabilityEnabled for swarms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:58:09 -04:00
max bittkerandClaude Fable 5 9fa3bdb8ef fix(state): stale combat signals and cross-session screenshot mixups
Three bug-report fixes around state that outlives the thing it describes:

- NPC healthPercent: the client entity keeps the last reported health
  forever, so a killed-then-respawned NPC advertised 0 HP indefinitely and
  deadlocked loops that skip dying targets. Health is now only published
  inside the hitbar window (combatCycle), like the game's own bar; outside
  it the NPC reads as never-hit (null).

- player.combat.targetIndex: faceEntity is facing, not fighting - a
  finished or reconnect-inherited fight kept inCombat/targetIndex pinned
  forever. The facing now counts only while corroborated: a recent hit on
  us, a recent hit on the target, or a ~10s grace window for the walk-up.

- SDK screenshots: the gateway broadcasts frames to every session on a
  bot, and the SDK resolved unknown-id frames against "the first pending"
  request - handing controllers frames an observer asked for. Frames with
  an id we did not issue are now dropped; the fallback only serves
  responses that carry no id at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:30:48 -04:00
max bittkerandClaude Fable 5 3c08ce1061 fix(sdk): shortest-name matching, drop/sell/fletch papercuts from bug reports
- find* methods now pick the shortest matching name (ties keep nearest-first),
  so /hopper/i resolves to "Hopper" instead of a nearer "Hopper controls"
- interactLoc/useItemOnLoc no longer rebuild a RegExp target from the matched
  name after walking, which silently dropped the caller's anchors
- sellToShop accepts -1 as "all" for parity with bank deposits
- fletchLogs treats a level-up "Click here to continue" chatbox as a blocking
  dialog: dismisses it and re-uses the knife instead of reporting the product
  missing; the skill-dialog helpers never count a continue button as a product
- new bot.dropItem(target, amount|'all') drops by pattern and waits for each
  drop to land, replacing slot loops that silently no-op on stale slots

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:18:21 -04:00
max bittker 160ffcbb50 combat improvements 2026-07-26 15:16:37 -04:00
max bittker 448ff85b21 a 2026-07-25 14:46:55 -04:00
max bittkerandClaude Opus 5 11424fa84d feat(sdk): add a bug-report CLI and a write-only endpoint to collect them
Agents hit SDK papercuts constantly - a method returns success:true and
nothing happened, a dialog option isn't where the docs say it is - and
there was nowhere to put that. Filing one is now a single command:

  bun sdk/bug-report.ts "walkTo returns success at closed gates"

The SDK's git commit is stamped on every report, so a report points at the
code that produced it.

Server side, POST /bug-report appends JSONL to the mounted volume
(BUG_REPORTS_FILE in fly.toml, so reports survive deploys). The endpoint is
deliberately write-only: nothing is served back over HTTP, and reports are
read off the machine with

  fly ssh console -C "cat /opt/server/data/bug-reports.jsonl"

Reports always go to rs-sdk-demo.fly.dev, never to whatever SERVER a bot is
playing on - a bug found against a local server is still a bug in the SDK.
Telemetry is on by default; TELEMETRY=false in bot.env or the environment
turns it off.

Also renames claude.md -> CLAUDE.md and agents.md -> AGENTS.md. Those names
only resolved on macOS: the filesystem is case-insensitive here, so git had
recorded lowercase names that no tool would find on a case-sensitive
checkout. Verified on a case-sensitive volume - before the rename, lookups
for CLAUDE.md and AGENTS.md both missed; after, all three resolve to the one
real file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:43:49 -04:00
max bittkerandClaude Opus 5 f20f69e1d5 fix(sdk): route to doors and gates with the wall reach rule
Every door and gate refused bot interaction with "I can't reach that!",
which read as the pen or building being sealed. The Lumbridge chicken coop
was the clearest case: its gates at (3236,3295)/(3236,3296) are
WALL_STRAIGHT/EAST, so the server (ReachStrategy.reachWall1) only accepts
the gate tile itself, the tile east of it, or the north/south neighbours —
never the tile to the west, where a bot arriving from Lumbridge stops.

Client.interactLoc() and useItemOnLoc() called tryMove() with the loc's
rectangular footprint and a hardcoded shape/angle of 0, so tryMove skipped
its testWall/testWDecor branches entirely and used the rectangle approach
test. The rectangle rule accepts any open side, so the client parked the
player west of the gate and fired OPLOC from a tile the server rejects.
This has been wrong since d3c390330 taught these methods to route at all;
that fix targeted trees and rocks and never covered wall shapes.

Resolve the loc's shape and angle from the scene typecode and branch the
way the mouse-click path does (interactWithLoc), which also restores the
rotated footprint and forceapproach that centrepieces were losing. Falls
back to the old rectangle footprint when the tile holds no such loc.

Also name the usual cause when walkTo finds no path: a blocked destination
tile reported as "no waypoints" reads as broken collision data, and sent a
bot chasing a phantom pathfinder bug instead of picking a neighbour tile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 22:45:39 -04:00
Max Bittker aba0f4e344 Merge pull request #51 from MaxBittker/sdk/interface-close-and-fletching-fixes
fix(sdk): make interface closing work and fletching pick the right product
2026-07-24 19:56:29 -04:00
max bittkerandClaude Opus 5 8fb2390717 fix(sdk): make interface closing work and fletching pick the right product
Close-type components ("Close Window") have no server if_button trigger -
the real client handles them locally. clickComponent sent IF_BUTTON anyway,
so the server answered "No trigger for [if_button,shop_template:com_77]" and
the modal stayed open. Route close-type buttons to closeModal().

An open shop or bank replaces the inventory tab, so isComponentVisible()
rejects inventory packets sent underneath with no game message at all.
sendUseItemOnItem now fails up front with reason 'modal_open' naming the
close method, instead of appearing to do nothing.

fletchLogs searched dialog.options for buttons reading "Ok". skill_multi3
has none - its twelve buttons are Make X / Make 10 / Make 5 / <product> per
product - so that branch never fired and every call fell through to an
index guess: arrow shafts clicked Make X and hung on the count prompt,
'short' on regular logs made ten arrow shafts. Product labels also arrive
padded with layout newlines ("\n\n\n\n15 Arrow Shafts"), defeating string
matching.

Flatten component labels at the client, and resolve the product by name
via resolveSkillDialogProduct - only the make-1 button carries a name, so
the named options are the products in order. On no match, report the
available labels rather than fletching something else.

Also document closeInterface/closeShop/sendCloseModal and the dialog
option ordering in CLAUDE.md, which is where they were missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 19:54:17 -04:00
Max Bittker 8a40d0bfd6 Merge pull request #50 from MaxBittker/sdk/quiet-runner-snapshot
feat(sdk): print a compact state summary after script runs
2026-07-24 19:51:01 -04:00
max bittkerandClaude Opus 5 f58713223f fix(sdk): report dropped action dispatches instead of throwing
sendAction rejected on four paths - dispatch timeout, socket close
mid-flight, gateway sdk_error, and a missing result payload. Porcelain
actions dispatch outside their try blocks, so those rejections escaped
past the `if (!result.success)` branches written to handle them and
killed the caller's script. bot.pickpocketNpc() advertised a
reason: 'timeout' it could never actually return; a thieving run died on
the first stalled dispatch instead of retrying.

sendAction now resolves { success: false, reason, phase: 'dispatch' },
tagging each rejection site via ActionDispatchError so callers can tell a
timeout from a disconnect from a gateway error. The pre-flight
"Not connected" guard still throws: a dead connection should stop a
script, not let it spin.

pickpocketNpc reports the dispatch failure as 'dispatch_failed' rather
than 'timeout' - withDoorRetry retries on 'timeout', which would have
cost 3x actionTimeout per call against a stalled client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 19:48:02 -04:00