100 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 07d53516ab fix(webclient): don't report talk/trade NPC targets as combat opponents
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:12 -04:00
max bittker 2012ab67f6 Reduce tick stalls and move leaderboard refreshes off the game thread 2026-10-11 02:21:47 -04:00
max bittker e504d6adb3 fix(mapview): preserve map proportions at minimum zoom 2026-10-11 01:17:02 -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 1be9ac7e38 fix(webclient): publish game ticks after decoding player updates 2026-10-11 00:45:03 -04:00
max bittkerandClaude Opus 5.5 93cd70b347 Tell clients on an old game revision how to update
After a revision upgrade, a lite runner from an old checkout fails login
with an opaque "Client out of date" and its scripts only report "No game
client is attached".

- Engine: on a revision mismatch, read the username from the login block
  (laid out the same across revisions) and record it for 30 minutes; a
  correct-revision login clears it. The management port serves it at
  /outdated-client.
- Gateway: when an SDK connects to a bot with no live client, ask the
  engine and answer sdk_error with the update steps. Old SDK checkouts
  print gateway errors, so this reaches people who haven't pulled yet.
- Public /api/version. The lite runner checks it before loading the
  cache and fails with OutdatedClientError (a LoginError, code 6) naming
  the fix; the runner and swarm exit 3 instead of retrying.
- CLAUDE.md troubleshooting entry for the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:53:12 -04:00
max bittkerandClaude Opus 5.5 b6714fa74e mapview: cover 289's map squares with non-overlapping bands
289 added Ape Atoll (rows 42-43), its dungeon (142-143) and squares at
raw row 69, which the old z remap either dropped or drew on top of the
underground. The bounds also skipped every edge square, so row 44 and
columns 55-56 were never drawn.

Replace the ad-hoc remap with a band table (overworld 42-62, underground
142-161 -> 63-82, misc 69-77 -> 83-91), widen the bounds to columns
29-56 with a padding square on each side, and leave rows outside every
band undrawn instead of placing them at their raw position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:41:36 -04:00
max bittkerandClaude Opus 5.5 8601955cfb Upgrade LostCity from revision 274 to 289
Rebases the vendored engine, content and webclient onto upstream's 289
branches (34 engine, 165 content, 9 webclient commits past our 274 base).
Each vendor-289 branch in ../repos is upstream 289 plus one squashed
"rs-sdk local mods" commit, refreshed from this tree first.

289 adaptations:
- Protocol: 289 reshuffles every opcode. Custom MESSAGE_PUBLIC (server 255)
  and MARKET_SEARCH (client 243) are still free and kept.
- Lite client: CLIENT_VERSION 289, UPDATE_INV_FULL size g1->g2 and
  UPDATE_INV_PARTIAL slot g1->gsmart, mirroring Client.ts.
- Content ids: upstream appended configs where ours lived. GE interface
  moves 10984->11942 (through 12437), grand_exchange_booth 4671->5116,
  grand_exchange_teller 1359->1596 (m49_53.jm2 updated), pvp_death_mark
  varp 358->394. PlayerLoading moves a saved varp 358 value to
  pvp_death_mark (289's varp 358 is temp-scoped, so only pre-289 saves
  carry it).
- GE loc/npc configs use 289's renamed model debugnames.
- Engine: keep our Bun web split over upstream's new Fastify web.ts (no
  fastify/tsx deps), add OnDemand.onClientClosed to our WS close handler,
  keep our WSClientSocket API/batching and the 1s delayed close (TCP too).
  Adopt upstream's HashTable iterator fix, IPv6 player-loop key (with our
  NaN guard), chat effects 0-5, pointer-check removal.
- Razmire building store keeps our 650 sell price with upstream's 4% haggle.
- Tests: opcode assertions use ClientProt enums; hand-built inv packets
  use the 289 layout.

