205 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 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 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
Kaden Staker ca0fe09cc8 fix: validate Managing Miscellania coffers deposit and withdraw amounts
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.
2026-10-11 17:29:39 -06: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 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 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 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 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 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 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 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 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