mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
fix sdk state and action contracts
This commit is contained in:
1 parent
b20b027f01
commit
9337aae9af
22 files changed
+994
-229
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user