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>
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>
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>
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>
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.
Clamp a deposit to the coins held as well as the coffer space, and ignore a withdraw count of 0 or less. Listed in server/PATCHES.md until LostCity ships its own fix.
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
/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>
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>
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>
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>
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>
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>
/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>
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>
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>
- 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>
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>
- 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>
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>
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>
- 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>