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 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-11 11:58:45 -04:00
1 parent d447f76077
commit dfac800e21
7 files changed
+320 -3

No files matched your search

+2
View File
@@ -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. */
+6
View File
@@ -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;
+6
View File
@@ -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 }>;
};
+10
View File
@@ -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);
}
@@ -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<string, unknown> = {}): 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([]);
});
});
+89
View File
@@ -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] : []);
}
+122 -3
View File
@@ -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', () => {