mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
1 parent
2b89920c70
commit
db1f30e1c1
12 files changed
+234
-42
No files matched your search
+3
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user