PATCHES.md documents the new per-upgrade steps (custom id renumbering,
lite decoder parity, opcode collision check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:53:05 -04:00
max bittkerandClaude Opus 5.5 c5edbc278a fix(lite): send ::commands as CLIENT_CHEAT instead of public chat
Mirrors Client.say in the browser client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:00:55 -04:00
max bittkerandClaude Opus 5.5 a132676216 chore(bot): stop sending the unused formattedState with each state frame
The browser bot attached a text rendering of the world state
(formatWorldStateForAgent) to every state message. Nothing reads it: the
gateway forwards only message.state, and the SDK, MCP server and lite runner
never refer to it. Dropping it trims ~5% of each browser bot frame (~10%
after compression).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:22:44 -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 bittker 4906fb6e83 feat: create Grand Exchange sell offers from inventory clicks 2026-09-07 23:55:50 -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 56b73e08fc feat(hiscores): server-side sprite rendering for KOTH/viewer pages + cache headers
- Hiscores pages emit <img> tags pointing at /sprite/player/... and
  /sprite/item/... URLs; sprites are rendered once in a worker thread hosting
  the webclient's ItemViewer, PNG-encoded, LRU-cached and served with long
  cache headers, instead of every browser pulling the whole model cache.
- KOTH page shows the reigning king of each window (all time / week / today)
  side by side above the table.
- ItemViewer gains a lazy ZipProvider that unpacks models on first use.
- Viewer cache archives get Cache-Control headers (immutable when crc-suffixed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:25:34 -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
Jamie Legg c27bb69cbf fix(map): keep place labels visible while zooming 2026-08-25 23:53:03 +01: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 34641c5cbf perf(engine): tick-thread diagnostics, hiscores cache, login takeover throttle
- management port: /tickstats (rolling per-phase cycle timings + per-route web
  request stats), /profile (JSC sampling profiler over the main thread),
  /prometheus computed per request instead of frozen at startup
- web: log requests slower than WEB_SLOW_MS (100) with client ip; per-route
  counters in /tickstats
- hiscores: ranked lists cached 60s (HISCORES_CACHE_MS) instead of materialising
  the whole hiscore/hiscore_large table on the tick thread per request; player
  page ranks come from the cached lists instead of 22 count() scans
- login: refuse session takeovers within LOGIN_TAKEOVER_MIN_MS (10s) of the
  previous one (response 5) - crash-looping clients were doing ~3 takeovers/s
- lite/loadtest.ts: N idle lite sessions for local load testing

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 19:44:43 -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 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 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 697b43933d feat(client): walkTo reports out-of-range destinations explicitly
A destination beyond the 104x104 build area used to be silently clamped
to the scene edge and reported as a plain success, so a caller could not
tell a full route from a partial leg - the lite fleet worked around it
with a hand-rolled Traveller (bug report msjgy9cl). Both clients'
walkTo now return {moved, outOfRange}, and the ActionExecutor surfaces
reason 'out_of_range' / a leg-to-edge message instead of pretending the
route was complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:46:53 -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 afd498e293 feat(viewer): default player appearance + texel pool init in ItemViewer
DEFAULT_APPEARANCE exports the engine's new-player body defaults in the
player-info wire encoding, and the viewer now calls Pix3D.initPool so
textured faces (walls, doors, fences) render instead of silently drawing
nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:58:58 -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 ea11070dcd fix(client): dispatch NPC/player/loc ops even when client routing fails
The client-side reachability precheck in talkToNpc/interactNpc/
interactPlayer/useItemOnNpc (and the lite interactLoc/opNpc/interactPlayer/
useItemOnNpc/useItemOnLoc) returned cant_reach WITHOUT sending a packet,
which made approach-range interactions impossible from the SDK: Wormbrain's
talk-through-the-jail-bars ([apnpc1] + p_aprange(3) + lineofsight) worked
for a human click but every SDK variant refused to send (bug report
2026-08-09); the Mage Arena slashable web hit the same wall on the lite
fleet (2026-08-11).

interactLoc/useItemOnLoc/spellOn* in the browser client already dispatch
unconditionally for exactly this reason - the server owns the reach check,
and its worst-case answer is an observable "I can't reach that!" or op
rejection instead of client-side silence. This aligns every NPC/player/loc
op in both clients with that precedent and keeps the route result as
routed on the ClientActionResult.

wrapClientAction now surfaces routed:false as data.{routed:false} plus the
rate-limited unreachable warning, so loops that blacklisted targets on
cant_reach still have a signal.

Verified live: sendInteractNpc on a client-unreachable rat now dispatches
(previously silent no-op) and the engine's opRejectedCount advances -
server-side feedback where there was none. Reachable talkTo unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:22:28 -04:00
max bittkerandClaude Fable 5 db83d0425a fix(client): refuse bare dialog continue while a choice is pending
The engine refuses RESUME_PAUSEBUTTON while if_addresumebutton options are
registered (a choice must be answered with IF_BUTTON). Both clients still
sent the bare RESUME and latched resumedPauseButton - and since the refused
resume produces no new interface, nothing ever reset the latch: every later
continue click returned client_rejected and the dialog was pinned until
close. This is the 'toll dialogs only advance with sendClickDialog(0) once,
then never again' failure fleets kept hitting (bug reports 2026-08-06 and
2026-08-10).

Choice pages are exactly the ones publishing OK-typed (buttonType 1)
options, so clickDialogOption now refuses a bare continue - and a
CONTINUE-typed click - while any OK option is visible, without sending or
latching. IF_BUTTON answers are unaffected.

Verified live against the local engine (lite client): bare
sendClickDialog(0) on the tutorial guide's choice page now returns
client_rejected immediately and the choice remains answerable by text;
plain continue pages still advance via their published index-1 option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:13:45 -04:00
max bittkerandClaude Fable 5 ca88d2142d perf(lite): skip BFS scratch-map fills when target is already in reach
route() cleared two BuildArea-sized dir/dist maps before discovering, on
the first queue element, that the actor already stands in reach of the
target. Run the same wall/decor/loc reach checks up front and return
"already reachable" without any fill or MOVE_OPCLICK packet, matching the
existing length<=0 no-packet behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 10:55:34 -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 bittker 6fd02c9fb5 fix(lite): align long walks with browser client 2026-08-08 23:51:27 -04:00
max bittkerandClaude Fable 5 933fe0dbe0 feat: NODE_PROFANITY_FILTER option to disable chat censoring
The filter runs in two independent layers, both now gated:

- Engine: WordEnc.filter skips censoring (bad words, domains, TLDs,
  fragments) when NODE_PROFANITY_FILTER=false, keeping casing
  normalization. Covers public chat ingest and PM encode.
- Browser client: the setting is injected via bot.ejs/client.ejs into
  the Client constructor and sets WordFilter.enabled, so the local
  re-filter follows the server automatically.
- Lite client: new SessionOptions.profanityFilter; the runner reads
  PROFANITY_FILTER from bot.env / process env (no server config
  channel exists headless).

Default unchanged (filter on).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:41:32 -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 7f0b4221d8 chore: keep PAPER_CUTS.md as local notes, not tracked
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:22:00 -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 Opus 5 478cb18ab7 webclient: remove the in-browser script runner
Nobody used it. Deletes ScriptRunnerUI.ts (the panel, Run/Stop/Restart, Copy
SDK Context, and the in-page BotSDK controller that connected back to /gateway
as a 'control' client to pair with its own page's session).

That runner was the only thing pulling sdk/index.ts + sdk/actions.ts into the
webclient bundle, so its scaffolding goes with it: the pathfinding browser stub
and its Bun resolve plugin (which existed to keep the rsmod WASM + 22MB
collision-data.json out of the browser), the `open` external, and the
sdk/API.md -> out/bot/api.md copy step.

No imports now cross from server/webclient/src into sdk/. The bot bundle drops
to byte-parity with the standard client (1117544 vs 1117545).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:49:14 -04:00
max bittkerandClaude Opus 5 898622e1d0 fix(lite): dead-on-wire actions, sticky anims, stale zone overlay
Audit of the lite client against the browser client and the engine
decoders turned up three classes of silent breakage:

Actions that never applied server-side. OPHELDU and OPHELDT wrote their
fields in the wrong order, so use-item-on-item and spell-on-item were
rejected; inv button ops sent obj id 0, which fails the engine's exact
inv.hasAt(slot, obj) check; bank deposit targeted component 5064 instead
of bank_side:inv (2006) and mapped "All" to option 5 (X) rather than 4;
MESSAGE_PUBLIC omitted the colour and effect bytes the decoder reads
before the packed text. say() now also self-echoes locally, since the
server does not echo your own public chat back.

Animations that never expired. Nothing aged primaryAnim/spotanimId, so
the first anim of a session stuck forever and every check gating on
animId was poisoned. Ports Client.entityAnim minus rendering, driven off
the same measured-tick multiplier the browser derives from PLAYER_INFO.
The ANIM update-mask block is factored into applyAnimMask and shared by
the player and npc paths.

Loc overlay that never reverted. UPDATE_ZONE_FULL_FOLLOWS resets a zone
to static map state, but the overlay kept pending loc changes, so a door
removed via LOC_DEL stayed deleted and routing kept pathing through it.
clearOverlay is replaced by clearZoneOverlay, translating the packet's
build-area zone origin to world tiles and marking collision dirty only
when something was actually removed.

Covered by action-encoding, anim-expiry, and zone-reset tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:24:15 -04:00
max bittkerandClaude Fable 5 61f7b79189 feat(koth): King of the Hill at the Demonic Ruins, tracked on hiscores
Every wall-clock minute, the highest combat level player standing inside
the Demonic Ruins wall polygon (traced from m51_60.jm2, incl. the western
annex; interior wall faces only) scores a point and is crowned; the
current king wins ties, an empty hill dethrones. Captures append to a new
koth_capture table via the logger pipeline, snapshotting the winner's
resolved appearance slots. /hiscores/koth shows all-time/week/day boards
with three kings side by side and median-loadout character sprites,
rendered in-browser by the viewer's new renderPlayerSpriteAsImageData
(ClientPlayer model assembly + offscreen Pix2D raster). Crown messages
are local (3 zones), never server-wide.

Also: split LoopCycle out of Client so the viewer bundle doesn't drag the
game client in, and fix the worker-killing once('error') ws handler in
InternalClient (the "engine dies ~40s into a bot session" local crash),
with per-worker error/exit logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:49:15 -04:00
max bittkerandClaude Fable 5 a740fd091a feat(lite): headless lite client, swarm runner, and reach differential test
The headless client speaks the 274 protocol over the gateway loopback with
no canvas/audio/render weight: protocol decode, zone/loc tracking, its own
collision build, and the same BotStateCollector/ActionExecutor bridge the
browser client uses, so bot state and dispatch behave identically. Includes
the runner/swarm CLI entrypoints and LoopCycle extracted to break the
Client import cycle.

reach.test.ts sweeps ReachProbe against the real router (movement.ts
tryMoveTo, the verbatim Client.tryMove port) tile-by-tile, so a
`reachable: true` the dispatch would then reject fails CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:58:17 -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 81d422e5ca fix(client): always dispatch loc ops even when client routing fails
interactLoc and useItemOnLoc aborted with cant_reach before sending
OPLOC/OPLOCU whenever the client routefinder found no path. Approach ops
(aploc/aplocu scripts like Waterfall's "Swim to" rock and rope-on-rock)
trigger at tiles that are never routable, so bots were stranded with no
packet sent and no game message. The server owns the reach check; now we
attempt the route for movement but dispatch regardless, reporting
routed: false like the spell ops already do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:08:50 -04:00
max bittker 160ffcbb50 combat improvements 2026-07-26 15:16:37 -04:00
max bittkerandClaude Opus 5 5e9a3775e7 fix(client): keep the main loop running in a background tab
A bot left in an unfocused tab went stale: ticks stopped advancing, state
aged into the minutes, and scripts reported "Stuck at (x, z)" while the
character stood still. Reconnecting was the only cure.

GameShell.run() is a sleep() loop and sleep() was setTimeout, which Chrome
clamps to one wake per second in a hidden page — then to roughly one per
minute once intensive throttling kicks in after about five minutes. Packets
still arrive over the WebSocket, but they are only drained inside mainloop(),
so the client falls arbitrarily far behind.

Timers inside a dedicated worker are exempt, so hand the clock to one: the
worker holds each delay and posts back when it elapses. Measured in Chrome
146 with the tab hidden, a page-timer loop managed 1.0 wake/s where the
worker-driven one held 66.4, against a foreground rate of 66.0.

Falls back to setTimeout when a worker cannot be created — no Worker or Blob
constructor, a CSP forbidding blob: workers, or the worker erroring at
runtime — so the worst case is today's behaviour rather than a hang.

Note this does not survive Chrome freezing or discarding a long-idle tab;
long-running scripts should still check sdk.getStateAge().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:16:23 -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 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