fix(sdk,engine,webclient): tradeoff-free fixes from the 2026-09-01 bug-report harvest

Engine
- World.processLogins takeover: closeModal() before save(), so items parked in
  temp invs (tradeoffer/dueloffer/duelwinnings/partyroom_tempinv) return to the
  pack before the snapshot. Logging in from a second client mid-trade deleted
  the offer and left the partner's screen stuck; reproduced locally before and
  after.
- Web server proxies /state/:bot, /chat/:bot, POST /say/:bot, POST /dm/:bot to
  the gateway (cli.ts / chat.ts HTTP fast paths 404'd on deployments that
  front the gateway through the engine web port).

SDK
- interactNpc/useItemOnNpc/talk path re-find an entity target by server index
  after walking (ActionHelpers.refindNpc) instead of by name, so a chosen
  fishing spot is not swapped for the nearest same-named one.
- runner: log a failed initial connect and tear down the orphan BotSDK (its
  reconnect timers kept the process alive as a zombie); connect race 30s -> 90s
  to cover a cold browser launch; browserLaunchTimeout default 30000 as documented.
- mcp/api: bot.env repo-root fallback was one directory short.
- cli.ts honours GATEWAY_URL from bot.env like the runner does.
- shortestNameMatch falls back to a substring match when a literal name with
  regex metacharacters ("Serum 207 (1)") matches nothing as a regex (+test).
- eatFood accepts Drink; openBank skips "Closed bank booth" and prefers a booth
  with a Bank/Use op; attack re-finds a bare {index} handle instead of throwing;
  depositItem(number) returns an invalid-target result instead of a TypeError.
- ActionDispatchError sets its name via defineProperty (hosts that freeze
  Error.prototype made the assignment throw from ws.onclose).

Webclient
- Client.say() routes '::' to CLIENT_CHEAT like the keyboard path and the lite
  client, instead of speaking cheats aloud as public chat.
- Client.isBankOpen() returns false when no main modal is open (component 2006
  linkObjType is never cleared, so state.bank.isOpen stuck true all session).
- acceptCharacterDesign (lite + browser) prefers the CC_ACCEPT_DESIGN button
  inside the open modal; other kits carry the same clientcode with no trigger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5.1 committed 2026-09-02 13:25:33 -04:00
1 parent 2b89920c70
commit db1f30e1c1
12 files changed
+234 -42

No files matched your search

