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>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-12 16:47:04 -04:00
1 parent fa3f4bd8e5
commit 9afd6d9613
6 files changed
+329 -14

No files matched your search

+5 -1
View File
@@ -246,7 +246,11 @@ export class BotActions {
if (!state) break;
if (state.dialog.isOpen) {
await this.sdk.sendClickDialog(0);
// Click the server-assigned index of the first published option
// ("Click here to continue" arrives at index 1, not 0). A bare
// 0 is only a fallback for optionless dialogs mid-transition.
const first = state.dialog.options[0];
await this.sdk.sendClickDialog(first?.index ?? 0);
await this.sdk.waitForStateChange(2000).catch(() => {});
continue;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Logic test for bot.dismissBlockingUI() — no bot required.
*
* Regression for the harvested report msqgybjn: the helper used to hardcode
* sendClickDialog(0), but "Click here to continue" is published at the
* server-assigned index 1, so every dismiss attempt was rejected and any
* continue-dialog silently wedged all subsequent BotActions calls.
*/
import { describe, expect, test } from 'bun:test';
import { BotSDK } from '../index';
import { BotActions } from '../actions';
import type { ActionResult, BotWorldState, DialogOption } from '../types';
function makeOption(index: number, text: string): DialogOption {
return { index, text, componentId: 6224, buttonType: 6 };
}
function makeState(opts: { dialogOpen: boolean; options?: DialogOption[] }): BotWorldState {
// Only the dialog field matters — dismissBlockingUI reads dialog first and
// the stub closes it after one click, so interface/shop/bank never gate.
return {
dialog: {
isOpen: opts.dialogOpen,
options: opts.options ?? [],
isWaiting: false,
},
} as unknown as BotWorldState;
}
function mount(states: BotWorldState[]): { bot: BotActions; calls: number[] } {
const sdk = new BotSDK({ botUsername: 'test' });
// Each getState() call consumes the next snapshot; the last one repeats.
let i = 0;
(sdk as any).getState = () => states[Math.min(i, states.length - 1)];
const calls: number[] = [];
(sdk as any).sendClickDialog = async (option: number): Promise<ActionResult> => {
calls.push(option);
i++; // clicking advances to the next snapshot
return { success: true, message: 'stub-clicked' };
};
(sdk as any).waitForStateChange = async () => states[Math.min(i, states.length - 1)];
return { bot: new BotActions(sdk), calls };
}
describe('dismissBlockingUI()', () => {
test('clicks the server-assigned index, not array position 0', async () => {
const { bot, calls } = mount([
makeState({ dialogOpen: true, options: [makeOption(1, 'Click here to continue')] }),
makeState({ dialogOpen: false }),
]);
await bot.dismissBlockingUI();
expect(calls).toEqual([1]);
});
test('falls back to 0 for an optionless dialog mid-transition', async () => {
const { bot, calls } = mount([
makeState({ dialogOpen: true, options: [] }),
makeState({ dialogOpen: false }),
]);
await bot.dismissBlockingUI();
expect(calls).toEqual([0]);
});
test('does not click when no dialog is open', async () => {
const { bot, calls } = mount([makeState({ dialogOpen: false })]);
await bot.dismissBlockingUI();
expect(calls).toEqual([]);
});
test('clicks each page of a multi-page dialog at its published index', async () => {
const { bot, calls } = mount([
makeState({ dialogOpen: true, options: [makeOption(1, 'Click here to continue')] }),
makeState({ dialogOpen: true, options: [makeOption(2, 'Click here to continue')] }),
makeState({ dialogOpen: false }),
]);
await bot.dismissBlockingUI();
expect(calls).toEqual([1, 2]);
});
});
+14 -1
View File
@@ -32,7 +32,7 @@ import VarBitType from '#/config/VarBitType.js';
import VarpType from '#/config/VarpType.js';
import WordFilter from '#/wordfilter/WordFilter.js';
import { captureBaseInterfaces } from './interfaces.js';
import { captureBaseInterfaces, resetBaseInterfaces } from './interfaces.js';
/** Archives the lite client actually unpacks, as [filename, crc index]. */
const REQUIRED_ARCHIVES: Array<[string, number]> = [
@@ -63,6 +63,19 @@ export interface LoadedCache {
let loaded: LoadedCache | null = null;
/**
* Forget the memoized cache so the next loadCache() re-fetches /crc and
* re-unpacks any archive whose CRC changed. Needed after a server content
* redeploy: a long-lived process otherwise presents its boot-time CRCs on
* every reconnect and gets login response 6 forever. Existing clients keep
* their (copy-on-write) interface tables; only clients created after the
* reload see the new baseline.
*/
export function invalidateCache(): void {
loaded = null;
resetBaseInterfaces();
}
/**
* Download + unpack the config archives and initialise the shared config type
* registries. Idempotent: the config types are module-level singletons, so a
@@ -0,0 +1,184 @@
// A long-lived process must survive a server content redeploy.
//
// loadCache() memoizes the /crc checksums for the process lifetime, so before
// invalidateCache() existed, a shard that outlived a redeploy presented its
// boot-time CRCs on every reconnect and the engine answered login response 6
// (out of date) forever - recoverable only by restarting the process. This test
// drives the real startSession path against a mock game server: first login
// gets response 6, the session must re-fetch /crc once and retry with the new
// checksums, and must NOT retry when the re-fetched checksums are unchanged
// (then the mismatch is a genuinely stale build, not a redeploy).
//
// Needs the config archives; set LITE_TEST_ORIGIN to point elsewhere. Skips if
// the server is unreachable.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import './dom-shim.js';
import { invalidateCache, loadCache } from './cache.js';
import { LoginError } from './net/GameConnection.js';
import { startSession } from './session.js';
import { LocIndex } from './world/LocIndex.js';
const ORIGIN = process.env.LITE_TEST_ORIGIN ?? 'https://rs-sdk-demo.fly.dev';
const CACHE_DIR = process.env.LITE_TEST_CACHE ?? '/tmp/rs-sdk-lite-test-cache';
let available = false;
let realChecksums: number[] = [];
beforeAll(async () => {
try {
// Warm the disk caches from the real origin so the mock server below
// only ever has to serve /crc and the login handshake: the archive and
// locindex fetches are keyed by CRC and will hit disk.
const { checksums } = await loadCache({ origin: ORIGIN, cacheDir: CACHE_DIR, quiet: true });
realChecksums = checksums;
await LocIndex.load({ origin: ORIGIN, cacheDir: CACHE_DIR, versionlistCrc: checksums[5], quiet: true });
available = true;
} catch {
available = false;
}
});
afterAll(() => {
// Don't leak the mock's checksums into later test files in this process.
invalidateCache();
});
/** /crc body: 9 big-endian CRCs plus the rolling trailer fetchChecksums validates. */
function crcPayload(checksums: number[]): Uint8Array {
const view = new DataView(new ArrayBuffer(40));
let trailer = 1234;
for (let i = 0; i < 9; i++) {
view.setInt32(i * 4, checksums[i]);
trailer = ((trailer << 1) + checksums[i]) | 0;
}
view.setInt32(36, trailer);
return new Uint8Array(view.buffer);
}
interface MockState {
crcFetches: number;
/** The 9 CRCs presented in each login packet, in attempt order. */
loginAttempts: number[][];
}
/**
* A game server that always answers login response 6. Speaks just enough of the
* protocol to reach the login packet: stage-1 ack + seed, then parse the
* presented CRCs (offset 6: after opcode, length, 255, version, lowmem) and
* reject.
*/
function startMockServer(servedChecksums: number[]): { port: number; state: MockState; stop(): void } {
const state: MockState = { crcFetches: 0, loginAttempts: [] };
const server = Bun.serve<{ buf: number[]; stage: number }, object>({
port: 0,
fetch(req, srv) {
const url = new URL(req.url);
if (url.pathname === '/crc') {
state.crcFetches++;
return new Response(crcPayload(servedChecksums));
}
if (srv.upgrade(req, { data: { buf: [], stage: 0 }, headers: { 'Sec-WebSocket-Protocol': 'binary' } })) {
return;
}
return new Response('not found', { status: 404 });
},
websocket: {
message(ws, msg): void {
if (typeof msg === 'string') {
return;
}
ws.data.buf.push(...new Uint8Array(msg));
if (ws.data.stage === 0 && ws.data.buf.length >= 2) {
// Stage 1 request consumed: 8 ignored bytes, status 0, 8-byte seed.
ws.data.buf.splice(0, 2);
ws.data.stage = 1;
ws.send(new Uint8Array(17));
} else if (ws.data.stage === 1 && ws.data.buf.length >= 2 && ws.data.buf.length >= 2 + ws.data.buf[1]) {
const b = ws.data.buf;
const crcs: number[] = [];
for (let i = 0; i < 9; i++) {
const o = 6 + i * 4;
crcs.push(((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) | 0);
}
state.loginAttempts.push(crcs);
ws.data.stage = 2;
ws.send(new Uint8Array([6]));
}
}
}
});
return {
port: server.port!,
state,
stop: () => server.stop(true)
};
}
function trySession(port: number): Promise<unknown> {
return startSession({
host: `localhost:${port}`,
username: 'crctest',
password: 'x',
cacheDir: CACHE_DIR,
quiet: true
}).then(
() => null,
err => err
);
}
/** What the server's CRCs become after the simulated redeploy. */
let redeployed: number[] = [];
describe('startSession recovery from login response 6', () => {
test('re-fetches /crc and retries with the new checksums', async () => {
if (!available) {
return;
}
// One archive changed on the server; the three we unpack (and the
// versionlist) did not, so every disk cache still hits.
redeployed = [...realChecksums];
redeployed[0] = (realChecksums[0] ^ 0x5eed) | 0;
const mock = startMockServer(redeployed);
try {
const err = await trySession(mock.port);
// Both attempts rejected, so the session still fails - but it must
// have re-fetched /crc and presented the new CRCs the second time.
expect(err).toBeInstanceOf(LoginError);
expect((err as LoginError).code).toBe(6);
expect(mock.state.crcFetches).toBe(1);
expect(mock.state.loginAttempts).toEqual([realChecksums, redeployed]);
} finally {
mock.stop();
}
});
test('does not retry when the re-fetched checksums are unchanged', async () => {
if (!available) {
return;
}
// The previous test's reload memoized `redeployed`; serving the same
// set again means the response-6 is a stale build, not a redeploy.
const mock = startMockServer(redeployed);
try {
const err = await trySession(mock.port);
expect(err).toBeInstanceOf(LoginError);
expect((err as LoginError).code).toBe(6);
expect(mock.state.crcFetches).toBe(1);
expect(mock.state.loginAttempts).toEqual([redeployed]);
} finally {
mock.stop();
}
});
});
+5 -1
View File
@@ -37,7 +37,11 @@ export function captureBaseInterfaces(): void {
}
}
/** Test seam: forget the captured baseline so a suite can re-init the cache. */
/**
* Forget the captured baseline so the next loadCache() recaptures it — used by
* invalidateCache() after a server content redeploy, and by test suites that
* re-init the cache. Existing InterfaceTables keep their old lists.
*/
export function resetBaseInterfaces(): void {
base = null;
}
+34 -11
View File
@@ -14,8 +14,8 @@
import './dom-shim.js';
import { loadCache } from './cache.js';
import { GameConnection } from './net/GameConnection.js';
import { invalidateCache, loadCache } from './cache.js';
import { GameConnection, LoginError } from './net/GameConnection.js';
import { LiteClient } from './LiteClient.js';
import { LocIndex } from './world/LocIndex.js';
import { ClientProt } from '#/io/ClientProt.js';
@@ -91,16 +91,39 @@ 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`;
const { checksums } = await loadCache({ origin, cacheDir, members: opts.members, quiet: opts.quiet });
const locIndex = await LocIndex.load({ origin, cacheDir, versionlistCrc: checksums[5], quiet: opts.quiet });
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 });
const connection = await GameConnection.connect({
host: opts.host,
secured,
username: opts.username,
password: opts.password,
checksums
});
const connect = () =>
GameConnection.connect({
host: opts.host,
secured,
username: opts.username,
password: opts.password,
checksums
});
let connection: GameConnection;
try {
connection = await connect();
} catch (err) {
// Login response 6 from a long-lived process usually means the server's
// content was redeployed under us and the memoized CRCs are stale, not
// that this build is actually out of date. Re-fetch /crc once and retry;
// if the checksums come back unchanged the mismatch is real, so rethrow.
if (!(err instanceof LoginError) || err.code !== 6) {
throw err;
}
const stale = checksums;
invalidateCache();
({ checksums } = await loadCache({ origin, cacheDir, members: opts.members, quiet: opts.quiet }));
if (checksums.every((crc, i) => crc === stale[i])) {
throw 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 });
connection = await connect();
}
const client = new LiteClient({
connection,