100 Commits
Author SHA1 Message Date
max bittkerandClaude Opus 5.5 55fa26ccaa feat(hiscores): rank by the tick a level was reached, not logout
Skill and total hiscore rows were only written at logout, so the playtime
tiebreak recorded when a player next logged out after a level-up - hours
late for anyone who kept playing. Stamp player.playtime per stat on each
level-up, persist it in the save (SAV_VERSION 9), and use the stamp when
writing hiscore rows. Total level uses the latest stat stamp. Saves from
before the stamps existed fall back to logout playtime.

Note: an engine older than this rejects v9 saves, so rolling back past this
commit locks out anyone who logged out on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:31:46 -04:00
max bittkerandClaude Opus 5.5 ac6ef5e42c feat(hiscores): show times to the second with aligned columns
Times were shown only to the minute, which no longer separates players at
the top of competitive boards. Show seconds, zero-pad minutes/seconds under
the leading unit with tabular digits so right-aligned columns line up, and
put the exact tick count in a hover tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:29:00 -04:00
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 578f12fd75 fix(worldfeed): always compress feed messages so Safari stays connected
WebKit drops a permessage-deflate socket (close 1006) when compressed and
uncompressed messages are mixed. The feed chose per message based on the last
tick's duration, so near the threshold Safari viewers disconnected every few
seconds.

Fixes #72

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:59:19 -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 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 bittkerandClaude Opus 5.5 f4b0e4a068 chore(gateway): satisfy strict index checks in traffic meter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:11 -04:00
max bittkerandClaude Opus 5.5 76391328ec fix(engine): allow staff ::kick and ::ban regardless of NODE_PRODUCTION
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:11 -04:00
max bittkerandClaude Opus 5.5 c06f209270 fix(hiscores): clear bank snapshots when a bank empties
Withdrawn items stayed ranked because empty banks skipped the update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:11 -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 fb26497163 Harden Miscellania transfers and add coin dupe regression coverage
Follow up kadeski’s fix in PR #73 with current-balance and inventory-capacity checks. Keep the original report and authorship credited in the vendor patch checklist. Exercise both reported exploit inputs in compiled-script tests, and build engine content for those tests in CI.
2026-10-11 19:37:42 -04:00
Max Bittker e4441c6233 Merge pull request #73 from kadeski/fix/misc-coffers-input
fix: validate Managing Miscellania coffers deposit and withdraw amounts
2026-10-11 19:36:36 -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 286035ba61 fix(runner): release cleanup timers without terminating later runs 2026-10-11 00:49:30 -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 68ff9ebf82 engine: add an optional live world feed for spectator viewers
A read-only /worldfeed websocket that streams the players and npcs in a
subscribed tile area every tick (positions, steps, anims, spotanims,
facing, hitmarks, chat, appearance), plus an optional online roster. It
drives the rs-world 3D viewer (github.com/MaxBittker/rs-world).

