diff --git a/CLAUDE.md b/CLAUDE.md index 31a8d3925..0ce5531b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -343,6 +343,8 @@ wiki/ **"No game client is attached" / "No state received"** - No client is publishing state for the bot. Start the lite client (`cd server/webclient && bun src/lite/runner.ts {username}`) or open the bot's browser URL, then rerun. +**"Client out of date" / "the server now runs revision N" / lite runner exits with code 3** - The game server was upgraded to a new game revision. Update your checkout (`git pull`, then `cd server/webclient && bun install`) and restart the lite runner. Browser clients only need a page reload. + **Script stalls** - Check for open dialogs (`state.dialog.isOpen`). Level-ups block everything. **"Can't reach"** - Path is blocked. Try walking closer first, or find a different target. diff --git a/server/PATCHES.md b/server/PATCHES.md index 391535ecb..f0a9b693b 100644 --- a/server/PATCHES.md +++ b/server/PATCHES.md @@ -53,6 +53,18 @@ survival) is described in the project memory; this file is the human-readable ch overload instead of sprinting through the whole backlog at max speed. Verify: `grep -n "start - this.tickRate \* 2" src/engine/World.ts` +### Old-client guidance (rs-sdk) +- [ ] **Outdated-client record** — `World.ts` login: on a revision mismatch, `noteOutdatedClient()` + decodes the rest of the login block (identical across revisions) for the username and keeps + `{revision, at}` in `World.outdatedClients` (30 min TTL); a correct-revision login clears it. + Management port `/outdated-client?username=` exposes it (port 8898, not published by fly). + Gateway `sdk_connect`: when the bot has no live client, it asks that endpoint and answers + `sdk_error` naming the fix — old SDK checkouts print gateway errors, so this is the one hint + that reaches people who haven't pulled. Public `/api/version` (`web/pages/api.ts`) returns + `{revision}`; the lite runner (`session.ts`) checks it first and throws `OutdatedClientError` + (a `LoginError` code 6) — runner/swarm exit **3** so supervisors stop restarting. + Verify: `curl localhost:8888/api/version` and `curl 'localhost:8898/outdated-client?username=x'`. + ### Database - [ ] **Bun sqlite dialect** — `src/db/dialect/BunSqliteDialect*.ts` (3 files) + runtime chooser in `src/db/query.ts` (`typeof Bun !== 'undefined'` → bun:sqlite, else upstream's diff --git a/server/engine/src/engine/World.ts b/server/engine/src/engine/World.ts index 6d82135a8..5682b4268 100644 --- a/server/engine/src/engine/World.ts +++ b/server/engine/src/engine/World.ts @@ -201,6 +201,9 @@ class World { loginAddressAttempts: TTLCache = new TTLCache({ ttl: 60000 }); loginDeviceAttempts: TTLCache = new TTLCache({ ttl: 15000 }); + // rs-sdk: logins refused for an old client revision, by safe username. The gateway reads this + // (management /outdated-client) to tell that bot's SDK scripts to update their checkout. + outdatedClients: TTLCache = new TTLCache({ ttl: 30 * 60_000, max: 10_000 }); constructor() { this.loginThread.on('message', msg => { @@ -2100,6 +2103,30 @@ class World { }); } + // rs-sdk: an old client's login block is laid out like ours (274 and 289 differ only in the + // revision), so read its username: the gateway can then tell that bot's scripts to update. + private noteOutdatedClient(revision: number): void { + try { + World.loginBuf.pos += 1 + 9 * 4; // info, archive crcs + World.loginBuf.rsadec(priv); + if (World.loginBuf.g1() !== 10) { + return; + } + World.loginBuf.pos += 4 * 4 + 4; // isaac seed, uid + const username = World.loginBuf.gjstr(); + if (username.length < 1 || username.length > 12) { + return; + } + const name = toSafeName(username); + if (!this.outdatedClients.has(name)) { + printInfo(`Refused login for ${name}: client revision ${revision}, server ${Environment.engine.revision}`); + } + this.outdatedClients.set(name, { revision, at: Date.now() }); + } catch { + // unreadable login block: nothing to record + } + } + rebootTimer(duration: number): void { this.shutdownTick = this.currentTick + duration; @@ -2462,6 +2489,7 @@ class World { rev = World.loginBuf.g2(); } if (rev !== Environment.engine.revision) { + this.noteOutdatedClient(rev); client.send(Uint8Array.from([6])); client.close(); return; @@ -2503,6 +2531,7 @@ class World { const uid = World.loginBuf.g4s(); const username = World.loginBuf.gjstr(); const password = World.loginBuf.gjstr(); + this.outdatedClients.delete(toSafeName(username)); if (Environment.node.production && Environment.node.rateLimitDeviceLogin > 0) { const last = this.loginDeviceAttempts.get(`${uid}@${client.remoteAddress}`); diff --git a/server/engine/src/web/index.ts b/server/engine/src/web/index.ts index ee357634e..ceece0c01 100644 --- a/server/engine/src/web/index.ts +++ b/server/engine/src/web/index.ts @@ -2,12 +2,13 @@ import { handleMarket } from './pages/market.js'; import { register } from 'prom-client'; import Environment from '#/util/Environment.js'; import World from '#/engine/World.js'; +import { toSafeName } from '#/util/JString.js'; import { handleClientPage, handleCacheEndpoints } from './pages/client.js'; import { handleHiscoresPage, handleHiscoresPlayerPage, handleHiscoresOutfitPage, handleHiscoresBankPage, handleHiscoresRunitePage } from './pages/hiscores.js'; import { handleViewerAssets } from './hiscoresServer.js'; import { handleSpriteRequest } from './sprites/SpriteRenderer.js'; import { handleScreenshotsListPage, handleScreenshotFilePage } from './pages/screenshots.js'; -import { handleScreenshotUpload, handleExportCollisionApi } from './pages/api.js'; +import { handleScreenshotUpload, handleExportCollisionApi, handleVersionApi } from './pages/api.js'; import { handleBugReport } from './pages/bug-report.js'; import { handleDisclaimerPage, handleMapviewPage, handlePublicFiles } from './pages/static.js'; import { WebSocketData, handleWebSocketUpgrade, handleGatewayEndpointGet, websocketHandlers } from './websocket.js'; @@ -294,6 +295,9 @@ async function handleRequest(req: Request, server: Bun.Server, url: URL): Promis const exportCollisionResponse = handleExportCollisionApi(req, url); if (exportCollisionResponse) return exportCollisionResponse; + const versionResponse = handleVersionApi(url); + if (versionResponse) return versionResponse; + // Hiscores const hiscoresResponse = await handleHiscoresPage(url); if (hiscoresResponse) return hiscoresResponse; @@ -348,6 +352,13 @@ export async function startManagementWeb() { }), // rolling per-phase cycle timings over the last ~300 ticks '/tickstats': () => Response.json({ ...World.getTickStats(), web: getWebStats() }), + // rs-sdk: the gateway asks whether a bot's recent login was refused for an old client + // revision, so it can tell that bot's SDK scripts to update (management port only) + '/outdated-client': (req: Request) => { + const username = new URL(req.url).searchParams.get('username') ?? ''; + const outdated = username ? (World.outdatedClients.get(toSafeName(username)) ?? null) : null; + return Response.json({ serverRevision: Environment.engine.revision, outdated }); + }, // sample the main thread with JSC's sampling profiler for ?ms= (default 3000, max 15000) // at ?interval= microseconds (default 250). Covers everything on the event loop: // world cycles, websocket I/O callbacks, timers. diff --git a/server/engine/src/web/pages/api.ts b/server/engine/src/web/pages/api.ts index e45b69fb0..121ee7a24 100644 --- a/server/engine/src/web/pages/api.ts +++ b/server/engine/src/web/pages/api.ts @@ -8,6 +8,7 @@ import * as rsmod from '#/engine/routefinder/index.js'; import { CollisionFlag, LocLayer } from '#/engine/routefinder/index.js'; import LocType from '#/cache/config/LocType.js'; import Packet from '#/io/Packet.js'; +import Environment from '#/util/Environment.js'; export async function handleScreenshotUpload(req: Request, url: URL): Promise { if (url.pathname !== '/api/screenshot' || req.method !== 'POST') { @@ -38,6 +39,14 @@ export async function handleScreenshotUpload(req: Request, url: URL): Promise { + try { + const res = await fetch(`${ENGINE_MANAGEMENT_URL}/outdated-client?username=${encodeURIComponent(username)}`, { signal: AbortSignal.timeout(1000) }); + if (!res.ok) return null; + const body = await res.json() as { serverRevision?: number; outdated?: { revision: number } | null }; + if (!body.outdated || typeof body.serverRevision !== 'number') return null; + return { revision: body.outdated.revision, serverRevision: body.serverRevision }; + } catch { + return null; // no engine on this host (private fleet gateway), or it's busy: skip the hint + } +} + +function outdatedClientMessage(username: string, outdated: OutdatedClient): string { + return `Bot "${username}" can't log in: its headless client is revision ${outdated.revision}, but the server now runs revision ${outdated.serverRevision}. ` + + `Update your rs-sdk checkout (git pull, then cd server/webclient && bun install) and restart the client: ` + + `cd server/webclient && bun src/lite/runner.ts ${username}. Browser bots (the /bot page) update by reloading.`; +} + // ============ Types ============ /** @@ -501,8 +528,14 @@ const SyncModule = { // Authenticate via login server (if enabled) sdkConnecting.add(ws); let authResult: { success: boolean; error?: string }; + let outdated: OutdatedClient | null = null; try { authResult = await authenticateSDK(targetUsername, message.password || ''); + // A bot with no client attached may be stuck on an old client revision + const bot = botSessions.get(targetUsername); + if (authResult.success && (!bot || getSessionStatus(bot) === 'dead')) { + outdated = await lookupOutdatedClient(targetUsername); + } } finally { sdkConnecting.delete(ws); } @@ -516,6 +549,16 @@ const SyncModule = { return; } + if (outdated) { + console.log(`[Gateway] SDK connect refused: ${targetUsername}'s client is revision ${outdated.revision} (server ${outdated.serverRevision})`); + ws.send(JSON.stringify({ + type: 'sdk_error', + error: outdatedClientMessage(targetUsername, outdated) + })); + ws.close(); + return; + } + const sessionId = newSDKSessionId(); const session: SDKSession = { ws, sessionId, sdkClientId, targetUsername, mode }; sdkSessions.set(sessionId, session); diff --git a/server/webclient/src/lite/net/GameConnection.ts b/server/webclient/src/lite/net/GameConnection.ts index d320ed143..c7ee26f66 100644 --- a/server/webclient/src/lite/net/GameConnection.ts +++ b/server/webclient/src/lite/net/GameConnection.ts @@ -10,7 +10,7 @@ import Isaac from '#/io/Isaac.js'; import JString from '#/datastruct/JString.js'; import Packet from '#/io/Packet.js'; -const CLIENT_VERSION = 289; +export const CLIENT_VERSION = 289; /** Defaults match webclient/bundle.ts, which bakes these in at build time. */ const DEFAULT_RSA_MODULUS = @@ -61,6 +61,25 @@ export class LoginError extends Error { } } +/** + * The server runs a different game revision than this checkout: retrying can't help. A login + * response 6 ("client out of date") with the fix spelled out, so existing LoginError handling + * still applies. + */ +export class OutdatedClientError extends LoginError { + constructor( + readonly origin: string, + readonly serverRevision: number | null + ) { + super( + 6, + `This lite client is revision ${CLIENT_VERSION}, but ${origin} runs ${serverRevision === null ? 'a different revision' : `revision ${serverRevision}`}. ` + + 'The game server was upgraded: update this checkout (git pull, then cd server/webclient && bun install) and start the client again.' + ); + this.name = 'OutdatedClientError'; + } +} + /** * A logged-in game socket: the two ISAAC-keyed packet buffers plus the raw * stream. Packet framing is the caller's job (see PacketReader) because the diff --git a/server/webclient/src/lite/runner.ts b/server/webclient/src/lite/runner.ts index 9dbb0c95c..daba508f8 100644 --- a/server/webclient/src/lite/runner.ts +++ b/server/webclient/src/lite/runner.ts @@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'; +import { OutdatedClientError } from './net/GameConnection.js'; import { startSession, type LiteSession } from './session.js'; import { type ActionResult } from '#/bot/ActionExecutor.js'; import { BotActionQueue, type QueuedBotAction } from '#/bot/ActionQueue.js'; @@ -404,6 +405,13 @@ const session = await startSession({ password: env.PASSWORD!, profanityFilter: ['false', '0', 'off', 'no'].includes(profanityRaw) ? false : undefined, quiet: true +}).catch((err: unknown) => { + // A revision mismatch won't fix itself: exit 3 so a supervisor can stop restarting us + if (err instanceof OutdatedClientError) { + console.error(`[lite-runner] ${err.message}`); + process.exit(3); + } + throw err; }); console.log(`[lite-runner] '${env.BOT_USERNAME}' logged into ${host}`); diff --git a/server/webclient/src/lite/session.ts b/server/webclient/src/lite/session.ts index cdd3bf7ce..88ba9afc1 100644 --- a/server/webclient/src/lite/session.ts +++ b/server/webclient/src/lite/session.ts @@ -15,7 +15,7 @@ import './dom-shim.js'; import { invalidateCache, loadCache } from './cache.js'; -import { GameConnection, LoginError } from './net/GameConnection.js'; +import { CLIENT_VERSION, GameConnection, LoginError, OutdatedClientError } from './net/GameConnection.js'; import { LiteClient } from './LiteClient.js'; import { LocIndex } from './world/LocIndex.js'; import { ClientProt } from '#/io/ClientProt.js'; @@ -83,6 +83,20 @@ export interface LiteSession { stopped: Promise; } +/** The server's game revision from /api/version, or null if it doesn't say (older server, network). */ +async function fetchServerRevision(origin: string): Promise { + try { + const res = await fetch(`${origin}/api/version`, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) { + return null; + } + const body = (await res.json()) as { revision?: unknown }; + return typeof body.revision === 'number' ? body.revision : null; + } catch { + return null; + } +} + export async function startSession(opts: SessionOptions): Promise { if (opts.profanityFilter !== undefined) { WordFilter.enabled = opts.profanityFilter; @@ -91,6 +105,13 @@ export async function startSession(opts: SessionOptions): Promise { const origin = `${secured ? 'https' : 'http'}://${opts.host}`; const cacheDir = opts.cacheDir ?? `${process.env.HOME}/.cache/rs-sdk-lite`; + // Fail with the fix, not a decode error or an opaque login response 6, when the server + // has moved to another revision. Servers without /api/version skip the check. + const serverRevision = await fetchServerRevision(origin); + if (serverRevision !== null && serverRevision !== CLIENT_VERSION) { + throw new OutdatedClientError(origin, serverRevision); + } + let { checksums } = await loadCache({ origin, cacheDir, members: opts.members, quiet: opts.quiet }); let locIndex = await LocIndex.load({ origin, cacheDir, versionlistCrc: checksums[5], quiet: opts.quiet }); @@ -118,7 +139,9 @@ export async function startSession(opts: SessionOptions): Promise { invalidateCache(); ({ checksums } = await loadCache({ origin, cacheDir, members: opts.members, quiet: opts.quiet })); if (checksums.every((crc, i) => crc === stale[i])) { - throw err; + // still refused with fresh CRCs: the revision is the likely cause unless the + // server confirmed it matches + throw serverRevision === null ? new OutdatedClientError(origin, null) : err; } console.error(`[lite] ${opts.username}: stale archive CRCs after server redeploy, re-fetched /crc and retrying login`); locIndex = await LocIndex.load({ origin, cacheDir, versionlistCrc: checksums[5], quiet: opts.quiet }); diff --git a/server/webclient/src/lite/swarm.ts b/server/webclient/src/lite/swarm.ts index 079f253ed..3b8e78c66 100644 --- a/server/webclient/src/lite/swarm.ts +++ b/server/webclient/src/lite/swarm.ts @@ -28,6 +28,7 @@ import { fileURLToPath } from 'node:url'; +import { OutdatedClientError } from './net/GameConnection.js'; import { startSession, type LiteSession, type SessionEnd } from './session.js'; import { BotStateCollector } from '#/bot/StateCollector.js'; import { ActionExecutor } from '#/bot/ActionExecutor.js'; @@ -386,6 +387,14 @@ function onSessionEnd(bot: PickpocketBot, end: SessionEnd): void { void relogin(bot); } +/** Every bot would hit the same revision mismatch, and retrying can't fix it. */ +function exitIfOutdated(e: unknown): void { + if (e instanceof OutdatedClientError) { + console.error(`[swarm] ${e.message}`); + process.exit(3); + } +} + /** Retry forever: the world may be restarting, and a bot is no use logged out. */ async function relogin(bot: PickpocketBot): Promise { let delay = RELOGIN_MS; @@ -400,6 +409,7 @@ async function relogin(bot: PickpocketBot): Promise { console.log(`[swarm] ${bot.name} back online (${bot.stats.relogins} re-logins so far)`); return; } catch (e) { + exitIfOutdated(e); console.error(`[swarm] ${bot.name} re-login failed (${e}); retrying in ${Math.round(delay / 1000)}s`); delay = Math.min(delay * 2, RELOGIN_MAX_MS); } @@ -414,6 +424,7 @@ for (const name of names) { await login(bot); console.log(`[swarm] ${name} logged in${bot.isCollector ? ' (COLLECTOR)' : ''}`); } catch (e) { + exitIfOutdated(e); console.error(`[swarm] ${name} failed to log in: ${e}`); void relogin(bot); // keep trying in the background rather than losing the bot }