fix sdk state and action contracts

This commit is contained in:
max bittker committed 2026-07-24 17:12:11 -04:00
1 parent b20b027f01
commit 9337aae9af
22 files changed
+994 -229

No files matched your search

+2 -4
View File
@@ -105,9 +105,8 @@ export class ActionHelpers {
}
/**
* Get the tick of the most recent game message (using server tick from messages,
* NOT state.tick which is a different counter). Use this before an action to
* establish a baseline for checkCantReachMessage.
* Get the public tick of the most recent game message. Use this before an
* action to establish a baseline for checkCantReachMessage.
*/
getMessageTick(): number {
const state = this.sdk.getState();
@@ -126,7 +125,6 @@ export class ActionHelpers {
/**
* Check recent game messages for "can't reach" indicators.
* @param sinceMessageTick - Only check messages with tick > this value.
* IMPORTANT: Use getMessageTick() to get this value, NOT state.tick (different counter).
*/
checkCantReachMessage(sinceMessageTick: number): boolean {
const state = this.sdk.getState();
+105 -17
View File
@@ -4,7 +4,7 @@
import { BotSDK } from './index';
import { ActionHelpers } from './actions-helpers';
import { findDoorsAlongPath, blockDoor, } from './pathfinding';
import { findDoorsAlongPath } from './pathfinding';
import type {
ActionResult,
SkillState,
@@ -226,6 +226,37 @@ export class BotActions {
}
}
/**
* Wait for an action effect while clearing safe, incidental UI interrupts
* such as level-up dialogs. Deliberate trade/duel/design interfaces remain
* protected by dismissBlockingUI's allowlist.
*/
private async waitForActionCondition(
predicate: (state: NonNullable<ReturnType<BotSDK['getState']>>) => boolean,
timeout: number
): Promise<NonNullable<ReturnType<BotSDK['getState']>>> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const current = this.sdk.getState();
if (current && predicate(current)) return current;
if (current?.dialog.isOpen ||
(current?.interface?.isOpen && !current.shop?.isOpen && !current.bank?.isOpen
&& !NEVER_AUTO_CLOSE.has(current.interface.interfaceId))) {
await this.dismissBlockingUI();
const afterDismiss = this.sdk.getState();
if (afterDismiss && predicate(afterDismiss)) return afterDismiss;
}
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await this.sdk.waitForStateChange(Math.min(remaining, 2_000)).catch(() => {});
}
throw new Error('waitForActionCondition timed out');
}
// ============ Porcelain: Smart Actions ============
/** Open a door or gate, walking to it if needed. */
@@ -816,11 +847,12 @@ export class BotActions {
const key = `${nearest.x},${nearest.z}`;
const fails = (doorFailCounts.get(key) ?? 0) + 1;
doorFailCounts.set(key, fails);
// Only permanently block after 3 reach failures
// Temporarily exclude after repeated reach failures. The
// session-owned evidence expires automatically.
if (fails >= 3 && !blockedDoors.has(key)) {
blockedDoors.add(key);
blockDoor(level, nearest.x, nearest.z);
console.log(`[walkTo] Blocked impassable door at (${nearest.x}, ${nearest.z}) — re-routing`);
this.sdk.blockDoorTemporarily(level, nearest.x, nearest.z);
console.log(`[walkTo] Temporarily avoiding impassable door at (${nearest.x}, ${nearest.z}) — re-routing`);
}
}
return false;
@@ -870,11 +902,12 @@ export class BotActions {
requiredDoorKeys.delete(dk);
await this.sdk.waitForTicks(1);
} else if (result === 'locked') {
// Definitely locked — block permanently in pathfinder
// Definitely locked right now — avoid it for
// this session's next path queries.
blockedDoors.add(dk);
blockDoor(door.level, door.x, door.z);
this.sdk.blockDoorTemporarily(door.level, door.x, door.z);
requiredDoorKeys.delete(dk);
console.log(`[walkTo] Blocked locked door at (${door.x}, ${door.z}) — re-routing`);
console.log(`[walkTo] Temporarily avoiding locked door at (${door.x}, ${door.z}) — re-routing`);
break; // Re-query path on next iteration
} else {
// cant_reach or not_found — transient failure, track attempts
@@ -882,9 +915,9 @@ export class BotActions {
doorFailCounts.set(dk, fails);
if (fails >= 3) {
blockedDoors.add(dk);
blockDoor(door.level, door.x, door.z);
this.sdk.blockDoorTemporarily(door.level, door.x, door.z);
requiredDoorKeys.delete(dk);
console.log(`[walkTo] Blocked impassable door at (${door.x}, ${door.z}) after ${fails} failures — re-routing`);
console.log(`[walkTo] Temporarily avoiding impassable door at (${door.x}, ${door.z}) after ${fails} failures — re-routing`);
} else {
console.log(`[walkTo] Door at (${door.x}, ${door.z}) ${result} (attempt ${fails}/3) — retrying`);
}
@@ -1617,19 +1650,31 @@ export class BotActions {
}
}
const startTick = this.sdk.getState()?.tick || 0;
const startState = this.sdk.getState();
const startTick = startState?.tick || 0;
const startLifeId = startState?.player?.lifeId;
const combatSkillNames = new Set(['Attack', 'Strength', 'Defence', 'Hitpoints', 'Ranged', 'Magic']);
const startCombatXp = (startState?.skills ?? [])
.filter(skill => combatSkillNames.has(skill.name))
.reduce((total, skill) => total + skill.experience, 0);
const msgBaseline = this.helpers.getMessageTick();
const result = await this.sdk.sendInteractNpc(npc.index, attackOpt.opIndex);
if (!result.success) {
return { success: false, message: result.message };
return {
success: false,
message: result.message,
reason: result.reason === 'cant_reach' ? 'out_of_reach' : 'timeout'
};
}
try {
const finalState = await this.sdk.waitForCondition(state => {
const finalState = await this.waitForActionCondition(state => {
for (const msg of state.gameMessages) {
if (msg.tick > msgBaseline) {
const text = msg.text.toLowerCase();
if (text.includes("someone else is fighting") || text.includes("already under attack")) {
if (text.includes("someone else is fighting") ||
text.includes("already under attack") ||
text.includes("can't reach")) {
return true;
}
}
@@ -1640,24 +1685,67 @@ export class BotActions {
return true;
}
if (targetNpc.distance <= 2) {
if (state.player?.isDead || (startLifeId !== undefined && state.player?.lifeId !== startLifeId)) {
return true;
}
return false;
if (state.player?.combat.inCombat && state.player.combat.targetIndex === npc.index) {
return true;
}
return state.combatEvents.some(event =>
event.tick > startTick &&
event.type === 'damage_dealt' &&
event.targetType === 'npc' &&
event.targetIndex === npc.index
) || state.skills
.filter(skill => combatSkillNames.has(skill.name))
.reduce((total, skill) => total + skill.experience, 0) > startCombatXp;
}, timeout);
// Check for "already in combat"
if (finalState.player?.isDead ||
(startLifeId !== undefined && finalState.player?.lifeId !== startLifeId)) {
return { success: false, message: `Died while attacking ${npc.name}`, reason: 'died' };
}
const engagedRequestedTarget =
(finalState.player?.combat.inCombat &&
finalState.player.combat.targetIndex === npc.index) ||
finalState.combatEvents.some(event =>
event.tick > startTick &&
event.type === 'damage_dealt' &&
event.targetType === 'npc' &&
event.targetIndex === npc.index
);
const gainedCombatXp = finalState.skills
.filter(skill => combatSkillNames.has(skill.name))
.reduce((total, skill) => total + skill.experience, 0) > startCombatXp;
for (const msg of finalState.gameMessages) {
if (msg.tick > msgBaseline) {
const text = msg.text.toLowerCase();
if (text.includes("can't reach")) {
return { success: false, message: `Cannot reach ${npc.name}`, reason: 'out_of_reach' };
}
if (text.includes("someone else is fighting") || text.includes("already under attack")) {
// "I'm already under attack!" can race with a successful
// engagement. Prefer observed target/damage/XP evidence.
if (engagedRequestedTarget || gainedCombatXp) {
return { success: true, message: `Already fighting ${npc.name}` };
}
return { success: false, message: `${npc.name} is already in combat`, reason: 'already_in_combat' };
}
}
}
return { success: true, message: `Attacking ${npc.name}` };
if (engagedRequestedTarget || gainedCombatXp) {
return { success: true, message: `Attacking ${npc.name}` };
}
const targetStillVisible = finalState.nearbyNpcs.some(candidate => candidate.index === npc.index);
return targetStillVisible
? { success: false, message: `No combat effect observed for ${npc.name}`, reason: 'timeout' }
: { success: false, message: `${npc.name} disappeared before combat was observed`, reason: 'npc_not_found' };
} catch {
return { success: false, message: `Timeout waiting to attack ${npc.name}`, reason: 'timeout' };
}
+7 -2
View File
@@ -41,12 +41,17 @@ export function formatWorldState(
lines.push('## Player');
lines.push(`Name: ${p.name} (Combat ${p.combatLevel})`);
lines.push(`Position: (${p.worldX}, ${p.worldZ}) Level ${p.level}`);
if (p.isDead) {
lines.push(`Status: DEAD (life ${p.lifeId}, death tick ${p.lastDeathTick ?? 'unknown'})`);
} else if (p.respawnCount > 0) {
lines.push(`Life: ${p.lifeId} (${p.respawnCount} respawn${p.respawnCount === 1 ? '' : 's'} observed)`);
}
// Combat status
if (p.combat.inCombat) {
const target = state.nearbyNpcs.find(n => n.index === p.combat.targetIndex);
if (target) {
const hpStr = target.maxHp > 0 ? ` HP: ${target.hp}/${target.maxHp}` : '';
const hpStr = target.hp !== null && target.maxHp !== null ? ` HP: ${target.hp}/${target.maxHp}` : '';
lines.push(`In Combat: ${target.name}${hpStr}`);
} else {
lines.push(`In Combat: target index ${p.combat.targetIndex}`);
@@ -211,7 +216,7 @@ export function formatWorldState(
lines.push('## Nearby NPCs');
for (const npc of state.nearbyNpcs.slice(0, 10)) {
const lvl = npc.combatLevel > 0 ? ` (Lvl ${npc.combatLevel})` : '';
const hp = npc.maxHp > 0 ? ` HP: ${npc.hp}/${npc.maxHp}` : '';
const hp = npc.hp !== null && npc.maxHp !== null ? ` HP: ${npc.hp}/${npc.maxHp}` : '';
const combat = npc.inCombat ? ' [in combat]' : '';
const opts = npc.options?.length > 0 ? ` [${npc.options.join(', ')}]` : '';
lines.push(`- ${npc.name}${lvl}${hp}${combat} - ${npc.distance} tiles (idx: ${npc.index})${opts}`);
+40 -2
View File
@@ -1,6 +1,7 @@
// Bot SDK - Standalone client for remote bot control
// Low-level WebSocket API that maps 1:1 to the action protocol
// Actions resolve when game ACKNOWLEDGES them (not when effects complete)
// Raw actions resolve when the browser validates/routes and dispatches them.
// Use BotActions when the caller needs observation of the resulting game effect.
import type {
BotWorldState,
@@ -119,6 +120,7 @@ export class BotSDK {
private connectionListeners = new Set<(state: ConnectionState, attempt?: number) => void>();
private connectPromise: Promise<void> | null = null;
private sdkClientId: string;
private temporaryDoorBlocks = new pathfinding.TemporaryDoorBlocklist();
/** True once the gateway has accepted our credentials (sdk_connected received). */
private authenticated = false;
@@ -741,6 +743,23 @@ export class BotSDK {
return this.state?.inventory || [];
}
/**
* Count total item quantity matching a name pattern.
*
* This sums stack sizes across every matching slot. Use
* `getInventory().filter(...)` when the number of occupied slots is needed.
*/
countInventoryItems(pattern: string | RegExp): number {
if (!this.state) return 0;
const regex = typeof pattern === 'string'
? new RegExp(pattern, 'i')
: new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
return this.state.inventory.reduce(
(total, item) => total + (regex.test(item.name) ? item.count : 0),
0
);
}
/** Get equipment item by slot number. */
getEquipmentItem(slot: number): InventoryItem | null {
if (!this.state) return null;
@@ -1304,7 +1323,15 @@ export class BotSDK {
const destZoneAllocated = pathfinding.isZoneAllocated(level, destX, destZ);
// 2048x2048 BFS grid handles any in-game distance in a single call.
const waypoints = pathfinding.findLongPath(level, srcX, srcZ, destX, destZ, maxWaypoints);
const waypoints = pathfinding.findLongPath(
level,
srcX,
srcZ,
destX,
destZ,
maxWaypoints,
this.temporaryDoorBlocks.active()
);
// If no waypoints and destination zone isn't allocated, that's expected -
// we just can't path there yet (might need to open a door first)
@@ -1332,6 +1359,17 @@ export class BotSDK {
return { success: true, waypoints, reachedDestination };
}
/**
* Temporarily exclude a known door from this SDK instance's path queries.
* The shared collision map is never mutated beyond the synchronous query.
*/
blockDoorTemporarily(level: number, x: number, z: number, ttlMs: number = 30_000): boolean {
const door = pathfinding.getDoorAt(level, x, z);
if (!door) return false;
this.temporaryDoorBlocks.block(door, ttlMs);
return true;
}
/** Find path to destination (async alias for findPath). */
async sendFindPath(
destX: number,
+67 -21
View File
@@ -20,6 +20,42 @@ export interface DoorInfo {
blockrange: boolean;
}
export interface TemporaryDoorBlock {
door: DoorInfo;
expiresAt: number;
}
/**
* Session-owned evidence that a door is temporarily impassable.
*
* Entries expire automatically so a transient route failure cannot poison the
* process-wide collision map for every bot until restart.
*/
export class TemporaryDoorBlocklist {
private entries = new Map<string, TemporaryDoorBlock>();
constructor(private readonly now: () => number = Date.now) {}
block(door: DoorInfo, ttlMs: number = 30_000): void {
this.entries.set(doorKey(door.level, door.x, door.z), {
door,
expiresAt: this.now() + Math.max(0, ttlMs)
});
}
active(): DoorInfo[] {
const now = this.now();
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key);
}
return [...this.entries.values()].map(entry => entry.door);
}
clear(): void {
this.entries.clear();
}
}
// Spatial index of all known door positions, keyed by "level,x,z"
const doorIndex = new Map<string, DoorInfo>();
@@ -115,18 +151,42 @@ export function findLongPath(
srcZ: number,
destX: number,
destZ: number,
maxWaypoints: number = 500
maxWaypoints: number = 500,
blockedDoors: Iterable<DoorInfo> = []
): Array<{ x: number; z: number; level: number }> {
if (!initialized) {
initPathfinding();
}
const waypointsRaw = rsmod.findLongPath(
level, srcX, srcZ, destX, destZ,
1, 1, 1, 0, -1, true, 0, maxWaypoints, CollisionType.NORMAL
);
// Re-apply only this SDK session's temporary door walls for the duration of
// this synchronous query. The shared collision snapshot is restored before
// another bot can pathfind.
const applied: DoorInfo[] = [];
try {
for (const door of blockedDoors) {
rsmod.changeWall(
door.x, door.z, door.level,
door.angle, door.shape, door.blockrange,
false, true
);
applied.push(door);
}
return unpackWaypoints(waypointsRaw);
const waypointsRaw = rsmod.findLongPath(
level, srcX, srcZ, destX, destZ,
1, 1, 1, 0, -1, true, 0, maxWaypoints, CollisionType.NORMAL
);
return unpackWaypoints(waypointsRaw);
} finally {
for (const door of applied) {
rsmod.changeWall(
door.x, door.z, door.level,
door.angle, door.shape, door.blockrange,
false, false
);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
@@ -192,22 +252,8 @@ export function findDoorsAlongPath(
/** Look up a door at an exact position. */
export function getDoorAt(level: number, x: number, z: number): DoorInfo | undefined {
return doorIndex.get(doorKey(level, x, z));
}
/**
* Re-add wall collision for a door that couldn't be opened (e.g. locked).
* This causes the pathfinder to route around it on subsequent queries.
* Also removes the door from the index so findDoorsAlongPath won't return it.
*/
export function blockDoor(level: number, x: number, z: number): boolean {
if (!initialized) initPathfinding();
const key = doorKey(level, x, z);
const door = doorIndex.get(key);
if (!door) return false;
rsmod.changeWall(x, z, level, door.angle, door.shape, door.blockrange, false, true);
doorIndex.delete(key);
return true;
return doorIndex.get(doorKey(level, x, z));
}
// Unpack waypoints from rsmod format
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, test } from 'bun:test';
import { BotActions } from '../actions';
import type { NearbyNpc } from '../types';
const target: NearbyNpc = {
index: 42,
name: 'Guard',
combatLevel: 21,
x: 3201,
z: 3200,
distance: 1,
hp: null,
maxHp: null,
healthPercent: null,
targetIndex: -1,
inCombat: false,
lastCombatTick: null,
animId: -1,
spotanimId: -1,
optionsWithIndex: [{ text: 'Attack', opIndex: 2 }],
options: ['Attack']
};
function state(targetIndex: number, message?: string) {
return {
tick: message ? 2 : 1,
player: {
worldX: 3200,
worldZ: 3200,
lifeId: 1,
isDead: false,
combat: { inCombat: targetIndex >= 0, targetIndex, lastDamageTick: -1 }
},
nearbyNpcs: [target],
skills: [
{ name: 'Attack', level: 1, baseLevel: 1, experience: 0 },
{ name: 'Strength', level: 1, baseLevel: 1, experience: 0 },
{ name: 'Defence', level: 1, baseLevel: 1, experience: 0 },
{ name: 'Hitpoints', level: 10, baseLevel: 10, experience: 1_154 }
],
gameMessages: message
? [{ tick: 2, text: message, type: 0, sender: '', fromSelf: false }]
: [],
combatEvents: [],
dialog: { isOpen: false, options: [], isWaiting: false },
interface: { isOpen: false, interfaceId: -1, options: [] },
shop: { isOpen: false },
bank: { isOpen: false }
} as any;
}
function createBot(finalTargetIndex: number) {
let current = state(-1);
const final = state(finalTargetIndex, "I'm already under attack!");
const sdk = {
getState: () => current,
sendInteractNpc: async () => ({ success: true, message: 'dispatched', phase: 'dispatch' }),
waitForStateChange: async () => {
current = final;
return final;
}
};
return new BotActions(sdk as any);
}
describe('BotActions.attackNpc observation', () => {
test('treats a refusal race as success when the requested target is engaged', async () => {
const result = await createBot(target.index).attackNpc(target);
expect(result).toEqual({ success: true, message: 'Already fighting Guard' });
});
test('keeps the refusal truthful when a different target is engaged', async () => {
const result = await createBot(7).attackNpc(target);
expect(result).toMatchObject({ success: false, reason: 'already_in_combat' });
});
});
+3 -3
View File
@@ -53,7 +53,7 @@ async function runCombatEventsTest(): Promise<boolean> {
}
console.log(`Found target: ${target.name} (lvl ${target.combatLevel}, index ${target.index})`);
console.log(`Initial state: hp=${target.hp}/${target.maxHp}, inCombat=${target.inCombat}, combatCycle=${target.combatCycle}\n`);
console.log(`Initial state: hp=${target.hp}/${target.maxHp}, inCombat=${target.inCombat}, lastCombatTick=${target.lastCombatTick}\n`);
// Attack the target
console.log(`Attacking ${target.name}...`);
@@ -108,9 +108,9 @@ async function runCombatEventsTest(): Promise<boolean> {
// Check NPC state
const currentTarget = state.nearbyNpcs.find(n => n.index === target!.index);
if (currentTarget && currentTarget.maxHp > 0) {
if (currentTarget && currentTarget.maxHp !== null && currentTarget.maxHp > 0) {
// NPC has taken damage, we can see its health
console.log(`[tick ${tick}] NPC health: ${currentTarget.hp}/${currentTarget.maxHp} (${currentTarget.healthPercent}%), combatCycle=${currentTarget.combatCycle}`);
console.log(`[tick ${tick}] NPC health: ${currentTarget.hp}/${currentTarget.maxHp} (${currentTarget.healthPercent}%), lastCombatTick=${currentTarget.lastCombatTick}`);
}
// If target died, find a new one
+14 -14
View File
@@ -128,15 +128,15 @@ async function runCombatStateTest(): Promise<boolean> {
if (!isArray) allTestsPassed = false;
}
// Test 4: Check combatCycle field exists
console.log('\n--- Test 4: NPC combatCycle Field ---');
// Test 4: Check public-clock combat marker exists
console.log('\n--- Test 4: NPC lastCombatTick Field ---');
if (npc) {
const hasCombatCycle = 'combatCycle' in npc;
console.log(` combatCycle field exists: ${hasCombatCycle}`);
if (hasCombatCycle) {
const validCombatCycle = typeof npc.combatCycle === 'number';
console.log(` combatCycle valid: ${validCombatCycle} (value: ${npc.combatCycle})`);
if (!validCombatCycle) allTestsPassed = false;
const hasLastCombatTick = 'lastCombatTick' in npc;
console.log(` lastCombatTick field exists: ${hasLastCombatTick}`);
if (hasLastCombatTick) {
const validLastCombatTick = npc.lastCombatTick === null || typeof npc.lastCombatTick === 'number';
console.log(` lastCombatTick valid: ${validLastCombatTick} (value: ${npc.lastCombatTick})`);
if (!validLastCombatTick) allTestsPassed = false;
} else {
allTestsPassed = false;
}
@@ -158,7 +158,7 @@ async function runCombatStateTest(): Promise<boolean> {
console.log(` healthPercent=${attackableNpc.healthPercent} (null = not yet damaged)`);
console.log(` targetIndex=${attackableNpc.targetIndex}`);
console.log(` inCombat=${attackableNpc.inCombat}`);
console.log(` combatCycle=${attackableNpc.combatCycle} (current tick: ${currentTick})`);
console.log(` lastCombatTick=${attackableNpc.lastCombatTick} (current tick: ${currentTick})`);
// Attack the NPC
const attackOpt = attackableNpc.optionsWithIndex.find(o => o.text.toLowerCase() === 'attack');
@@ -177,7 +177,7 @@ async function runCombatStateTest(): Promise<boolean> {
console.log(` targetIndex: ${pc.targetIndex}`);
console.log(` lastDamageTick: ${pc.lastDamageTick}`);
// Player should be in combat now (via combatCycle check)
// Player should be in combat now.
if (pc.inCombat) {
console.log(` PASS: player.combat.inCombat is TRUE during combat`);
} else {
@@ -204,17 +204,17 @@ async function runCombatStateTest(): Promise<boolean> {
console.log(` hp=${updatedNpc.hp}/${updatedNpc.maxHp}`);
console.log(` targetIndex=${updatedNpc.targetIndex}`);
console.log(` inCombat=${updatedNpc.inCombat}`);
console.log(` combatCycle=${updatedNpc.combatCycle}`);
console.log(` lastCombatTick=${updatedNpc.lastCombatTick}`);
// After taking damage, NPC should have health data and be in combat
if (updatedNpc.maxHp > 0) {
if (updatedNpc.maxHp !== null && updatedNpc.maxHp > 0) {
console.log(` PASS: NPC health now visible (hp=${updatedNpc.hp}/${updatedNpc.maxHp})`);
}
if (updatedNpc.inCombat) {
console.log(` PASS: NPC inCombat is TRUE`);
}
if (updatedNpc.combatCycle > newTick) {
console.log(` PASS: NPC combatCycle (${updatedNpc.combatCycle}) > current tick (${newTick})`);
if (updatedNpc.lastCombatTick !== null && updatedNpc.lastCombatTick <= newTick) {
console.log(` PASS: NPC lastCombatTick (${updatedNpc.lastCombatTick}) uses public tick clock (${newTick})`);
}
} else {
console.log(` NOTE: NPC no longer visible (died)`);
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test';
import { BotSDK } from '../index';
import type { InventoryItem } from '../types';
function item(slot: number, name: string, count: number): InventoryItem {
return { slot, name, count, id: slot, optionsWithIndex: [] };
}
describe('BotSDK.countInventoryItems', () => {
test('sums stack sizes across matching occupied slots', () => {
const sdk = Object.create(BotSDK.prototype) as BotSDK;
(sdk as any).state = {
inventory: [
item(0, 'Logs', 1),
item(1, 'Logs', 1),
item(2, 'Coins', 500)
]
};
expect(sdk.countInventoryItems(/^logs$/i)).toBe(2);
expect(sdk.countInventoryItems('coins')).toBe(500);
expect(sdk.getInventory().filter(entry => /^logs$/i.test(entry.name))).toHaveLength(2);
});
test('normalizes stateful regular expressions', () => {
const sdk = Object.create(BotSDK.prototype) as BotSDK;
(sdk as any).state = {
inventory: [item(0, 'Bones', 1), item(1, 'Bones', 1)]
};
expect(sdk.countInventoryItems(/bones/gi)).toBe(2);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test';
import { TemporaryDoorBlocklist, type DoorInfo } from '../pathfinding';
const door: DoorInfo = {
level: 0,
x: 3200,
z: 3201,
shape: 0,
angle: 1,
blockrange: false
};
describe('TemporaryDoorBlocklist', () => {
test('expires transient route evidence', () => {
let now = 1_000;
const blocklist = new TemporaryDoorBlocklist(() => now);
blocklist.block(door, 500);
expect(blocklist.active()).toEqual([door]);
now = 1_500;
expect(blocklist.active()).toEqual([]);
});
test('is isolated per SDK session', () => {
const first = new TemporaryDoorBlocklist(() => 0);
const second = new TemporaryDoorBlocklist(() => 0);
first.block(door);
expect(first.active()).toEqual([door]);
expect(second.active()).toEqual([]);
});
});
+17 -4
View File
@@ -34,6 +34,14 @@ export interface PlayerState {
spotanimId: number;
/** Combat state tracking */
combat: PlayerCombatState;
/** True while the player's hitpoints are zero. */
isDead: boolean;
/** Changes after each observed death/respawn cycle. */
lifeId: number;
/** Number of respawns observed during this client session. */
respawnCount: number;
/** Public game tick when death was last observed, or null if none was observed. */
lastDeathTick: number | null;
}
export interface SkillState {
@@ -68,12 +76,15 @@ export interface NearbyNpc {
x: number;
z: number;
distance: number;
hp: number;
maxHp: number;
/** Current HP, or null until the server reveals it by updating the NPC. */
hp: number | null;
/** Maximum HP, or null until the server reveals it by updating the NPC. */
maxHp: number | null;
healthPercent: number | null;
targetIndex: number;
inCombat: boolean;
combatCycle: number;
/** Public game tick when damage was last observed on this NPC. */
lastCombatTick: number | null;
animId: number;
spotanimId: number;
optionsWithIndex: NpcOption[];
@@ -354,6 +365,8 @@ export interface ActionResult {
data?: any;
/** Machine-readable failure category (e.g. 'cant_reach', 'no_match', 'timeout') */
reason?: string;
/** Primitive actions report dispatch; porcelain actions may report observation/completion. */
phase?: 'validation' | 'routing' | 'dispatch' | 'observation' | 'completion';
}
/**
@@ -510,7 +523,7 @@ export interface EatResult {
export interface AttackResult {
success: boolean;
message: string;
reason?: 'npc_not_found' | 'no_attack_option' | 'out_of_reach' | 'already_in_combat' | 'timeout';
reason?: 'npc_not_found' | 'no_attack_option' | 'out_of_reach' | 'already_in_combat' | 'died' | 'timeout';
}
export interface CastSpellResult {
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test';
import { ActionExecutor } from './ActionExecutor.js';
describe('ActionExecutor interaction routing', () => {
test('surfaces an unreachable route instead of reporting packet dispatch', () => {
const client = {
interactNpc: () => ({ success: false, reason: 'cant_reach' as const }),
projectNpcToScreen: () => null
};
const executor = new ActionExecutor(client as any);
const result = executor.execute({
type: 'interactNpc',
npcIndex: 42,
optionIndex: 1,
reason: 'test'
});
expect(result).toEqual({
success: false,
message: 'Failed to interact with NPC: cant_reach',
phase: 'routing',
reason: 'cant_reach'
});
});
test('describes successful primitive interactions as dispatch, not completion', () => {
const client = {
interactLoc: () => ({ success: true as const }),
projectTileToScreen: () => null
};
const executor = new ActionExecutor(client as any);
const result = executor.execute({
type: 'interactLoc',
x: 3200,
z: 3200,
locId: 1,
optionIndex: 1,
reason: 'test'
});
expect(result).toMatchObject({ success: true, phase: 'dispatch' });
});
});
+30 -12
View File
@@ -1,13 +1,17 @@
// ActionExecutor.ts - Executes bot actions by calling client methods
// Maps BotAction types to actual game client operations
import type { Client } from '#/client/Client.js';
import type { Client, ClientActionResult } from '#/client/Client.js';
import type { BotAction, NearbyLoc, GroundItem } from './types.js';
export interface ActionResult {
success: boolean;
message: string;
data?: any; // Optional data payload for scan results
/** Stage reached by this primitive action. Success here means dispatched, not effect-complete. */
phase?: 'validation' | 'routing' | 'dispatch' | 'observation' | 'completion';
/** Stable failure category suitable for retry decisions. */
reason?: string;
}
export type ActionResultOrPromise = ActionResult | Promise<ActionResult>;
@@ -51,7 +55,7 @@ export class ActionExecutor {
}
case 'talkToNpc': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.talkToNpc(action.npcIndex),
`Talking to NPC #${action.npcIndex}`,
'Failed to talk to NPC'
@@ -61,7 +65,7 @@ export class ActionExecutor {
}
case 'interactNpc': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.interactNpc(action.npcIndex, action.optionIndex),
`Interacting with NPC #${action.npcIndex}`,
'Failed to interact with NPC'
@@ -71,7 +75,7 @@ export class ActionExecutor {
}
case 'interactPlayer': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.interactPlayer(action.playerIndex, action.optionIndex),
`Interacting with player #${action.playerIndex}`,
'Failed to interact with player'
@@ -81,7 +85,7 @@ export class ActionExecutor {
}
case 'interactLoc': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.interactLoc(action.x, action.z, action.locId, action.optionIndex),
`Interacting with loc ${action.locId}`,
'Failed to interact with location'
@@ -105,7 +109,7 @@ export class ActionExecutor {
);
case 'pickupItem': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.pickupGroundItem(action.x, action.z, action.itemId),
`Picking up item ${action.itemId}`,
'Failed to pickup item'
@@ -145,7 +149,7 @@ export class ActionExecutor {
);
case 'useItemOnLoc': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.useItemOnLoc(action.itemSlot, action.x, action.z, action.locId),
`Using item on location`,
'Failed to use item on location'
@@ -155,7 +159,7 @@ export class ActionExecutor {
}
case 'useItemOnNpc': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.useItemOnNpc(action.itemSlot, action.npcIndex),
`Using item on NPC #${action.npcIndex}`,
'Failed to use item on NPC'
@@ -208,7 +212,7 @@ export class ActionExecutor {
);
case 'spellOnNpc': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.spellOnNpc(action.npcIndex, action.spellComponent),
`Casting spell on NPC #${action.npcIndex}`,
'Failed to cast spell on NPC'
@@ -225,7 +229,7 @@ export class ActionExecutor {
);
case 'spellOnGroundItem':
return this.wrapBool(
return this.wrapClientAction(
this.client.spellOnGroundItem(action.x, action.z, action.itemId, action.spellComponent),
`Casting spell on ground item ${action.itemId} at (${action.x}, ${action.z})`,
'Failed to cast spell on ground item'
@@ -278,7 +282,7 @@ export class ActionExecutor {
);
case 'interactGroundItem': {
const result = this.wrapBool(
const result = this.wrapClientAction(
this.client.interactGroundItem(action.x, action.z, action.itemId, action.optionIndex),
`Interacting with ground item ${action.itemId}`,
'Failed to interact with ground item'
@@ -355,7 +359,21 @@ export class ActionExecutor {
// Helper to wrap boolean client methods
private wrapBool(result: boolean, successMsg: string, failMsg: string): ActionResult {
return result ? { success: true, message: successMsg } : { success: false, message: failMsg };
return result
? { success: true, message: successMsg, phase: 'dispatch' }
: { success: false, message: failMsg, phase: 'validation', reason: 'client_rejected' };
}
private wrapClientAction(result: ClientActionResult, successMsg: string, failMsg: string): ActionResult {
if (result.success) {
return { success: true, message: successMsg, phase: 'dispatch' };
}
return {
success: false,
message: `${failMsg}: ${result.reason}`,
phase: result.reason === 'cant_reach' ? 'routing' : 'validation',
reason: result.reason
};
}
// Show red click cross at an NPC's screen position
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test';
import { BotActionQueue } from './ActionQueue.js';
import type { QueuedBotAction } from './ActionQueue.js';
function entry(actionId: string): QueuedBotAction {
return {
actionId,
action: { type: 'none', reason: 'test' }
};
}
describe('BotActionQueue', () => {
test('preserves FIFO ordering without overwriting the active action', () => {
const queue = new BotActionQueue();
const first = entry('first');
const second = entry('second');
queue.enqueue(first);
queue.enqueue(second);
expect(queue.startNext()).toBe(first);
expect(queue.startNext()).toBeNull();
expect(queue.active).toBe(first);
expect(queue.pendingCount).toBe(1);
expect(queue.complete(first)).toBeTrue();
expect(queue.startNext()).toBe(second);
});
test('ignores stale async completions after the queue is cleared', () => {
const queue = new BotActionQueue();
const stale = entry('stale');
queue.enqueue(stale);
expect(queue.startNext()).toBe(stale);
queue.clear();
const fresh = entry('fresh');
queue.enqueue(fresh);
expect(queue.startNext()).toBe(fresh);
expect(queue.complete(stale)).toBeFalse();
expect(queue.active).toBe(fresh);
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { BotAction } from './types.js';
export interface QueuedBotAction {
action: BotAction;
actionId: string | null;
}
/**
* Single-consumer FIFO for browser actions.
*
* The game client can execute only one action at a time. Keeping the active
* entry separate prevents a later gateway message from overwriting the action
* id whose result is still pending.
*/
export class BotActionQueue {
private entries: QueuedBotAction[] = [];
private current: QueuedBotAction | null = null;
enqueue(entry: QueuedBotAction): void {
this.entries.push(entry);
}
startNext(): QueuedBotAction | null {
if (this.current) return null;
this.current = this.entries.shift() ?? null;
return this.current;
}
get active(): QueuedBotAction | null {
return this.current;
}
get pendingCount(): number {
return this.entries.length;
}
complete(entry: QueuedBotAction): boolean {
if (this.current !== entry) return false;
this.current = null;
return true;
}
clear(): void {
this.entries = [];
this.current = null;
}
}
+34 -30
View File
@@ -9,6 +9,8 @@ import { formatBotState, formatWorldStateForAgent } from './formatters.js';
import { GatewayConnection, type GatewayMessageHandler } from './GatewayConnection.js';
import { OverlayUI } from './OverlayUI.js';
import { ScriptRunnerUI } from './ScriptRunnerUI.js';
import { BotActionQueue, type QueuedBotAction } from './ActionQueue.js';
import type { ActionResult } from './ActionExecutor.js';
// Global instance reference (set when overlay is created)
let globalBotOverlay: BotOverlay | null = null;
@@ -26,8 +28,7 @@ export class BotOverlay implements GatewayMessageHandler {
private scriptRunner: ScriptRunnerUI;
// Action state
private pendingAction: BotAction | null = null;
private currentActionId: string | null = null;
private actionQueue = new BotActionQueue();
private waitTicks: number = 0;
// Server tick counter - increments once per PLAYER_INFO packet (~420ms)
@@ -91,8 +92,7 @@ export class BotOverlay implements GatewayMessageHandler {
onAction(action: BotAction, actionId: string | null): void {
console.log(`[BotOverlay] Received action: ${action.type} (${actionId})`);
this.pendingAction = action;
this.currentActionId = actionId;
this.actionQueue.enqueue({ action, actionId });
this.lastActionTime = Date.now();
// Reset idle timer - SDK actions count as activity
this.client.idleTimer = performance.now();
@@ -107,10 +107,10 @@ export class BotOverlay implements GatewayMessageHandler {
this.ui.logAction('connected', 'Connected to SDK gateway');
// Clear any stale pending actions from previous sessions
if (this.pendingAction) {
console.log(`[BotOverlay] Clearing stale pending action on connect: ${this.pendingAction.type}`);
if (this.actionQueue.active || this.actionQueue.pendingCount > 0) {
console.log('[BotOverlay] Clearing stale action queue on connect');
}
this.pendingAction = null;
this.actionQueue.clear();
this.waitTicks = 0;
}
@@ -146,17 +146,19 @@ export class BotOverlay implements GatewayMessageHandler {
// Handle wait ticks
if (this.waitTicks > 0) {
this.waitTicks--;
if (this.waitTicks === 0 && this.currentActionId) {
this.sendActionResult({ success: true, message: 'Wait complete' });
const active = this.actionQueue.active;
if (this.waitTicks === 0 && active) {
this.finishAction(active, { success: true, message: 'Wait complete', phase: 'completion' });
}
return;
}
// Execute pending action
if (this.pendingAction) {
const action = this.pendingAction;
this.pendingAction = null;
// Never overlap an asynchronous action with the next queued action.
if (this.actionQueue.active) return;
const queued = this.actionQueue.startNext();
if (queued) {
const action = queued.action;
console.log(`[BotOverlay] Executing action: ${action.type}`);
const resultOrPromise = this.executor.execute(action);
@@ -164,11 +166,15 @@ export class BotOverlay implements GatewayMessageHandler {
if (resultOrPromise instanceof Promise) {
resultOrPromise.then(result => {
console.log(`[BotOverlay] Async action result: ${result.success ? 'success' : 'failed'} - ${result.message}`);
this.sendActionResult(result);
this.sendState(); // Immediate feedback after action
this.finishAction(queued, result);
}).catch(e => {
console.error(`[BotOverlay] Async action error:`, e);
this.sendActionResult({ success: false, message: `Error: ${e}` });
this.finishAction(queued, {
success: false,
message: `Error: ${e}`,
phase: 'completion',
reason: 'execution_error'
});
});
return;
}
@@ -182,10 +188,7 @@ export class BotOverlay implements GatewayMessageHandler {
return;
}
this.sendActionResult(result);
// Send state immediately after action for fresh feedback
this.sendState();
this.finishAction(queued, result);
return;
}
@@ -198,7 +201,7 @@ export class BotOverlay implements GatewayMessageHandler {
private collectWorldState(): BotWorldState | null {
if (!this.collector || !this.client) return null;
const baseState = this.collector.collectState();
const baseState = this.collector.collectState(this.serverTick);
const c = this.client as any;
// Get dialog state - include componentId for direct clicking
@@ -234,7 +237,6 @@ export class BotOverlay implements GatewayMessageHandler {
return {
...baseState,
tick: this.serverTick, // Override with server tick (not client frame counter)
dialog: {
isOpen: this.client.isDialogOpen(),
options: dialogOptions,
@@ -264,12 +266,16 @@ export class BotOverlay implements GatewayMessageHandler {
this.gateway.sendState(state, formattedState);
}
private sendActionResult(result: { success: boolean; message: string; data?: any }): void {
if (this.currentActionId) {
this.gateway.sendActionResult(this.currentActionId, result);
private finishAction(entry: QueuedBotAction, result: ActionResult): void {
// An old async completion may arrive after reconnect cleared the queue.
if (this.actionQueue.active !== entry) return;
if (entry.actionId) {
this.gateway.sendActionResult(entry.actionId, result);
this.ui.logAction(result.success ? 'success' : 'failed', result.message);
this.currentActionId = null;
}
this.actionQueue.complete(entry);
this.sendState();
}
private captureAndSendScreenshot(screenshotId?: string): void {
@@ -289,14 +295,12 @@ export class BotOverlay implements GatewayMessageHandler {
update(): void {
if (!this.ui.isVisible() || this.ui.isMinimized()) return;
const state = this.collector.collectState();
// Override with actual game tick (not client render cycle)
state.tick = this.serverTick;
const state = this.collector.collectState(this.serverTick);
this.ui.updateContent(formatBotState(state));
}
getState(): BotState {
return this.collector.collectState();
return this.collector.collectState(this.serverTick);
}
toggle(): void {
@@ -0,0 +1,135 @@
import { describe, expect, test } from 'bun:test';
// StateCollector imports the browser client graph. A tiny DOM shim is enough
// for module initialization because these tests exercise collection only.
(globalThis as any).window = {};
(globalThis as any).document = {
hidden: false,
body: { appendChild() {}, removeChild() {} },
addEventListener() {},
removeEventListener() {},
getElementById() { return null; },
createElement(tag: string) {
if (tag === 'canvas') {
return {
width: 0,
height: 0,
getContext: () => ({
clearRect() {},
drawImage() {},
getImageData() { return {}; }
})
};
}
return {};
}
};
const { BotStateCollector } = await import('./StateCollector.js');
function createClient() {
const skills = new Array(21).fill(1);
skills[3] = 10;
const client: any = {
getClientCycle: () => 100,
localPlayer: {
name: 'Tester',
combatLevel: 3,
x: 128,
z: 128,
faceEntity: 7,
// Deliberately different: the removed field must never win.
targetId: 99,
combatCycle: 120,
damageCycles: new Int32Array([110, 0, 0, 0]),
damageValues: new Int32Array([2, 0, 0, 0]),
primaryAnim: -1,
spotanimId: -1
},
statEffectiveLevel: [...skills],
statBaseLevel: [...skills],
mapBuildBaseX: 3200,
mapBuildBaseZ: 3200,
minusedlevel: 0,
runenergy: 100,
runweight: 0,
npcCount: 1,
npcIds: [7],
npc: {
7: {
type: { name: 'Rat', vislevel: 1, op: ['Attack'] },
x: 256,
z: 128,
health: 0,
totalHealth: 0,
faceEntity: -1,
combatCycle: 0,
primaryAnim: -1,
spotanimId: -1,
damageCycles: new Int32Array(4),
damageValues: new Int32Array(4)
}
}
};
return client;
}
describe('BotStateCollector combat state', () => {
test('uses the public tick for damage events and faceEntity for targeting', () => {
const client = createClient();
const collector = new BotStateCollector(client);
(collector as any).collectCombatEvents(12);
const player = (collector as any).collectPlayerState(12, 100);
expect(player.combat.targetIndex).toBe(7);
expect(player.combat.lastDamageTick).toBe(12);
expect((collector as any).combatEvents[0].tick).toBe(12);
});
test('normalizes raw client message cycles onto the public state clock', () => {
const client = createClient();
client.chatText = ['Hello'];
client.chatUsername = ['Guide'];
client.chatType = [0];
client.messageTick = [9_999];
const collector = new BotStateCollector(client);
expect((collector as any).collectGameMessages(5)[0].tick).toBe(5);
expect((collector as any).collectGameMessages(6)[0].tick).toBe(5);
client.chatText.unshift('New message');
client.chatUsername.unshift('');
client.chatType.unshift(0);
client.messageTick.unshift(10_000);
const messages = (collector as any).collectGameMessages(6);
expect(messages.at(-1).tick).toBe(6);
});
test('represents unrevealed NPC health as unknown rather than dead', () => {
const client = createClient();
const collector = new BotStateCollector(client);
const [npc] = (collector as any).collectNearbyNpcs(100);
expect(npc.hp).toBeNull();
expect(npc.maxHp).toBeNull();
expect(npc.healthPercent).toBeNull();
});
test('exposes death and respawn transitions with a stable life id', () => {
const client = createClient();
const collector = new BotStateCollector(client);
const alive = (collector as any).collectPlayerState(1, 100);
client.statEffectiveLevel[3] = 0;
const dead = (collector as any).collectPlayerState(2, 100);
client.statEffectiveLevel[3] = 10;
const respawned = (collector as any).collectPlayerState(3, 100);
expect(alive).toMatchObject({ isDead: false, lifeId: 1, respawnCount: 0 });
expect(dead).toMatchObject({ isDead: true, lifeId: 1, lastDeathTick: 2 });
expect(respawned).toMatchObject({ isDead: false, lifeId: 2, respawnCount: 1 });
});
});
+78 -40
View File
@@ -74,9 +74,17 @@ export class BotStateCollector implements ScanProvider {
// Combat event tracking
private combatEvents: CombatEvent[] = [];
private lastPlayerDamageCycles: Int32Array = new Int32Array(4);
private lastPlayerDamageTick: number = -1;
private lastNpcDamageCycles: Map<number, Int32Array> = new Map();
private lastNpcCombatTicks: Map<number, number> = new Map();
private static readonly MAX_EVENTS = 50;
private static readonly EVENT_EXPIRY_TICKS = 50;
private lastDeadState: boolean | null = null;
private lifeId: number = 0;
private respawnCount: number = 0;
private lastDeathTick: number | null = null;
private messagePublicTicks = new Map<string, number>();
private dialogPublicTicks = new Map<string, number>();
// Cached prayer component map (built once on first use)
private prayerComponentMap: PrayerComponentMap | null = null;
@@ -85,26 +93,26 @@ export class BotStateCollector implements ScanProvider {
this.client = client;
}
collectState(): BotState {
collectState(currentTick: number): BotState {
const c = this.client as any; // Access private members
const currentTick = c.loopCycle || 0;
const clientCycle = this.client.getClientCycle();
// Collect combat events (must be done before returning state)
this.collectCombatEvents(currentTick);
return {
tick: currentTick,
player: this.collectPlayerState(),
player: this.collectPlayerState(currentTick, clientCycle),
skills: this.collectSkills(),
inventory: this.collectInventory(INVENTORY_INTERFACE_ID),
equipment: this.collectInventory(EQUIPMENT_INTERFACE_ID),
combatStyle: this.collectCombatStyle(),
nearbyNpcs: this.collectNearbyNpcs(),
nearbyNpcs: this.collectNearbyNpcs(clientCycle),
nearbyPlayers: this.collectNearbyPlayers(),
nearbyLocs: this.scanNearbyLocs(),
groundItems: this.scanGroundItems(),
gameMessages: this.collectGameMessages(),
recentDialogs: this.collectRecentDialogs(),
gameMessages: this.collectGameMessages(currentTick),
recentDialogs: this.collectRecentDialogs(currentTick),
menuActions: this.collectMenuActions(),
shop: this.collectShopState(),
bank: this.collectBankState(),
@@ -227,11 +235,20 @@ export class BotStateCollector implements ScanProvider {
return { isOpen, options, isWaiting };
}
private collectRecentDialogs(): Array<{ text: string[]; tick: number; interfaceId: number }> {
if (typeof this.client.getDialogHistory === 'function') {
return this.client.getDialogHistory();
private collectRecentDialogs(currentTick: number): Array<{ text: string[]; tick: number; interfaceId: number }> {
if (typeof this.client.getDialogHistory !== 'function') return [];
const activeKeys = new Set<string>();
const dialogs = this.client.getDialogHistory().map(entry => {
const key = `${entry.tick}|${entry.interfaceId}|${entry.text.join('\u0000')}`;
activeKeys.add(key);
if (!this.dialogPublicTicks.has(key)) this.dialogPublicTicks.set(key, currentTick);
return { ...entry, tick: this.dialogPublicTicks.get(key)! };
});
for (const key of this.dialogPublicTicks.keys()) {
if (!activeKeys.has(key)) this.dialogPublicTicks.delete(key);
}
return [];
return dialogs;
}
private collectInterfaceState(): InterfaceState {
@@ -268,12 +285,13 @@ export class BotStateCollector implements ScanProvider {
if (cycle > lastCycle && cycle > 0) {
// New damage detected
const damage = player.damageValues[i] || 0;
this.lastPlayerDamageTick = currentTick;
this.combatEvents.push({
tick: currentTick,
type: 'damage_taken',
damage,
sourceType: 'npc', // Assume NPC source for now
sourceIndex: player.targetId ?? -1,
sourceIndex: player.faceEntity ?? -1,
targetType: 'player',
targetIndex: -1 // Self
});
@@ -305,8 +323,9 @@ export class BotStateCollector implements ScanProvider {
if (cycle > lastCycle && cycle > 0) {
const damage = npc.damageValues[j] || 0;
this.lastNpcCombatTicks.set(npcIndex, currentTick);
// Check if player is targeting this NPC (likely we dealt the damage)
const playerTarget = player?.targetId ?? -1;
const playerTarget = player?.faceEntity ?? -1;
const isPlayerSource = playerTarget === npcIndex;
this.combatEvents.push({
@@ -331,6 +350,7 @@ export class BotStateCollector implements ScanProvider {
for (const npcIndex of this.lastNpcDamageCycles.keys()) {
if (!activeNpcIndices.has(npcIndex)) {
this.lastNpcDamageCycles.delete(npcIndex);
this.lastNpcCombatTicks.delete(npcIndex);
}
}
@@ -340,40 +360,44 @@ export class BotStateCollector implements ScanProvider {
}
}
private collectPlayerState(): PlayerState | null {
private collectPlayerState(currentTick: number, clientCycle: number): PlayerState | null {
const c = this.client as any;
const player = c.localPlayer;
const loopCycle = c.loopCycle || 0;
if (!player) return null;
// Get player's combat state
const targetId = player.targetId ?? -1;
const targetId = player.faceEntity ?? -1;
// combatCycle is set to loopCycle + 400 when damage is taken/dealt
// So if combatCycle > loopCycle, we're in combat (within 400 ticks of last hit)
const combatCycle = player.combatCycle ?? -1000;
const inCombat = combatCycle > loopCycle;
// Find most recent damage tick from damageCycles array
let lastDamageTick = -1;
const damageCycles = player.damageCycles;
if (damageCycles) {
for (let i = 0; i < damageCycles.length; i++) {
if (damageCycles[i] > lastDamageTick) {
lastDamageTick = damageCycles[i];
}
}
}
const inCombat = targetId !== -1 || combatCycle > clientCycle;
// Hitpoints is skill index 3
const skillLevel = c.statEffectiveLevel || [];
const skillBaseLevel = c.statBaseLevel || [];
const hp = skillLevel[3] || 0;
const maxHp = skillBaseLevel[3] || 0;
const isDead = maxHp > 0 && hp <= 0;
if (this.lastDeadState === null) {
this.lastDeadState = isDead;
if (!isDead) this.lifeId = 1;
} else if (!this.lastDeadState && isDead) {
this.lastDeadState = true;
this.lastDeathTick = currentTick;
} else if (this.lastDeadState && !isDead) {
this.lastDeadState = false;
this.respawnCount++;
this.lifeId++;
}
return {
name: player.name || 'Unknown',
combatLevel: player.combatLevel || 0,
hp: skillLevel[3] || 0,
maxHp: skillBaseLevel[3] || 0,
hp,
maxHp,
x: player.x || 0,
z: player.z || 0,
worldX: (c.mapBuildBaseX || 0) + ((player.x || 0) >> 7),
@@ -386,8 +410,12 @@ export class BotStateCollector implements ScanProvider {
combat: {
inCombat,
targetIndex: targetId,
lastDamageTick
}
lastDamageTick: this.lastPlayerDamageTick
},
isDead,
lifeId: this.lifeId,
respawnCount: this.respawnCount,
lastDeathTick: this.lastDeathTick
};
}
@@ -571,7 +599,7 @@ export class BotStateCollector implements ScanProvider {
return items;
}
private collectNearbyNpcs(): NearbyNpc[] {
private collectNearbyNpcs(clientCycle: number): NearbyNpc[] {
const c = this.client as any;
const npcs: NearbyNpc[] = [];
const player = c.localPlayer;
@@ -615,16 +643,18 @@ export class BotStateCollector implements ScanProvider {
}
}
const hp = npc.health || 0;
const maxHp = npc.totalHealth || 0;
const healthKnown = (npc.totalHealth ?? 0) > 0;
const hp = healthKnown ? (npc.health ?? 0) : null;
const maxHp = healthKnown ? npc.totalHealth : null;
// healthPercent is null until NPC takes damage (server only sends health on hit)
const healthPercent = maxHp > 0 ? Math.round((hp / maxHp) * 100) : null;
const healthPercent = hp !== null && maxHp !== null && maxHp > 0
? Math.round((hp / maxHp) * 100)
: null;
const targetId = npc.faceEntity ?? -1;
// combatCycle is set to loopCycle + 400 when NPC takes damage
const combatCycle = npc.combatCycle ?? -1000;
const loopCycle = c.loopCycle || 0;
// NPC is in combat if it was hit recently (within 400 ticks of last damage)
const inCombat = combatCycle > loopCycle;
const inCombat = targetId !== -1 || combatCycle > clientCycle;
// Convert fine-grained local coords (128 units/tile) to world coordinates
const npcWorldX = baseX + (npcX >> 7);
@@ -642,7 +672,7 @@ export class BotStateCollector implements ScanProvider {
healthPercent,
targetIndex: targetId,
inCombat,
combatCycle,
lastCombatTick: this.lastNpcCombatTicks.get(npcIndex) ?? null,
animId: npc.primaryAnim ?? -1,
spotanimId: npc.spotanimId ?? -1,
optionsWithIndex,
@@ -931,7 +961,7 @@ export class BotStateCollector implements ScanProvider {
private static readonly MAX_GAME_MESSAGES = 50;
private collectGameMessages(): GameMessage[] {
private collectGameMessages(currentTick: number): GameMessage[] {
const c = this.client as any;
const messageText = c.chatText || [];
@@ -948,6 +978,7 @@ export class BotStateCollector implements ScanProvider {
// bot<->bot handoffs silently failed). Type-filtering is left to the SDK
// (sdk.getChat) so system messages stay available here too.
const messages: GameMessage[] = [];
const activeKeys = new Set<string>();
for (let i = 0; i < messageText.length && messages.length < BotStateCollector.MAX_GAME_MESSAGES; i++) {
const text = messageText[i];
if (!text) continue;
@@ -956,15 +987,22 @@ export class BotStateCollector implements ScanProvider {
// Strip @cr1@/@whi@ crown+colour codes so sender filtering is reliable.
const sender = String(messageSender[i] || '').replace(/@\w+@/g, '');
const fromSelf = type === 6 || (sender !== '' && sender.toLowerCase() === ownName);
const rawTick = messageTick[i] || 0;
const key = `${rawTick}|${type}|${sender}|${text}`;
activeKeys.add(key);
if (!this.messagePublicTicks.has(key)) this.messagePublicTicks.set(key, currentTick);
messages.push({
type,
text,
sender,
tick: messageTick[i] || 0,
tick: this.messagePublicTicks.get(key)!,
fromSelf
});
}
for (const key of this.messagePublicTicks.keys()) {
if (!activeKeys.has(key)) this.messagePublicTicks.delete(key);
}
// Return in chronological order (oldest first, newest last) so .slice(-N)
// callers and the formatter pick up the MOST RECENT messages.
+9 -2
View File
@@ -17,6 +17,11 @@ export function formatBotState(state: BotState): string {
lines.push(`${p.name} (Combat ${p.combatLevel})`);
lines.push(`Pos: (${p.worldX}, ${p.worldZ}) Level: ${p.level}`);
lines.push(`Run: ${p.runEnergy}% Weight: ${p.runWeight}kg`);
if (p.isDead) {
lines.push(`Status: DEAD Life: ${p.lifeId} Death tick: ${p.lastDeathTick ?? '?'}`);
} else if (p.respawnCount > 0) {
lines.push(`Life: ${p.lifeId} Respawns observed: ${p.respawnCount}`);
}
lines.push('');
}
@@ -57,7 +62,9 @@ export function formatBotState(state: BotState): string {
const npc = state.nearbyNpcs[i];
const name = npc.name.padEnd(16);
const lvl = npc.combatLevel > 0 ? `Lv${String(npc.combatLevel).padStart(2)}` : ' ';
const hp = npc.maxHp > 0 ? `${String(npc.hp).padStart(2)}/${String(npc.maxHp).padEnd(2)}` : ' ';
const hp = npc.hp !== null && npc.maxHp !== null
? `${String(npc.hp).padStart(2)}/${String(npc.maxHp).padEnd(2)}`
: ' ?/?';
const dist = `${npc.distance}t`.padStart(3);
const opts = npc.optionsWithIndex.map(o => o.text);
const opStr = opts.length > 0 ? `[${opts.join(',')}]` : '';
@@ -284,7 +291,7 @@ export function formatWorldStateForAgent(state: BotWorldState, goal: string): st
lines.push('### Nearby NPCs');
for (const npc of state.nearbyNpcs.slice(0, 8)) {
const lvl = npc.combatLevel > 0 ? ` (Lvl ${npc.combatLevel})` : '';
const hp = npc.maxHp > 0 ? ` HP: ${npc.hp}/${npc.maxHp}` : '';
const hp = npc.hp !== null && npc.maxHp !== null ? ` HP: ${npc.hp}/${npc.maxHp}` : '';
const opts = npc.options.length > 0 ? ` [${npc.options.join(', ')}]` : '';
lines.push(`- ${npc.name}${lvl}${hp} - ${npc.distance} tiles away, index: ${npc.index}${opts}`);
}
@@ -10,7 +10,7 @@
//
// Must export every symbol sdk/index.ts and sdk/actions.ts import from
// './pathfinding'. Currently: isZoneAllocated, findLongPath, findDoorsAlongPath,
// blockDoor. The rest are exported for completeness so any future import resolves.
// TemporaryDoorBlocklist. The rest are exported for completeness so any future import resolves.
export interface DoorInfo {
level: number;
@@ -21,6 +21,12 @@ export interface DoorInfo {
blockrange: boolean;
}
export class TemporaryDoorBlocklist {
block(_door: DoorInfo, _ttlMs?: number): void {}
active(): DoorInfo[] { return []; }
clear(): void {}
}
type Waypoint = { x: number; z: number; level: number };
export function initPathfinding(): void {
@@ -39,7 +45,8 @@ export function findLongPath(
_srcZ: number,
_destX: number,
_destZ: number,
_maxWaypoints?: number
_maxWaypoints?: number,
_blockedDoors?: Iterable<DoorInfo>
): Waypoint[] {
return [];
}
@@ -63,7 +70,3 @@ export function findDoorsAlongPath(_waypoints: Array<{ x: number; z: number }>):
export function getDoorAt(_level: number, _x: number, _z: number): DoorInfo | undefined {
return undefined;
}
export function blockDoor(_level: number, _x: number, _z: number): boolean {
return false;
}
+14 -6
View File
@@ -57,18 +57,18 @@ export interface NearbyNpc {
x: number;
z: number;
distance: number;
/** Current HP - NOTE: 0 until NPC takes damage (server only sends on hit) */
hp: number;
/** Max HP - NOTE: 0 until NPC takes damage (server only sends on hit) */
maxHp: number;
/** Current HP, or null until the server reveals it by updating the NPC. */
hp: number | null;
/** Maximum HP, or null until the server reveals it by updating the NPC. */
maxHp: number | null;
/** Health as percentage 0-100 (null until NPC takes damage) */
healthPercent: number | null;
/** Index of who this NPC is targeting (-1 if none) */
targetIndex: number;
/** Is this NPC currently in combat? (has target OR was hit within last 400 ticks) */
inCombat: boolean;
/** Combat cycle - set to tick+400 when NPC takes damage. Compare with state.tick for timing. */
combatCycle: number;
/** Public game tick when damage was last observed on this NPC. */
lastCombatTick: number | null;
/** Current animation ID (-1 = idle/none) */
animId: number;
/** Current spot animation ID (-1 = none) */
@@ -200,6 +200,14 @@ export interface PlayerState {
spotanimId: number;
/** Combat state tracking */
combat: PlayerCombatState;
/** True while the player's hitpoints are zero. */
isDead: boolean;
/** Changes after each observed death/respawn cycle. */
lifeId: number;
/** Number of respawns observed during this client session. */
respawnCount: number;
/** Public game tick when death was last observed, or null if none was observed. */
lastDeathTick: number | null;
}
// Combat style state
+152 -66
View File
@@ -101,6 +101,25 @@ export interface SayOutcome {
finalText: string;
}
export type ClientActionFailureReason =
| 'not_in_game'
| 'invalid_target'
| 'invalid_option'
| 'item_not_found'
| 'target_not_found'
| 'out_of_scene'
| 'cant_reach';
/**
* Result of validating and routing a client-side interaction.
*
* Success means the interaction packet was dispatched. Effect observation
* belongs to the higher-level BotActions API.
*/
export type ClientActionResult =
| { success: true }
| { success: false; reason: ClientActionFailureReason };
export class Client extends GameShell {
static nodeId: number = 10;
static memServer: boolean = true;
@@ -698,6 +717,11 @@ export class Client extends GameShell {
this.onGameTickCallback = callback;
}
/** Client-cycle clock used by entity animation and combat-cycle fields. */
getClientCycle(): number {
return Client.loopCycle;
}
/**
* Internal method to log a packet
*/
@@ -989,26 +1013,32 @@ export class Client extends GameShell {
* Talk to an NPC by index (sends OPNPC1 - first option, usually "Talk-to")
* Use findNpcByName to get the index
*/
talkToNpc(npcIndex: number): boolean {
if (!this.ingame || !this.out || !this.localPlayer || npcIndex < 0) {
return false;
talkToNpc(npcIndex: number): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return { success: false, reason: 'not_in_game' };
}
if (npcIndex < 0) {
return { success: false, reason: 'invalid_target' };
}
const n = this.npc[npcIndex];
if (!n) {
return false;
return { success: false, reason: 'target_not_found' };
}
// Walk to the NPC first. In 274's client-routefinder mode the server expects the
// client to send the route to an interaction target; without it the player never
// moves and the server replies "I can't reach that!". Mirrors spellOnNpc/useItemOnNpc.
this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], n.routeX[0], n.routeZ[0], false, 1, 1, 0, 0, 0, 2);
const routed = this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], n.routeX[0], n.routeZ[0], false, 1, 1, 0, 0, 0, 2);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
// Send OPNPC1 packet (Talk-to)
this.writePacketOpcode(ClientProt.OPNPC1);
this.out.p2(npcIndex);
return true;
return { success: true };
}
/**
@@ -1016,20 +1046,29 @@ export class Client extends GameShell {
* Option 1 is usually "Talk-to", Option 2 might be "Pickpocket", etc.
* Use getNearbyNpcs() to see available options for each NPC
*/
interactNpc(npcIndex: number, optionIndex: number): boolean {
if (!this.ingame || !this.out || !this.localPlayer || npcIndex < 0 || optionIndex < 1 || optionIndex > 5) {
return false;
interactNpc(npcIndex: number, optionIndex: number): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return { success: false, reason: 'not_in_game' };
}
if (npcIndex < 0) {
return { success: false, reason: 'invalid_target' };
}
if (optionIndex < 1 || optionIndex > 5) {
return { success: false, reason: 'invalid_option' };
}
const n = this.npc[npcIndex];
if (!n) {
return false;
return { success: false, reason: 'target_not_found' };
}
// Walk to the NPC first. In 274's client-routefinder mode the server expects the
// client to send the route to an interaction target; without it the player never
// moves and the server replies "I can't reach that!". Mirrors spellOnNpc/useItemOnNpc.
this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], n.routeX[0], n.routeZ[0], false, 1, 1, 0, 0, 0, 2);
const routed = this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], n.routeX[0], n.routeZ[0], false, 1, 1, 0, 0, 0, 2);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
// Send the appropriate OPNPC packet based on option index
const opcodes = [
@@ -1042,27 +1081,36 @@ export class Client extends GameShell {
this.writePacketOpcode(opcodes[optionIndex - 1]);
this.out.p2(npcIndex);
return true;
return { success: true };
}
/**
* Interact with a player using a specific option (1-4)
* Option 2 is Attack (wilderness only), Option 3 is Follow, Option 4 is Trade
*/
interactPlayer(playerIndex: number, optionIndex: number): boolean {
if (!this.ingame || !this.out || !this.localPlayer || playerIndex < 0 || optionIndex < 1 || optionIndex > 4) {
return false;
interactPlayer(playerIndex: number, optionIndex: number): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return { success: false, reason: 'not_in_game' };
}
if (playerIndex < 0) {
return { success: false, reason: 'invalid_target' };
}
if (optionIndex < 1 || optionIndex > 4) {
return { success: false, reason: 'invalid_option' };
}
const p = this.players[playerIndex];
if (!p) {
return false;
return { success: false, reason: 'target_not_found' };
}
// Walk to the player first. In 274's client-routefinder mode the server expects the
// client to send the route to an interaction target; without it the player never
// moves and the server replies "I can't reach that!". Mirrors spellOnNpc/useItemOnNpc.
this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], p.routeX[0], p.routeZ[0], false, 1, 1, 0, 0, 0, 2);
const routed = this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], p.routeX[0], p.routeZ[0], false, 1, 1, 0, 0, 0, 2);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
const opcodes = [
ClientProt.OPPLAYER1,
@@ -1073,7 +1121,7 @@ export class Client extends GameShell {
this.writePacketOpcode(opcodes[optionIndex - 1]);
this.out.p2(playerIndex);
return true;
return { success: true };
}
/**
@@ -1083,18 +1131,21 @@ export class Client extends GameShell {
* npcIndex: the index of the NPC to target
* spellComponent: the interface component ID of the spell (e.g., 1152 for Wind Strike)
*/
spellOnNpc(npcIndex: number, spellComponent: number): boolean {
if (!this.ingame || !this.out || !this.localPlayer || npcIndex < 0) {
return false;
spellOnNpc(npcIndex: number, spellComponent: number): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return { success: false, reason: 'not_in_game' };
}
if (npcIndex < 0) {
return { success: false, reason: 'invalid_target' };
}
const n = this.npc[npcIndex];
if (!n) {
return false;
return { success: false, reason: 'target_not_found' };
}
// Try to move towards the NPC
this.tryMove(
const routed = this.tryMove(
this.localPlayer.routeX[0],
this.localPlayer.routeZ[0],
n.routeX[0],
@@ -1105,13 +1156,16 @@ export class Client extends GameShell {
0, // forceapproach
2 // type = MOVE_OPCLICK
);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
// Send OPNPCT packet
this.writePacketOpcode(ClientProt.OPNPCT);
this.out.p2(npcIndex);
this.out.p2(spellComponent);
return true;
return { success: true };
}
/**
@@ -1155,20 +1209,20 @@ export class Client extends GameShell {
* Cast a spell on a ground item (e.g., Telekinetic Grab)
* Sends OPOBJT packet
*/
spellOnGroundItem(worldX: number, worldZ: number, itemId: number, spellComponent: number): boolean {
spellOnGroundItem(worldX: number, worldZ: number, itemId: number, spellComponent: number): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return false;
return { success: false, reason: 'not_in_game' };
}
const sceneX = worldX - this.mapBuildBaseX;
const sceneZ = worldZ - this.mapBuildBaseZ;
if (sceneX < 0 || sceneX >= 104 || sceneZ < 0 || sceneZ >= 104) {
return false;
return { success: false, reason: 'out_of_scene' };
}
// Try to move towards the ground item
this.tryMove(
const routed = this.tryMove(
this.localPlayer.routeX[0],
this.localPlayer.routeZ[0],
sceneX,
@@ -1179,6 +1233,9 @@ export class Client extends GameShell {
0,
2 // MOVE_OPCLICK
);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
// Send OPOBJT packet: x, z, itemId, spellComponent
this.writePacketOpcode(ClientProt.OPOBJT);
@@ -1187,16 +1244,16 @@ export class Client extends GameShell {
this.out.p2(itemId);
this.out.p2(spellComponent);
return true;
return { success: true };
}
/**
* Pick up a ground item at the specified world coordinates
* Use the groundItems from BotState to get item locations
*/
pickupGroundItem(worldX: number, worldZ: number, itemId: number): boolean {
pickupGroundItem(worldX: number, worldZ: number, itemId: number): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return false;
return { success: false, reason: 'not_in_game' };
}
// Convert world coordinates to scene coordinates
@@ -1205,11 +1262,11 @@ export class Client extends GameShell {
// Validate scene coordinates
if (sceneX < 0 || sceneX >= 104 || sceneZ < 0 || sceneZ >= 104) {
return false;
return { success: false, reason: 'out_of_scene' };
}
// First send MOVE_OPCLICK via tryMove (type=2) to walk to the item
this.tryMove(
const routed = this.tryMove(
this.localPlayer.routeX[0],
this.localPlayer.routeZ[0],
sceneX,
@@ -1220,6 +1277,9 @@ export class Client extends GameShell {
0, // forceapproach
2 // type = MOVE_OPCLICK
);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
// Then send OPOBJ3 packet (Take is option 3, not option 1)
this.writePacketOpcode(ClientProt.OPOBJ3);
@@ -1227,16 +1287,19 @@ export class Client extends GameShell {
this.out.p2(worldZ);
this.out.p2(itemId);
return true;
return { success: true };
}
/**
* Interact with a ground item using a specific option (1-5)
* Option 1 = op[0], Option 2 = op[1], Option 3 = Take (op[2]), etc.
*/
interactGroundItem(worldX: number, worldZ: number, itemId: number, optionIndex: number): boolean {
if (!this.ingame || !this.out || !this.localPlayer || optionIndex < 1 || optionIndex > 5) {
return false;
interactGroundItem(worldX: number, worldZ: number, itemId: number, optionIndex: number): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return { success: false, reason: 'not_in_game' };
}
if (optionIndex < 1 || optionIndex > 5) {
return { success: false, reason: 'invalid_option' };
}
// Convert world coordinates to scene coordinates
@@ -1245,11 +1308,11 @@ export class Client extends GameShell {
// Validate scene coordinates
if (sceneX < 0 || sceneX >= 104 || sceneZ < 0 || sceneZ >= 104) {
return false;
return { success: false, reason: 'out_of_scene' };
}
// First send MOVE_OPCLICK via tryMove (type=2) to walk to the item
this.tryMove(
const routed = this.tryMove(
this.localPlayer.routeX[0],
this.localPlayer.routeZ[0],
sceneX,
@@ -1260,6 +1323,9 @@ export class Client extends GameShell {
0, // forceapproach
2 // type = MOVE_OPCLICK
);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
// Then send OPOBJ packet
const opcodes = [
@@ -1274,7 +1340,7 @@ export class Client extends GameShell {
this.out.p2(worldZ);
this.out.p2(itemId);
return true;
return { success: true };
}
/**
@@ -1881,22 +1947,22 @@ export class Client extends GameShell {
/**
* Use an inventory item on an NPC (OPNPCU)
*/
useItemOnNpc(itemSlot: number, npcIndex: number, interfaceId: number = 3214): boolean {
useItemOnNpc(itemSlot: number, npcIndex: number, interfaceId: number = 3214): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
console.log('[Client] useItemOnNpc failed - not in game');
return false;
return { success: false, reason: 'not_in_game' };
}
const component = IfType.list[interfaceId];
if (!component || !component.linkObjType) {
console.log('[Client] useItemOnNpc failed - invalid interface');
return false;
return { success: false, reason: 'item_not_found' };
}
const rawItemId = component.linkObjType[itemSlot];
if (!rawItemId || rawItemId <= 0) {
console.log(`[Client] useItemOnNpc failed - no item in slot ${itemSlot}`);
return false;
return { success: false, reason: 'item_not_found' };
}
const itemObj = ObjType.list(rawItemId - 1);
const itemId = itemObj.id;
@@ -1904,16 +1970,19 @@ export class Client extends GameShell {
const n = this.npc[npcIndex];
if (!n) {
console.log(`[Client] useItemOnNpc failed - NPC ${npcIndex} not found`);
return false;
return { success: false, reason: 'target_not_found' };
}
this.tryMove(
const routed = this.tryMove(
this.localPlayer.routeX[0],
this.localPlayer.routeZ[0],
n.routeX[0],
n.routeZ[0],
false, 1, 1, 0, 0, 0, 2
);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
console.log(`[Client] Using item ${itemObj.name} (id:${itemId}, slot ${itemSlot}) on NPC ${npcIndex}`);
@@ -1923,28 +1992,28 @@ export class Client extends GameShell {
this.out.p2(itemSlot);
this.out.p2(interfaceId);
return true;
return { success: true };
}
/**
* Use an inventory item on a location (OPLOCU)
*/
useItemOnLoc(itemSlot: number, worldX: number, worldZ: number, locId: number, interfaceId: number = 3214): boolean {
useItemOnLoc(itemSlot: number, worldX: number, worldZ: number, locId: number, interfaceId: number = 3214): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
console.log('[Client] useItemOnLoc failed - not in game');
return false;
return { success: false, reason: 'not_in_game' };
}
const component = IfType.list[interfaceId];
if (!component || !component.linkObjType) {
console.log('[Client] useItemOnLoc failed - invalid interface');
return false;
return { success: false, reason: 'item_not_found' };
}
const rawItemId = component.linkObjType[itemSlot];
if (!rawItemId || rawItemId <= 0) {
console.log(`[Client] useItemOnLoc failed - no item in slot ${itemSlot}`);
return false;
return { success: false, reason: 'item_not_found' };
}
const itemObj = ObjType.list(rawItemId - 1);
const itemId = itemObj.id;
@@ -1954,14 +2023,20 @@ export class Client extends GameShell {
if (destSceneX < 0 || destSceneX > 103 || destSceneZ < 0 || destSceneZ > 103) {
console.log(`[Client] useItemOnLoc failed - location out of scene bounds`);
return false;
return { success: false, reason: 'out_of_scene' };
}
const loc = LocType.list(locId);
if (loc) {
let width = loc.width;
let height = loc.length;
this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], destSceneX, destSceneZ, false, width, height, 0, 0, 0, 2);
if (!loc) {
return { success: false, reason: 'target_not_found' };
}
const routed = this.tryMove(
this.localPlayer.routeX[0], this.localPlayer.routeZ[0],
destSceneX, destSceneZ,
false, loc.width, loc.length, 0, 0, 0, 2
);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
console.log(`[Client] Using item ${itemObj.name} (id:${itemId}, slot ${itemSlot}) on loc ${loc?.name || locId} at (${worldX}, ${worldZ})`);
@@ -1974,7 +2049,7 @@ export class Client extends GameShell {
this.out.p2(itemSlot);
this.out.p2(interfaceId);
return true;
return { success: true };
}
/**
@@ -2133,9 +2208,15 @@ export class Client extends GameShell {
/**
* Interact with a location/object in the world (doors, trees, etc.)
*/
interactLoc(worldX: number, worldZ: number, locId: number, optionIndex: number): boolean {
if (!this.ingame || !this.out || !this.localPlayer || optionIndex < 1 || optionIndex > 5) {
return false;
interactLoc(worldX: number, worldZ: number, locId: number, optionIndex: number): ClientActionResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return { success: false, reason: 'not_in_game' };
}
if (locId < 0) {
return { success: false, reason: 'invalid_target' };
}
if (optionIndex < 1 || optionIndex > 5) {
return { success: false, reason: 'invalid_option' };
}
// Walk to the loc first so the server receives a movement path. In 274's
@@ -2145,11 +2226,16 @@ export class Client extends GameShell {
// "I can't reach that!" whenever we aren't already adjacent. Mirrors useItemOnLoc.
const destSceneX = worldX - this.mapBuildBaseX;
const destSceneZ = worldZ - this.mapBuildBaseZ;
if (destSceneX >= 0 && destSceneX <= 103 && destSceneZ >= 0 && destSceneZ <= 103) {
const loc = LocType.list(locId);
if (loc) {
this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], destSceneX, destSceneZ, false, loc.width, loc.length, 0, 0, 0, 2);
}
if (destSceneX < 0 || destSceneX > 103 || destSceneZ < 0 || destSceneZ > 103) {
return { success: false, reason: 'out_of_scene' };
}
const loc = LocType.list(locId);
if (!loc) {
return { success: false, reason: 'target_not_found' };
}
const routed = this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], destSceneX, destSceneZ, false, loc.width, loc.length, 0, 0, 0, 2);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
const opcodes = [
@@ -2164,7 +2250,7 @@ export class Client extends GameShell {
this.out.p2(worldZ);
this.out.p2(locId);
return true;
return { success: true };
}
/**