Off unless WORLD_FEED=true. WORLD_FEED_TOKEN requires ?token=, and
WORLD_FEED_MAX_CLIENTS caps connections (default 32). While no viewer is
subscribed the tick hook returns immediately. Hidden-visibility players
are never sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:10:56 -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 aea4333baf engine: cap outdated-client login decodes per tick
Each record costs an RSA decode (~0.3 ms) on the tick thread. Cap it at
8 per tick so a fleet of old clients retrying after the upgrade cannot
eat into the tick; refusals past the cap are recorded on a later retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:53:45 -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 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 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 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 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 446f1ebb42 chore(gitignore): drop patterns for the old flat fleet layout
Those files moved under bots/fleet/, which bots/*/ already covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:00:55 -04:00
max bittkerandClaude Opus 5.5 c755bbe3bd feat(tools): add item census, owner lookup and offline bank grant tools
- item-census.ts / item-census-html.ts: count and holders per item across
  every save plus GE escrow, rendered as a sprite viewer
- find-item-owners.ts: who holds a given obj id
- grant-bank-item.ts: patch an offline player's bank, optionally waiting
  for them to log out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:00:55 -04:00
max bittkerandClaude Opus 5.5 a6d124cec2 chore(fly): set kill_timeout and hide local test presets from hiscores
Matches the deployed config: 120s graceful stop, and HISCORES_HIDDEN_NAMES
covering the imported local test saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:00:55 -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 33aa0a1424 fix(server): preserve socket ownership through reconnects
World.processLogins() swapped in the replacement socket without pointing it
back at the active player, and the WS/TCP close handlers cleared a player's
socket without checking that the closing socket still owned it. A delayed
close of the old socket could detach the replacement.

Same change as 87dc1bf18 (deployed 2026-10-06), applied to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:00:49 -04:00
max bittker a0c0a29b78 style(hiscores): match item search to classic bottom inputs 2026-10-08 12:51:27 -04:00
max bittker 0c1d35abb1 feat(hiscores): rank bank wealth by item name substring 2026-10-08 12:28:19 -04:00
max bittker 1bc50391c8 fix(hiscores): count equipped throwing weapon stacks once 2026-10-08 12:20:25 -04:00
max bittkerandClaude Opus 5.5 415491a1cc fix(hiscores): stamp playtime only when a skill's level changes
Skill and overall rows rewrote playtime on every logout, even with no XP
gained. Playtime is the tiebreak (level desc, playtime asc), so maxed
players slid down the board for continuing to play. Now playtime records
when the current level was reached; XP-only changes still update value.
Matches the quest points board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:36:42 -04:00
max bittkerandClaude Opus 5.5 ab59cc31bf chore(hiscores): drop the single-option profile dropdown
Prod only has the "main" profile, so the selector on the list and player
pages offered one choice and did nothing. ?profile= still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:20:21 -04:00
max bittkerandClaude Opus 5.5 353d1061b6 feat(hiscores): backfill the quest points board from saves
tools/server/backfill-quest-points.ts fills hiscore type 100 for players
who haven't saved since the board shipped. Older saves don't hold %qp (it
was temp-scoped), so the total is recounted from the saved quest progress
varps by evaluating [proc,count_questpoints] from content; any condition
form it doesn't understand is a hard error.

Only reads saves for accounts with >= 1h playtime or >= 100 total level
(every quest holder seen at launch had >= 7h and >= 126), skips staff and
banned accounts, and never replaces an existing row. --verify checks the
recount against %qp in saves written since it became perm.

Ran on prod 2026-10-07: verify 643/643 matched; 15,896 candidates, 2,716
rows filled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:17:47 -04:00
max bittkerandClaude Opus 5.5 74b79e56a0 feat(hiscores): add a quest points leaderboard
/hiscores?category=100 ranks players by quest points, ties broken by the
playtime they reached that total at. Linked from the hiscores sidebar and
shown as a row on player profiles. Stored in the hiscore table under type
100, so no migration.

The qp varp was temp-scoped, so it never reached the save file the login
server reads. Make it scope=perm (the login trigger still recounts it from
quest progress every login), and load VarPlayerType in the login server so
saves load with their varps. The login server warns at startup if qp ever
stops being perm.

Updated on logout and autosave; autosave only writes when the total changes.
Existing players appear after their first save on this build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:59:24 -04:00
Max Bittker 4be9a63b53 Merge pull request #71 from MaxBittker/fix/runscript-require-live-client
fix(runner): fail loudly when no game client is attached; document starting one
2026-10-06 21:38:26 -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 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 9f749a8b9b perf(engine): compress state frames on the /gateway relay
Older SDKs and browser bots still reach the gateway through the game
server's /gateway relay, uncompressed, which was ~85% of rs-sdk-demo's
bandwidth. The engine's web server now negotiates permessage-deflate and
sends relayed frames compressed. Clients that offer the extension (browsers,
Bun, Node ws) pick it up with no change on their side and compress what they
publish as well. Clients that don't keep working uncompressed.

Bun can't opt out per upgrade, so game sockets negotiate it too, but game
packets still go out uncompressed (ws.send's default), and small client
packets showed no inflate cost.

Compression runs on the tick thread at ~7 ms CPU per MB, so a frame is sent
uncompressed whenever the last tick used 75% or more of its budget.

Real state frames shrink to ~14% in both directions. On prod, eth0 went
from ~100 Mbps out / ~120 in to ~21 out / ~18 in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:22:43 -04:00
max bittkerandClaude Opus 5.5 3dd523f5b6 feat(gateway): per-swarm traffic meter on the internal port
GET /traffic (bytes by swarm, connection mode, Fly edge and source IP, with
a $/month estimate) and GET /traffic/fields (per-field share of state bytes
and how often each repeats frame to frame, sampled 1 in 30). Both answer
only on the internal port 7780, never on the public listener.

The engine's /gateway relay forwards Fly-Region and Fly-Client-IP as
?edge=&ip= because the gateway can't see those headers through the
loopback hop.

This is what has been running on rs-sdk-demo since v181.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:22:43 -04:00
max bittkerandClaude Opus 5.5 2fdb019b3e fix(server): cap game sessions at 100 per IP keyed on Fly-Client-IP; skip repeat state frames
MAX_PLAYERS_PER_IP is now 100 on Fly (code default stays 200 for local load
tests). getIp only trusts Fly-Client-IP behind Fly and the socket peer
elsewhere; the old cf-connecting-ip / X-Forwarded-For lookup took the first,
client-supplied entry, so a swarm could claim a new IP per connection and walk
past the cap.

The gateway no longer fans out a state frame that repeats the last one sent
to subscribers apart from its publication revision (same tick, same content:
a post-action publish where the action changed nothing visible yet).
Subscribers get the next tick's frame instead; any difference still goes out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:07:37 -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 cbfd40e6fa fix(shop): eliminate Razmire buyback profit 2026-10-03 21:16:33 -04:00
max bittker c7a94502a9 fix(login): release timed-out request callbacks and correct takeover response 2026-09-28 18:44:06 -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 Opus 5.5 af84ba25a5 feat(hiscores): show all-time, week and today runite boards side by side
/hiscores/runite now renders the three windows as columns on one page
instead of tabs behind ?window=. Each board lists the top 10 by ore mined
(rank, name, ore); the "Last mined" column is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:47:17 -04:00
max bittkerandClaude Opus 5.5 822af40abf fix(hiscores): backfill equipment-board appearances from saves
Outfit rows only gained an appearance on the player's next save, and most
of the board's top players are long offline, so the sprites never showed.
tools/server/backfill-outfit-appearance.ts rebuilds it once from each save
(via the exchange checkpoint when present, like the login server), filling
only still-empty rows whose listed items match the save.

outfitAppearance() moves to its own module so the tool can share it with
the login server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:13:08 -04:00
max bittkerandClaude Opus 5.5 aa4bb5dbbf feat(hiscores): replace King of the Hill with a Lava Maze runite leaderboard
Rip out KOTH (Koth.ts, the per-minute World hook, koth_capture logging and
table, /hiscores/koth) for simplicity. In its place, the mining script calls
a new runite_mined(coord) command whenever it gives runite ore; the engine
counts only the two Lava Maze rocks (0_3059_3885, 0_3060_3884) and relays a
runite_mine row through the logger pipeline. /hiscores/runite ranks ore
mined (all time / week / today) under an in-game shot of the rocks. No
backfill. Migration 20260926000000_runite_mine drops koth_capture.

The server-side player sprites move to the Equipment board: the login
server stores each outfit's resolved appearance slots in the new
hiscore_outfit.appearance column, the top 10 rows show /sprite/player
images, and item icons use /sprite/item instead of the in-browser viewer.
Rows without an appearance get one on the player's next save.

The sidebar link list shared by the hiscores pages is now one helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:56:17 -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 3c176cd763 fix(hiscores): refresh bank + outfit hiscores on autosave
Bank/outfit hiscores were only written on logout, so players who stay
online for days (e.g. #2 on the bank board) showed values days stale.
Autosave now updates both boards after a successful save write. Rows are
only rewritten when value/items changed, since autosave posts every
online player in one burst. Skill hiscores stay logout-only.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 18:45:20 -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 bittkerandClaude Code ccf011d91c fix(traces): stop /playertraces 502s on prod
The segment query filtered and sorted on end_time, which has no index, so
every request full-scanned 13M segments in the 32GB db. That took longer
than Bun's 10s idle timeout, and the fly proxy answered with a bare 502.

- bound start_time (segments span <1h) so the query uses its index
- stride-sample segments across the window past 100k instead of taking
  only the newest 250k (a week is now 2.2M segments), and skip raw rows
  when sampling
- dedupe concurrent builds per window, serve stale cache while rebuilding,
  and snap ?hours= to fixed buckets so the cache stays bounded
- extend the proxy request timeout and bound the upstream fetch

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 17:02:39 -04:00
max bittker 4906fb6e83 feat: create Grand Exchange sell offers from inventory clicks 2026-09-07 23:55:50 -04:00
max bittker e5282efdd9 fix: preserve player assets across Grand Exchange recovery 2026-09-07 23:47:29 -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 0f761d1cf3 chore: gitignore remote fleet tooling and sqlite sidecar files
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:57:26 -04:00
max bittkerandClaude Fable 5.1 eef5964755 feat(engine): opt-in JSONL kill and trade ledgers
KILL_LEDGER_FILE records every NPC death at the NPC_DEL chokepoint with the
credited killer and full damage split. TRADE_LEDGER_FILE records completed
player trades at the moment the exchange executes. Both are off by default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:57:26 -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
Max Bittker 2b89920c70 Merge pull request #66 from jamie-legg/fix/map-labels 2026-08-26 11:32:13 -04:00
max bittkerandClaude c4880dc82e fix(content): gate duel-death branch on the dying player's own state
The player_death queue decided a death was a duel death by looking up the
challenged opponent (%duel2accept) and checking *their* position. That varp
is set on Challenge and never cleared when you walk away, so a death anywhere
jumped to player_death_duel, which no-ops outside a fight pit — skipping item
loss and respawn entirely. Now mirror the label's own guard: the dying player
must be in a fight pit with an unfinished duel.

Fixes #55

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 18:04:15 -04:00
max bittkerandClaude Fable 5 831c8d215a fix(engine): cache exportCollision + remove default web-UI password
- /api/exportCollision now caches the serialized export after the first
  request (the full scan runs on the tick thread and took 2-11s per hit)
  and rejects non-GET methods. Restart invalidates, matching how map
  content actually changes. Fixes #60.
- /bot no longer defaults the password to "test" (or "botpass123" in
  createAndLogin); a blank field generates a random 12-char password
  shown in the field and persisted via the existing sync. Fixes #57.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 15:40:23 -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 347c8be687 perf(engine): only batch websocket output during processClientsOut
Mid-tick packets (script replies, dialogs, login/logout) go out immediately
again; the per-tick coalescing now only covers the client-out phase, which is
where ~all the packets come from. Out-of-order is impossible: an immediate send
flushes anything already batched for that socket first.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:17:16 -04:00
max bittkerandClaude 9763784285 perf(engine): coalesce outgoing game packets into one websocket message per tick
Profiling prod (~950 players) put ~20% of the tick thread in ws.send(): every
packet was its own websocket frame + syscall (~20 per player per tick). Batch
per client in WSClientSocket and flush once at the end of processClientsOut;
login-phase replies still go out immediately, close() flushes first, and the ws
close event discards the batch. Clients read the socket as a byte stream, so
framing is not observable.

Also collapse /status/:bot in the web request stats.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 19:56:51 -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 79587ece10 fix(login): cache password verifications + off-thread bcrypt; drop stale sdk_auth requests
Prod login outage 2026-08-17 after the v159 redeploy: reconnect stampede saturated
the login worker with pure-JS bcrypt-ts compares (~5/s at 100-200ms each), every
sdk_auth crossed the gateway's 15s timeout, clients retried, backlog never drained.

- comparePassword: remember (stored hash, sha256(plaintext)) outcomes so repeat
  logins skip bcrypt; verify cold via Bun.password (native, thread pool)
- gateway stamps sentAt on sdk_auth; login server skips requests older than 14s

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 14:43:41 -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 a868401408 feat(content): pvp death mark keeps respawn at 1hp for 5 minutes
A pvp death now sets %pvp_death_mark (repurposed unused varp 358 so the
client cache CRC is unchanged) for 1000 ticks. Any death while marked
respawns at 1hp, so suiciding on an npc right after losing a fight no
longer shortcuts back to full health. The mark persists across logout
using the same remaining-ticks scheme as pk_skull, and expires on its
own; npc deaths while marked do not refresh it. Adds ::pvpmark /
::checkpvpmark debug cheats.

Verified locally: unmarked npc death respawns at full hp, marked npc
death (mark injected via .sav, exercising the login path) respawns at 1hp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:33: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 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 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 e4cb65ec47 fix(engine): self-heal bug-report log after a truncated append
A partial write (ENOSPC during the disk-full event) left a record cut
mid-line, and the next append spliced its JSON into the same line,
permanently breaking JSONL parsing for both. Before appending, check the
log's last byte and start on a fresh line if the previous append was cut
short.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:34:16 -04:00
max bittkerandClaude Fable 5 60c5445f45 feat(content): PvP deaths respawn at 1hp instead of full health
NPC/environment deaths and duel arena are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:21:42 -04:00
max bittkerandClaude Fable 5 2f8d26a40b chore(engine): halve default per-IP player cap to 200
MAX_PLAYERS_PER_IP still overrides; this only lowers the default memory
backstop introduced after the 2026-07-21 swarm OOM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:58:58 -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 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 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 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 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 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 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