+3 -2
View File
@@ -49,9 +49,10 @@ class BotManager {
// Load credentials from bot.env if no password provided
if (!password) {
// Try cwd first, then fall back to the repo root (one level up from mcp/)
// Try cwd first, then fall back to the repo root (this file lives in mcp/api/,
// so the root is two levels up)
const cwdPath = join(process.cwd(), 'bots', name, 'bot.env');
const repoPath = join(import.meta.dir, '..', 'bots', name, 'bot.env');
const repoPath = join(import.meta.dir, '..', '..', 'bots', name, 'bot.env');
const envPath = existsSync(cwdPath) ? cwdPath : repoPath;
if (!existsSync(envPath)) {
+17 -9
View File
@@ -61,16 +61,24 @@ export function shortestNameMatch<T extends { name: string; reachable?: boolean
);
}
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
let best: T | null = null;
let bestReachable: T | null = null;
for (const item of items) {
if (!testPattern(regex, item.name)) continue;
if (!best || item.name.length < best.name.length) best = item;
if (item.reachable !== false && (!bestReachable || item.name.length < bestReachable.name.length)) {
bestReachable = item;
const pick = (matches: (name: string) => boolean): T | null => {
let best: T | null = null;
let bestReachable: T | null = null;
for (const item of items) {
if (!matches(item.name)) continue;
if (!best || item.name.length < best.name.length) best = item;
if (item.reachable !== false && (!bestReachable || item.name.length < bestReachable.name.length)) {
bestReachable = item;
}
}
}
return bestReachable ?? best;
return bestReachable ?? best;
};
const found = pick(name => testPattern(regex, name));
if (found || typeof pattern !== 'string' || !/[.*+?^${}()|[\]\\]/.test(pattern)) return found;
// A literal item name with regex metacharacters ("Serum 207 (1)",
// "Cake (1/3)") compiles to a pattern that matches nothing - fall back to a
// plain case-insensitive substring match before giving up.
return pick(name => testPattern(pattern, name));
}
/** Anchored, escaped pattern matching exactly `name` (case-insensitive). */
+36 -1
View File
@@ -579,6 +579,30 @@ export class ActionHelpers {
return this.sdk.findNearbyNpc(target);
}
/**
* Re-find an NPC the caller already chose, by its server slot `index`.
*
* The index is the entity handle interact packets address and it survives
* the npc walking (or a fishing spot relocating). Re-resolving by name
* instead hands the action to whichever same-named neighbour is nearest on
* arrival - the wrong fishing spot, the wrong "Man". Returns null when the
* slot is no longer in view.
*/
refindNpc(original: { index: number }): NearbyNpc | null {
return this.sdk.getState()?.nearbyNpcs.find(n => n.index === original.index) ?? null;
}
/**
* After walking, re-resolve an npc target: a pattern re-finds by pattern
* (keeping the caller's anchors), an entity re-finds by identity.
*/
refindNpcTarget(target: NearbyNpc | string | RegExp, resolved: NearbyNpc): NearbyNpc | null {
if (typeof target === 'object' && 'index' in target) {
return this.refindNpc(resolved);
}
return this.resolveNpc(target);
}
/**
* Resolve a combat target that may be an npc or another player.
*
@@ -589,7 +613,18 @@ export class ActionHelpers {
*/
resolveCombatTarget(target: CombatTarget): NearbyNpc | NearbyPlayer | null {
if (typeof target === 'object' && 'index' in target) {
return target;
if (Array.isArray((target as { optionsWithIndex?: unknown }).optionsWithIndex)) {
return target;
}
// A bare {index, name} handle (kept across ticks by a re-attack
// loop) is not a live entry: reading .optionsWithIndex off it used
// to throw and silently stop the fight loop. Re-find the live entry
// by its server slot instead.
const state = this.sdk.getState();
const kind = (target as { kind?: string }).kind;
const npc = kind === 'player' ? null : state?.nearbyNpcs.find(n => n.index === target.index) ?? null;
const player = kind === 'npc' ? null : state?.nearbyPlayers.find(p => p.index === target.index) ?? null;
return npc ?? player ?? null;
}
return this.sdk.findNearbyNpc(target) ?? this.sdk.findNearbyPlayer(target);
}
+35 -20
View File
@@ -578,9 +578,9 @@ export class BotActions {
}
}
// Re-find the NPC after walking (it may have moved)
const npcPattern = typeof npc === 'object' && 'index' in npc ? new RegExp(resolvedNpc.name, 'i') : npc;
const npcNow = this.helpers.resolveNpc(npcPattern);
// Re-find the NPC after walking (it may have moved). An entity target
// re-finds by server index so a same-named neighbour cannot win.
const npcNow = this.helpers.refindNpcTarget(npc, resolvedNpc);
if (!npcNow) {
return { success: false, message: `${resolvedNpc.name} no longer visible`, reason: 'npc_not_found' };
}
@@ -937,9 +937,9 @@ export class BotActions {
}
}
// Re-find the NPC after walking (it may have moved)
const npcPattern = typeof target === 'object' ? new RegExp(npc.name, 'i') : target;
const npcNow = this.helpers.resolveNpc(npcPattern);
// Re-find the NPC after walking (it may have moved). An entity target
// re-finds by server index so a same-named neighbour cannot win.
const npcNow = this.helpers.refindNpcTarget(target, npc);
if (!npcNow) {
return { success: false, message: `${npc.name} no longer visible` };
}
@@ -1635,11 +1635,22 @@ export class BotActions {
// ============ Porcelain: Bank Actions ============
/**
* Nearest bank booth/chest that can actually open the bank. 'Closed bank
* booth' (Fishing Guild, wedged between open booths) matches the name and
* may publish options but no bank op; if it wins on distance every click
* times out silently.
*/
private findUsableBankBooth(): NearbyLoc | null {
const booths = this.sdk.getNearbyLocs()
.filter(l => /bank booth|bank chest/i.test(l.name) && !/^closed/i.test(l.name) && l.optionsWithIndex.length > 0)
.sort((a, b) => a.distance - b.distance);
return booths.find(l => l.optionsWithIndex.some(o => /bank|use/i.test(o.text))) ?? booths[0] ?? null;
}
/** Open a bank booth or talk to a banker. */
async openBank(timeout: number = 10000): Promise<OpenBankResult> {
const bankBooth = this.sdk.getNearbyLocs()
.filter(l => /bank booth|bank chest/i.test(l.name) && l.optionsWithIndex.length > 0)
.sort((a, b) => a.distance - b.distance)[0] || null;
const bankBooth = this.findUsableBankBooth();
return this.helpers.withDoorRetry(
() => this._openBankOnce(timeout),
@@ -1659,9 +1670,7 @@ export class BotActions {
const banker = this.sdk.findNearbyNpc(/banker/i);
// Filter bank booths/chests to only those with usable options (excludes "Closed bank booth" etc.)
const bankBooth = this.sdk.getNearbyLocs()
.filter(l => /bank booth|bank chest/i.test(l.name) && l.optionsWithIndex.length > 0)
.sort((a, b) => a.distance - b.distance)[0] || null;
const bankBooth = this.findUsableBankBooth();
if (!banker && !bankBooth) {
return { success: false, message: 'No banker NPC or bank booth found nearby', reason: 'no_bank_found' };
@@ -1678,9 +1687,7 @@ export class BotActions {
}
// Re-find targets after walking (they may have changed)
const bankBoothNow = this.sdk.getNearbyLocs()
.filter(l => /bank booth|bank chest/i.test(l.name) && l.optionsWithIndex.length > 0)
.sort((a, b) => a.distance - b.distance)[0] || null;
const bankBoothNow = this.findUsableBankBooth();
const bankerNow = this.sdk.findNearbyNpc(/banker/i);
let interactSuccess = false;
@@ -1789,6 +1796,11 @@ export class BotActions {
* whichever matched first.
*/
async depositItem(target: InventoryItem | string | RegExp, amount: number = -1): Promise<BankDepositResult> {
if (typeof target !== 'string' && !(target instanceof RegExp) && !(typeof target === 'object' && target !== null && 'slot' in target)) {
// A bare slot number lands here from untyped callers and used to
// crash inside the name matcher with a readonly-property TypeError.
return { success: false, message: `depositItem target must be an item, name or pattern (got ${typeof target}); pass sdk.getInventory()[slot] for a slot`, reason: 'item_not_found' };
}
const validated = validateActionQuantity(amount, { allowAll: true, max: MAX_BANK_ACTION_QUANTITY });
if (!validated.valid) {
return { success: false, message: validated.message, reason: 'invalid_amount' };
@@ -2694,9 +2706,11 @@ export class BotActions {
return { success: false, hpGained: 0, message: `Food not found: ${target}` };
}
const eatOpt = food.optionsWithIndex.find(o => /eat/i.test(o.text));
// Drinkable healing items (Beer, wine, potions) publish 'Drink', not 'Eat'.
const eatOpt = food.optionsWithIndex.find(o => /^(eat|drink)$/i.test(o.text))
?? food.optionsWithIndex.find(o => /eat|drink/i.test(o.text));
if (!eatOpt) {
return { success: false, hpGained: 0, message: `No eat option on ${food.name}` };
return { success: false, hpGained: 0, message: `No eat/drink option on ${food.name}` };
}
const hpBefore = this.sdk.getSkill('Hitpoints')?.level ?? 10;
@@ -4064,9 +4078,10 @@ export class BotActions {
}
}
// Re-find the NPC after walking (it may have moved)
const npcPattern = typeof target === 'object' ? new RegExp(npc.name, 'i') : target;
const npcNow = this.helpers.resolveNpc(npcPattern);
// Re-find the NPC after walking (it may have moved). An entity target
// re-finds by server index so a same-named neighbour (the other kind of
// "Fishing spot") cannot win on distance.
const npcNow = this.helpers.refindNpcTarget(target, npc);
if (!npcNow) {
return { success: false, message: `${npc.name} no longer visible`, reason: 'npc_not_found' };
}
+6 -2
View File
@@ -144,7 +144,7 @@ To execute code on a bot, use the MCP execute_code tool instead.
/**
* Try to load credentials from bots/<name>/bot.env
*/
function tryLoadBotEnv(botName: string): { username: string; password: string; server?: string } | null {
function tryLoadBotEnv(botName: string): { username: string; password: string; server?: string; gatewayUrl?: string } | null {
const envPath = join(process.cwd(), 'bots', botName, 'bot.env');
if (!existsSync(envPath)) return null;
@@ -165,7 +165,8 @@ function tryLoadBotEnv(botName: string): { username: string; password: string; s
return {
username: env.BOT_USERNAME,
password: env.PASSWORD,
server: env.SERVER
server: env.SERVER,
gatewayUrl: env.GATEWAY_URL
};
}
@@ -183,6 +184,9 @@ async function fetchState(botName: string, flags: { server: string; timeout: num
username = botEnv.username;
password = botEnv.password;
if (botEnv.server && !server) server = botEnv.server;
// The runner loads the whole bot.env into process.env, so a GATEWAY_URL
// there reaches deriveGatewayUrl; give the CLI the same behaviour.
if (botEnv.gatewayUrl && !process.env.GATEWAY_URL) process.env.GATEWAY_URL = botEnv.gatewayUrl;
} else {
username = botName;
}
+8 -2
View File
@@ -104,7 +104,11 @@ interface PendingAction {
class ActionDispatchError extends Error {
constructor(message: string, readonly reason: 'timeout' | 'disconnected' | 'error') {
super(message);
this.name = 'ActionDispatchError';
// Own-property define rather than assignment: hosts that freeze
// Error.prototype make the inherited `name` non-writable and a plain
// assignment throws in strict mode (from ws.onclose, taking the process
// exit code with it).
Object.defineProperty(this, 'name', { value: 'ActionDispatchError', writable: true, configurable: true });
}
}
@@ -166,7 +170,7 @@ export class BotSDK {
autoLaunchBrowser: config.autoLaunchBrowser ?? 'auto',
freshDataThreshold: config.freshDataThreshold ?? 3000,
browserLaunchUrl: config.browserLaunchUrl || '',
browserLaunchTimeout: config.browserLaunchTimeout || 10000,
browserLaunchTimeout: config.browserLaunchTimeout || 30000,
readyTimeout: config.readyTimeout ?? 15000,
connectTimeout: config.connectTimeout ?? 30000,
actionTimeout: config.actionTimeout || 60000,
@@ -1561,6 +1565,8 @@ export class BotSDK {
/** Deposit item to bank by slot. */
async sendBankDeposit(slot: number, amount: number = 1): Promise<ActionResult> {
// Amounts other than 1/5/10/All dispatch the "X" option; the client
// executor waits for the server's count dialog and submits `amount`.
return this.sendAction({ type: 'bankDeposit', slot, amount, reason: 'SDK' });
}
+18 -2
View File
@@ -180,11 +180,26 @@ async function getOrCreateConnection(): Promise<BotConnection> {
const bot = new BotActions(sdk);
// Cover the browser-launch wait (up to browserLaunchTimeout) plus the
// handshake and ready waits that follow it, not just the handshake.
const CONNECT_TIMEOUT_MS = 90_000;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Connection timed out after 30s')), 30000);
timeoutHandle = setTimeout(() => reject(new Error(`Connection timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
});
await Promise.race([sdk.connect(), timeoutPromise]);
try {
await Promise.race([sdk.connect(), timeoutPromise]);
} catch (error) {
// The BotSDK was never registered in `connections`, so nothing else will
// ever tear it down - and with autoReconnect on, its reconnect timers
// would keep the process alive as a zombie holding the bot's control
// connection while runScript has already given up.
await sdk.disconnect().catch(() => {});
throw error;
} finally {
clearTimeout(timeoutHandle);
}
console.error(`[Runner] Connected to bot "${username}"`);
@@ -269,6 +284,7 @@ export async function runScript(
managedConnection = true;
}
} catch (error: any) {
console.error(`[Runner] Failed to connect: ${error?.message ?? error}`);
return {
success: false,
error,
+9
View File
@@ -161,6 +161,15 @@ describe('shortestNameMatch', () => {
expect(shortestNameMatch(locs, /^hopper$/i)?.name).toBe('Hopper');
expect(shortestNameMatch(locs, /furnace/i)).toBeNull();
});
test('a literal name with regex metacharacters still matches as a string', () => {
const items = [{ name: 'Serum 207 (1)' }, { name: 'Cake (1/3)' }, { name: 'Serum 207 (4)' }];
expect(shortestNameMatch(items, 'Serum 207 (1)')).toBe(items[0]!);
expect(shortestNameMatch(items, 'cake (1/3)')).toBe(items[1]!);
// A string that is a working regex keeps regex semantics.
expect(shortestNameMatch(items, '^cake')).toBe(items[1]!);
expect(shortestNameMatch(items, 'Serum 207 (9)')).toBeNull();
});
});
describe('exactNamePattern', () => {
+9
View File
@@ -968,6 +968,15 @@ class World {
}
other.addSessionLog(LoggerEventType.ENGINE, 'Kicked due to login from another session');
// Close any open modal BEFORE snapshotting. save() only serializes
// scope=perm invs, so anything parked in a temp inv behind an open
// interface (tradeoffer, dueloffer, duelwinnings, partyroom_tempinv,
// trawler_rewardinv) is deleted unless the interface's [if_close]
// return-to-inventory script runs first. The logout and reconnect
// paths both already do this; the takeover path did not, so logging
// in from a second client mid-trade silently destroyed the offer.
other.closeModal();
// Save the existing player's in-memory state before removing them
// This prevents losing progress when the new login was loaded from stale disk save
const existingSave = other.save();
+48
View File
@@ -4,6 +4,7 @@ import World from '#/engine/World.js';
import { handleClientPage, handleCacheEndpoints } from './pages/client.js';
import { handleHiscoresPage, handleHiscoresPlayerPage, handleHiscoresOutfitPage, handleHiscoresBankPage, handleHiscoresKothPage } 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 { handleBugReport } from './pages/bug-report.js';
@@ -188,6 +189,50 @@ async function handleRequest(req: Request, server: Bun.Server, url: URL): Promis
}
}
// Gateway per-bot HTTP endpoints (proxy to gateway, preserving method,
// query string and body). These are what sdk/cli.ts (GET /state/:bot) and
// sdk/chat.ts (GET /chat/:bot, POST /say/:bot, POST /dm/:bot) use as their
// fast path; without this allowlist they 404 on deployments that front the
// gateway through this web server.
const gatewayBotMatch = url.pathname.match(/^\/(state|chat|say|dm)\/([^/]+)\/?$/);
if (gatewayBotMatch) {
const [, route, username] = gatewayBotMatch;
const isPost = req.method === 'POST';
const expectsPost = route === 'say' || route === 'dm';
if (isPost !== expectsPost) {
return new Response(JSON.stringify({ error: `${route} expects ${expectsPost ? 'POST' : 'GET'}` }), {
status: 405,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
try {
const gatewayUrl = process.env.GATEWAY_URL || 'http://localhost:7780';
const target = `${gatewayUrl}/${route}/${username}${url.search}`;
const init: RequestInit = { method: req.method, signal: AbortSignal.timeout(15000) };
if (isPost) {
init.body = await req.text();
init.headers = { 'Content-Type': req.headers.get('content-type') || 'application/json' };
}
const response = await fetch(target, init);
const text = await response.text();
return new Response(text, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') || 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
return new Response(JSON.stringify({
error: `Failed to reach gateway /${route}`,
message: error instanceof Error ? error.message : 'Unknown error'
}, null, 2), {
status: 503,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
}
// Bot status endpoint (proxy to gateway)
const botStatusMatch = url.pathname.match(/^\/status\/([^/]+)\/?$/);
if (botStatusMatch) {
@@ -256,6 +301,9 @@ async function handleRequest(req: Request, server: Bun.Server, url: URL): Promis
const hiscoresKothResponse = await handleHiscoresKothPage(url);
if (hiscoresKothResponse) return hiscoresKothResponse;
const spriteResponse = await handleSpriteRequest(url);
if (spriteResponse) return spriteResponse;
// Viewer assets (cache data, JS, WASM for item icon rendering)
const viewerResponse = handleViewerAssets(url);
if (viewerResponse) return viewerResponse;
+30 -3
View File
@@ -929,13 +929,22 @@ export class Client extends GameShell {
// normal button (clientcode CC_ACCEPT_DESIGN) whose IF_BUTTON trigger
// ([if_button,player_kit:accept]) is what closes the design interface and
// advances the tutorial. A real click sends both; replicate the IF_BUTTON here.
// Prefer the accept button inside the open modal: other interfaces
// (hairdresser, tailor kits) carry the same clientcode with no live
// trigger for this screen.
let accept: IfType | null = null;
for (let i = 0; i < IfType.list.length; i++) {
const com = IfType.list[i];
if (com && com.clientCode === ClientCode.CC_ACCEPT_DESIGN) {
this.writePacketOpcode(ClientProt.IF_BUTTON);
this.out.p2(com.id);
if (!com || com.clientCode !== ClientCode.CC_ACCEPT_DESIGN) continue;
if (this.mainModalId !== -1 && (com.layerId === this.mainModalId || com.id === this.mainModalId)) {
accept = com;
break;
}
if (!accept) accept = com;
}
if (accept) {
this.writePacketOpcode(ClientProt.IF_BUTTON);
this.out.p2(accept.id);
}
return true;
@@ -1740,6 +1749,14 @@ export class Client extends GameShell {
return true;
}
// The side-inventory fallback below reads component 2006's linkObjType,
// which the server never clears after the bank closes - without this
// guard state.bank.isOpen stays true for the rest of the session after
// the first bank visit and every inventory guard refuses to send.
if (this.mainModalId === -1) {
return false;
}
const component = IfType.list[BANK_SIDE_INV_ID];
if (component && component.linkObjType) {
for (let i = 0; i < component.linkObjType.length; i++) {
@@ -1984,6 +2001,16 @@ export class Client extends GameShell {
// Public chat is capped at Client.maxMessageLength (server-configured, default
// 80 = the RS wire limit); anything past it is dropped silently on the wire, so
// report it back to the caller.
// '::cmd' is a client cheat, not public chat - mirror the keyboard input path
// and the lite client so bot-driven ::give/::setstat are not spoken aloud.
if (message.startsWith('::')) {
const cheat = message.substring(2);
this.out.p1Enc(ClientProt.CLIENT_CHEAT);
this.out.p1(cheat.length + 1);
this.out.pjstr(cheat);
return { ok: true, truncated: false, filtered: false, finalText: message };
}
const truncated: boolean = message.length > Client.maxMessageLength;
let text = message.substring(0, Client.maxMessageLength);
+15 -1
View File
@@ -887,13 +887,27 @@ export function acceptCharacterDesign(c: LiteClient): boolean {
c.out.p1(c.designColours[i]);
}
// Several interfaces carry a CC_ACCEPT_DESIGN button (tutorial player_kit,
// the Falador hairdresser and Varrock tailor kits). Only the one inside the
// open modal has a live [if_button] trigger; a first-match scan over the
// whole list can hit another interface's button and the server answers
// 'No trigger for ...' while the design screen stays open.
const modal = c.mainModalId;
let fallback = -1;
for (let i = 0; i < IfType.list.length; i++) {
const com = IfType.list[i];
if (com && com.clientCode === ClientCode.CC_ACCEPT_DESIGN) {
if (!com || com.clientCode !== ClientCode.CC_ACCEPT_DESIGN) continue;
if (modal !== -1 && (com.layerId === modal || i === modal)) {
c.writeOpcode(ClientProt.IF_BUTTON);
c.out.p2(i);
return true;
}
if (fallback === -1) fallback = i;
}
if (fallback !== -1) {
c.writeOpcode(ClientProt.IF_BUTTON);
c.out.p2(fallback);
return true;
}
return false;
}