mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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>
This commit is contained in:
1 parent
3b2be2bc2d
commit
93cd70b347
10 files changed
+171
-4
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -201,6 +201,9 @@ class World {
|
||||
|
||||
loginAddressAttempts: TTLCache<string, number> = new TTLCache({ ttl: 60000 });
|
||||
loginDeviceAttempts: TTLCache<string, number> = 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<string, { revision: number; at: number }> = 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}`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Response | null> {
|
||||
if (url.pathname !== '/api/screenshot' || req.method !== 'POST') {
|
||||
@@ -38,6 +39,14 @@ export async function handleScreenshotUpload(req: Request, url: URL): Promise<Re
|
||||
// naturally invalidates the cache.
|
||||
let collisionExportCache: string | null = null;
|
||||
|
||||
// rs-sdk: lets clients outside the browser (the lite runner) check they speak this server's revision
|
||||
export function handleVersionApi(url: URL): Response | null {
|
||||
if (url.pathname !== '/api/version') {
|
||||
return null;
|
||||
}
|
||||
return Response.json({ revision: Environment.engine.revision });
|
||||
}
|
||||
|
||||
export function handleExportCollisionApi(req: Request, url: URL): Response | null {
|
||||
if (url.pathname !== '/api/exportCollision') {
|
||||
return null;
|
||||
|
||||
@@ -133,6 +133,33 @@ async function authenticateSDK(username: string, password: string): Promise<{ su
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Outdated Client Check ============
|
||||
|
||||
// The engine records logins it refused for an old client revision, on its management port (not
|
||||
// published). When an SDK connects to a bot with no client attached, ask whether that's why, so
|
||||
// scripts on an old checkout are told to update instead of waiting on a client that can't log in.
|
||||
const ENGINE_MANAGEMENT_URL = process.env.ENGINE_MANAGEMENT_URL || `http://127.0.0.1:${process.env.WEB_MANAGEMENT_PORT || 8898}`;
|
||||
|
||||
type OutdatedClient = { revision: number; serverRevision: number };
|
||||
|
||||
async function lookupOutdatedClient(username: string): Promise<OutdatedClient | null> {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<SessionEnd>;
|
||||
}
|
||||
|
||||
/** The server's game revision from /api/version, or null if it doesn't say (older server, network). */
|
||||
async function fetchServerRevision(origin: string): Promise<number | null> {
|
||||
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<LiteSession> {
|
||||
if (opts.profanityFilter !== undefined) {
|
||||
WordFilter.enabled = opts.profanityFilter;
|
||||
@@ -91,6 +105,13 @@ export async function startSession(opts: SessionOptions): Promise<LiteSession> {
|
||||
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<LiteSession> {
|
||||
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 });
|
||||
|
||||
@@ -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<void> {
|
||||
let delay = RELOGIN_MS;
|
||||
@@ -400,6 +409,7 @@ async function relogin(bot: PickpocketBot): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user