From dfac800e21004296bd19893a09443c7158e5ebae Mon Sep 17 00:00:00 2001 From: max bittker Date: Tue, 11 Aug 2026 11:58:45 -0400 Subject: [PATCH] feat(lite): in-process fast-KBD attack/prayer hooks in the gateway runner Policy-gated fast path for King Black Dragon fights: the lite runner emits the KBD attack op (and protect-prayer on/off flips) in-process on the same publication that reveals the spawn/removal, instead of round-tripping through the gateway controller. Enabled only when bots/logs/ fleet-runtime.mode contains the fast-kbd mode string; state publications carry fastKbdAttackTick/fastKbdPrayerOn/fastKbdPrayerOff so the controller can observe what was already dispatched. Co-Authored-By: Claude Fable 5 --- sdk/API.md | 2 + sdk/types.ts | 6 + server/webclient/src/bot/types.ts | 6 + server/webclient/src/lite/LiteClient.ts | 10 ++ server/webclient/src/lite/fast-kbd.test.ts | 85 ++++++++++++++ server/webclient/src/lite/fast-kbd.ts | 89 +++++++++++++++ server/webclient/src/lite/runner.ts | 125 ++++++++++++++++++++- 7 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 server/webclient/src/lite/fast-kbd.test.ts create mode 100644 server/webclient/src/lite/fast-kbd.ts diff --git a/sdk/API.md b/sdk/API.md index 3222cc10f..5058017bd 100644 --- a/sdk/API.md +++ b/sdk/API.md @@ -500,6 +500,8 @@ interface BotWorldState { tick: number; /** Tick on which an updated Lite runner already emitted policy-gated KBD op2. */ fastKbdAttackTick?: number; + /** Prayer-on packets emitted in-process immediately before first-spawn KBD op2. */ + fastKbdPrayerOn?: { tick: number; prayerIndices: number[] }; /** Prayer-off packets emitted in-process on the KBD-removal publication. */ fastKbdPrayerOff?: { tick: number; prayerIndices: number[] }; /** Monotonic state publication cursor; advances even for multiple publications in one game tick. */ diff --git a/sdk/types.ts b/sdk/types.ts index 113230b26..41d823972 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -496,6 +496,12 @@ export interface OpFeedbackState { export interface BotWorldState { tick: number; + /** Tick on which an updated Lite runner already emitted policy-gated KBD op2. */ + fastKbdAttackTick?: number; + /** Prayer-on packets emitted in-process immediately before first-spawn KBD op2. */ + fastKbdPrayerOn?: { tick: number; prayerIndices: number[] }; + /** Prayer-off packets emitted in-process on the KBD-removal publication. */ + fastKbdPrayerOff?: { tick: number; prayerIndices: number[] }; /** Monotonic state publication cursor; advances even for multiple publications in one game tick. */ revision?: number; inGame: boolean; diff --git a/server/webclient/src/bot/types.ts b/server/webclient/src/bot/types.ts index d1a73a733..9433002f9 100644 --- a/server/webclient/src/bot/types.ts +++ b/server/webclient/src/bot/types.ts @@ -405,6 +405,12 @@ export interface BotState { // Extended world state interface for agent (includes extra debug info) export interface BotWorldState extends BotState { + /** Tick on which a policy-gated Lite runner already emitted KBD op2. */ + fastKbdAttackTick?: number; + /** Prayer-on packets emitted in-process immediately before first-spawn KBD op2. */ + fastKbdPrayerOn?: { tick: number; prayerIndices: number[] }; + /** Prayer-off packets emitted in-process on the KBD-removal publication. */ + fastKbdPrayerOff?: { tick: number; prayerIndices: number[] }; dialog: DialogState & { allComponents?: Array<{ id: number; type: number; buttonType: number; option: string; text: string }>; }; diff --git a/server/webclient/src/lite/LiteClient.ts b/server/webclient/src/lite/LiteClient.ts index 9371106fa..0c83b5d0d 100644 --- a/server/webclient/src/lite/LiteClient.ts +++ b/server/webclient/src/lite/LiteClient.ts @@ -726,6 +726,16 @@ export class LiteClient { interactNpc(npcIndex: number, optionIndex: number): ClientActionResult { return A.interactNpc(this, npcIndex, optionIndex); } + + /** Exact raw-table lookup for latency-critical, policy-gated hooks. */ + findNpcIndexByExactName(name: string): number | undefined { + for (let i = 0; i < this.npcCount; i++) { + const index = this.npcIds[i]; + const npc = this.npc[index]; + if (npc?.type?.name === name) return index; + } + return undefined; + } interactPlayer(playerIndex: number, optionIndex: number): ClientActionResult { return A.interactPlayer(this, playerIndex, optionIndex); } diff --git a/server/webclient/src/lite/fast-kbd.test.ts b/server/webclient/src/lite/fast-kbd.test.ts new file mode 100644 index 000000000..120cab9c1 --- /dev/null +++ b/server/webclient/src/lite/fast-kbd.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test'; + +import type { BotWorldState } from '#/bot/types.js'; +import { + findFastKbdAttackTarget, + findFastKbdPrayerOffIndices, + findFastKbdPrayerOnIndices, + isFastKbdPolicyState, +} from './fast-kbd.js'; + +function state(overrides: Record = {}): BotWorldState { + return { + player: { worldX: 2718, worldZ: 9819, isDead: false }, + modalOpen: false, + dialog: { isOpen: false }, + equipment: [{ name: 'Rune 2h sword' }], + inventory: [], + nearbyNpcs: [{ name: 'King black dragon', index: 2771, reachable: true }], + prayers: { activePrayers: Array(15).fill(false), prayerPoints: 70, prayerLevel: 70 }, + ...overrides, + } as unknown as BotWorldState; +} + +describe('runner-side KBD fast attack eligibility', () => { + test('selects the exact KBD from the one-item lair state', () => { + expect(findFastKbdAttackTarget(state())).toBe(2771); + }); + + test('allows two retained valuable slots but not a due bank return', () => { + expect(findFastKbdAttackTarget(state({ + inventory: [{ name: 'Dragonhide' }, { name: 'Coal' }], + }))).toBe(2771); + expect(findFastKbdAttackTarget(state({ + inventory: [{ name: 'Dragonhide' }, { name: 'Coal' }, { name: 'Yew logs' }], + }))).toBeUndefined(); + }); + + test('rejects forbidden loot, blocking UI, and positions outside KBD', () => { + expect(findFastKbdAttackTarget(state({ inventory: [{ name: 'Coins' }] }))).toBeUndefined(); + expect(findFastKbdAttackTarget(state({ modalOpen: true }))).toBeUndefined(); + expect(findFastKbdAttackTarget(state({ + player: { worldX: 3094, worldZ: 3957, isDead: false }, + }))).toBeUndefined(); + }); + + test('recognises the reusable one-item policy without a live dragon', () => { + expect(isFastKbdPolicyState(state({ nearbyNpcs: [] }))).toBe(true); + }); + + test('switches active prayers off only during a policy-gated post-KBD phase', () => { + const activePrayers = Array(15).fill(false); + activePrayers[10] = true; + activePrayers[11] = true; + activePrayers[12] = true; + const removed = state({ + nearbyNpcs: [], + prayers: { activePrayers, prayerPoints: 60, prayerLevel: 70 }, + }); + expect(findFastKbdPrayerOffIndices(removed, true)).toEqual([10, 11, 12]); + expect(findFastKbdPrayerOffIndices(removed, false)).toEqual([]); + expect(findFastKbdPrayerOffIndices(state({ + prayers: { activePrayers, prayerPoints: 60, prayerLevel: 70 }, + }), true)).toEqual([]); + }); + + test('switches damage prayers on for the spawn tick and adds bounded protection', () => { + expect(findFastKbdPrayerOnIndices(state())).toEqual([10, 11]); + expect(findFastKbdPrayerOnIndices(state({ + player: { worldX: 2718, worldZ: 9819, isDead: false, hp: 60 }, + inventory: [{ name: 'Dragonhide' }], + }))).toEqual([10, 11, 12, 8]); + }); + + test('does not re-toggle active or unavailable spawn prayers', () => { + const activePrayers = Array(15).fill(false); + activePrayers[10] = true; + activePrayers[11] = true; + expect(findFastKbdPrayerOnIndices(state({ + prayers: { activePrayers, prayerPoints: 60, prayerLevel: 70 }, + }))).toEqual([]); + expect(findFastKbdPrayerOnIndices(state({ + prayers: { activePrayers: Array(15).fill(false), prayerPoints: 0, prayerLevel: 70 }, + }))).toEqual([]); + }); +}); diff --git a/server/webclient/src/lite/fast-kbd.ts b/server/webclient/src/lite/fast-kbd.ts new file mode 100644 index 000000000..760fa6127 --- /dev/null +++ b/server/webclient/src/lite/fast-kbd.ts @@ -0,0 +1,89 @@ +import type { BotWorldState } from '#/bot/types.js'; + +export const FAST_KBD_RUNTIME_MODE = 'kbd-fast-attack-v4'; + +const RUNE_2H = /^rune 2h sword$/i; +const BONES = /bones$/i; +const IGNORED_KBD_LOOT = /^(?:coins|gold ring|uncut (?:sapphire|emerald|ruby|diamond))$/i; +const KBD_PROTECT_MAGIC_HP_THRESHOLD = 65; + +function valuableKbdLootSlots(state: BotWorldState): number { + return state.inventory.filter(item => + !BONES.test(item.name) && !IGNORED_KBD_LOOT.test(item.name) + ).length; +} + +/** The durable one-item policy conditions shared by the attack and prayer-off hooks. */ +export function isFastKbdPolicyState(state: BotWorldState): boolean { + const player = state.player; + if (!player + || player.isDead + || player.worldX < 2688 || player.worldX >= 2752 + || player.worldZ < 9792 || player.worldZ >= 9856 + || state.modalOpen + || state.dialog.isOpen + || state.equipment.length !== 1 + || !RUNE_2H.test(state.equipment[0].name) + || state.inventory.some(item => IGNORED_KBD_LOOT.test(item.name))) { + return false; + } + + return valuableKbdLootSlots(state) < 3; +} + +/** + * Return the one NPC index the Lite runner may attack before publishing state. + * This mirrors the durable fleet policy narrowly enough that the generic + * runner cannot start combat anywhere else or while a bank return is due. + */ +export function findFastKbdAttackTarget(state: BotWorldState): number | undefined { + if (!isFastKbdPolicyState(state)) return undefined; + + return state.nearbyNpcs.find(npc => + npc.name === 'King black dragon' && npc.reachable !== false + )?.index; +} + +/** + * Inactive prayers to switch on immediately before the first KBD op2 packet. + * Interface-button packets are decoded before player interactions in the next + * server cycle, so these boosts apply to that same first swing without paying + * several pre-spawn drain ticks. + */ +export function findFastKbdPrayerOnIndices(state: BotWorldState): number[] { + const player = state.player; + if (!player || !isFastKbdPolicyState(state) || state.prayers.prayerPoints <= 0) return []; + + const level = state.prayers.prayerLevel; + const indices: number[] = []; + if (level >= 31) indices.push(10); // Ultimate Strength + else if (level >= 13) indices.push(4); // Superhuman Strength + else if (level >= 4) indices.push(1); // Burst of Strength + + if (level >= 34) indices.push(11); // Incredible Reflexes + else if (level >= 16) indices.push(5); // Improved Reflexes + else if (level >= 7) indices.push(2); // Clarity of Thought + + if ((player.hp ?? Number.MAX_SAFE_INTEGER) <= KBD_PROTECT_MAGIC_HP_THRESHOLD + && level >= 37) { + indices.push(12); // Protect from Magic + } + if (valuableKbdLootSlots(state) > 0 && level >= 25) { + indices.push(8); // Protect Item + } + + return indices.filter(index => !state.prayers.activePrayers[index]); +} + +/** Active prayers the runner may disable during a confirmed post-KBD off phase. */ +export function findFastKbdPrayerOffIndices( + state: BotWorldState, + prayerOffPending: boolean, +): number[] { + if (!prayerOffPending + || !isFastKbdPolicyState(state) + || state.nearbyNpcs.some(npc => npc.name === 'King black dragon')) { + return []; + } + return state.prayers.activePrayers.flatMap((active, index) => active ? [index] : []); +} diff --git a/server/webclient/src/lite/runner.ts b/server/webclient/src/lite/runner.ts index 1e56d854e..9a08d6904 100644 --- a/server/webclient/src/lite/runner.ts +++ b/server/webclient/src/lite/runner.ts @@ -13,8 +13,15 @@ import { startSession, type LiteSession } from './session.js'; import { type ActionResult } from '#/bot/ActionExecutor.js'; import { BotActionQueue, type QueuedBotAction } from '#/bot/ActionQueue.js'; -import type { BotAction } from '#/bot/types.js'; +import type { BotAction, BotWorldState } from '#/bot/types.js'; import type { LiteClient } from './LiteClient.js'; +import { + FAST_KBD_RUNTIME_MODE, + findFastKbdAttackTarget, + findFastKbdPrayerOffIndices, + findFastKbdPrayerOnIndices, + isFastKbdPolicyState, +} from './fast-kbd.js'; const TICK_MS = 20; // matches the browser draw loop that drives BotOverlay.tick() const RECONNECT_MS = 3000; @@ -24,6 +31,15 @@ class LiteGatewayRunner { private actionQueue = new BotActionQueue(); private waitTicks = 0; private serverTick = 0; + private lastFastKbdAttackTick = -1; + private fastKbdPolicyEligible = false; + private fastKbdWasVisible = false; + private fastKbdRawWasVisible = false; + private fastKbdLastState: BotWorldState | null = null; + private lastFastKbdPrayerOnTick = -1; + private lastFastKbdPrayerOnIndices: number[] = []; + private fastKbdPrayerOffPending = false; + private lastFastKbdPrayerOffDispatchTick = -1; private ws: WebSocket | null = null; private wsConnected = false; @@ -33,13 +49,31 @@ class LiteGatewayRunner { constructor( private session: LiteSession, - private gatewayUrl: string + private gatewayUrl: string, + private fastKbdAttackEnabled = false, ) { this.client = session.client; // State collection and action execution go through the client, which // activates this bot's interface table first - see LiteClient.activate. this.client.setOnGameTickCallback(() => { this.serverTick++; + // Reuse the previous authoritative one-item policy snapshot and + // probe the newly decoded raw NPC table before full state + // collection. This puts KBD op2 ahead of reachability, nearby + // player/loc/item scans, and UI serialization. + if (this.fastKbdAttackEnabled) { + const targetIndex = this.fastKbdPolicyEligible + ? this.client.findNpcIndexByExactName('King black dragon') + : undefined; + if (targetIndex !== undefined + && this.lastFastKbdAttackTick !== this.serverTick) { + if (!this.fastKbdRawWasVisible && this.fastKbdLastState) { + this.dispatchFastKbdPrayerOn(this.fastKbdLastState); + } + this.dispatchFastKbdAttack(targetIndex); + } + this.fastKbdRawWasVisible = targetIndex !== undefined; + } this.sendState(); }); @@ -218,10 +252,88 @@ class LiteGatewayRunner { this.send({ type: 'actionResult', actionId, result }); } + private dispatchFastKbdAttack(targetIndex: number): void { + const result = this.client.interactNpc(targetIndex, 2); + if (result.success) this.lastFastKbdAttackTick = this.serverTick; + } + + private dispatchFastKbdPrayerOn(state: BotWorldState): void { + const emitted: number[] = []; + for (const prayerIndex of findFastKbdPrayerOnIndices(state)) { + const result = this.client.executeBotAction({ + type: 'togglePrayer', + prayerIndex, + reason: 'runner-fast KBD publication', + }); + if (!(result instanceof Promise) && result.success) emitted.push(prayerIndex); + } + if (emitted.length > 0) { + this.lastFastKbdPrayerOnTick = this.serverTick; + this.lastFastKbdPrayerOnIndices = emitted; + } + } + private sendState(): void { if (!this.wsConnected) return; const state = this.client.collectBotState(this.serverTick); if (!state) return; + if (this.fastKbdAttackEnabled) { + const targetIndex = findFastKbdAttackTarget(state); + if (targetIndex !== undefined && this.lastFastKbdAttackTick !== this.serverTick) { + // This is intentionally in-process and before state publication: + // the ordinary SDK path must traverse the gateway twice before + // the same packet reaches this client. + this.dispatchFastKbdPrayerOn(state); + this.dispatchFastKbdAttack(targetIndex); + } + if (this.lastFastKbdAttackTick === this.serverTick) { + state.fastKbdAttackTick = this.serverTick; + } + if (this.lastFastKbdPrayerOnTick === this.serverTick) { + state.fastKbdPrayerOn = { + tick: this.serverTick, + prayerIndices: this.lastFastKbdPrayerOnIndices, + }; + } + + const fastKbdVisible = targetIndex !== undefined; + const policyEligible = isFastKbdPolicyState(state); + if (this.fastKbdWasVisible && !fastKbdVisible && policyEligible) { + this.fastKbdPrayerOffPending = true; + } + if (fastKbdVisible || !policyEligible) { + this.fastKbdPrayerOffPending = false; + } + const prayerOffIndices = this.lastFastKbdPrayerOffDispatchTick === this.serverTick + ? [] + : findFastKbdPrayerOffIndices(state, this.fastKbdPrayerOffPending); + const emittedPrayerOffIndices: number[] = []; + for (const prayerIndex of prayerOffIndices) { + const result = this.client.executeBotAction({ + type: 'togglePrayer', + prayerIndex, + reason: 'runner-fast KBD removal', + }); + if (!(result instanceof Promise) && result.success) { + emittedPrayerOffIndices.push(prayerIndex); + } + } + if (emittedPrayerOffIndices.length > 0) { + this.lastFastKbdPrayerOffDispatchTick = this.serverTick; + state.fastKbdPrayerOff = { + tick: this.serverTick, + prayerIndices: emittedPrayerOffIndices, + }; + } + if (this.fastKbdPrayerOffPending + && !state.prayers.activePrayers.some(active => active)) { + this.fastKbdPrayerOffPending = false; + } + this.fastKbdPolicyEligible = policyEligible; + this.fastKbdWasVisible = fastKbdVisible; + this.fastKbdRawWasVisible = fastKbdVisible; + this.fastKbdLastState = state; + } this.send({ type: 'state', state }); } } @@ -271,7 +383,14 @@ console.log(`[lite-runner] '${env.BOT_USERNAME}' logged into ${host}`); // local hosts with an explicit web port (localhost:8888 is the engine, not the // gateway). GATEWAY_URL (bot.env or process env) overrides the derivation. const gatewayUrl = env.GATEWAY_URL || process.env.GATEWAY_URL || deriveGatewayUrl(host); -const runner = new LiteGatewayRunner(session, gatewayUrl); +const runtimeMode = await Bun.file(`${repoRoot}bots/logs/fleet-runtime.mode`).text() + .then(value => value.trim()) + .catch(() => ''); +const fastKbdAttackEnabled = runtimeMode === FAST_KBD_RUNTIME_MODE; +if (fastKbdAttackEnabled) { + console.log('[lite-runner] in-process KBD op2 enabled'); +} +const runner = new LiteGatewayRunner(session, gatewayUrl, fastKbdAttackEnabled); runner.connect(); process.on('SIGINT', () => {