mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
combat improvements
This commit is contained in:
1 parent
448ff85b21
commit
160ffcbb50
24 files changed
+1256
-225
No files matched your search
@@ -142,7 +142,7 @@ For the complete method reference, see **[sdk/API.md](sdk/API.md)** (auto-genera
|
||||
|
||||
**Quick overview:**
|
||||
|
||||
- `bot.*` - High-level actions that attempt to observe method-specific evidence (chopTree, walkTo, attackNpc, etc.). Check a result when the method returns one.
|
||||
- `bot.*` - High-level actions that attempt to observe method-specific evidence (chopTree, walkTo, attack, etc.). Check a result when the method returns one.
|
||||
- `sdk.*` - State queries and low-level browser-client dispatches. A successful `send*` result does not prove the game server applied the effect.
|
||||
|
||||
### bot.\* Quick Reference
|
||||
@@ -153,9 +153,9 @@ For the complete method reference, see **[sdk/API.md](sdk/API.md)** (auto-genera
|
||||
| `talkTo(target)` | Walk to NPC, start dialog |
|
||||
| `interactNpc(target, option?)` | Walk to NPC, interact with any option (e.g. `'trade'`, `'fish'`) |
|
||||
| `interactLoc(target, option?)` | Walk to loc, interact with any option (e.g. `'mine'`, `'smelt'`) |
|
||||
| `attackNpc(target)` | Walk to NPC, start combat |
|
||||
| `attack(target)` | Walk to an NPC or player and start combat |
|
||||
| `castSpell(target, spell)` | Cast a combat spell on an NPC or player |
|
||||
| `pickpocketNpc(target)` | Pickpocket NPC, detects XP gain vs stun |
|
||||
| `castSpellOnNpc(target, spell)` | Cast combat spell on NPC |
|
||||
| `chopTree(target?)` | Chop tree, wait for logs |
|
||||
| `pickupItem(target)` | Pick up ground item |
|
||||
| `openDoor(target?)` | Open a door or gate |
|
||||
@@ -188,6 +188,7 @@ For the complete method reference, see **[sdk/API.md](sdk/API.md)** (auto-genera
|
||||
| `getSkill(name)` / `getSkillXp(name)` | Skill info |
|
||||
| `getInventory()` / `findInventoryItem(pattern)` | Inventory queries |
|
||||
| `findNearbyNpc(pattern)` / `findNearbyLoc(pattern)` | Find nearby entities |
|
||||
| `findNearbyPlayer(pattern)` / `getNearbyPlayers()` | Find other players |
|
||||
| `findGroundItem(pattern)` | Find ground items |
|
||||
| `getDialog()` | Current dialog state |
|
||||
| `sendClickDialog(option)` | Click dialog option by its published `index` |
|
||||
|
||||
+8
-7
@@ -4,11 +4,11 @@ Successful patterns for combat training.
|
||||
|
||||
## Attacking NPCs
|
||||
|
||||
Use `bot.attackNpc()` for cleaner code, or raw SDK for more control:
|
||||
Use `bot.attack()` for cleaner code, or raw SDK for more control:
|
||||
|
||||
```typescript
|
||||
// Porcelain method (recommended)
|
||||
const attack = await bot.attackNpc(/cow/i);
|
||||
const attack = await bot.attack(/cow/i);
|
||||
if (!attack.success) console.warn(attack.reason, attack.message);
|
||||
|
||||
// Raw SDK dispatch (observe combat state afterwards)
|
||||
@@ -23,13 +23,14 @@ if (npc && attackOpt) {
|
||||
|
||||
## Combat Style Cycling
|
||||
|
||||
Combat-style indices depend on the equipped weapon. Resolve them from the
|
||||
current state instead of hard-coding `0..3`:
|
||||
Combat-style indices depend on the equipped weapon the SDK reads
|
||||
the table from the combat tab the server installed, so resolve indices from state
|
||||
|
||||
```typescript
|
||||
const targetSkill = 'Defence';
|
||||
// A controlled style lists every skill it trains, so match against trainsSkills.
|
||||
const style = sdk.getState()?.combatStyle?.styles.find(
|
||||
candidate => candidate.trainedSkill === targetSkill,
|
||||
candidate => candidate.trainsSkills.includes(targetSkill),
|
||||
);
|
||||
if (!style) {
|
||||
throw new Error(`No ${targetSkill} style is available for the equipped weapon`);
|
||||
@@ -143,7 +144,7 @@ Timeouts and errors are frequent in crowded areas. Wrap attacks in try/catch:
|
||||
```typescript
|
||||
// This pattern enabled consistent 10-minute runs
|
||||
try {
|
||||
await bot.attackNpc(/cow/i);
|
||||
await bot.attack(/cow/i);
|
||||
} catch (err) {
|
||||
console.log(`Attack timed out, trying next cow`);
|
||||
continue; // Don't crash - just find another target
|
||||
@@ -196,7 +197,7 @@ function getLowestCombatStat(state) {
|
||||
const state = sdk.getState();
|
||||
if (!state) throw new Error('No world state');
|
||||
const stat = getLowestCombatStat(state);
|
||||
const style = state.combatStyle?.styles.find(option => option.trainedSkill === stat);
|
||||
const style = state.combatStyle?.styles.find(option => option.trainsSkills.includes(stat));
|
||||
if (!style) throw new Error(`No style trains ${stat} with the equipped weapon`);
|
||||
await sdk.sendSetCombatStyle(style.index);
|
||||
console.log(`Training ${stat} with ${style.name}`);
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ See [`sdk/API.md`](../sdk/API.md) for the full auto-generated API documentation.
|
||||
|
||||
- Movement: `walkTo(x, z)`
|
||||
- Skills: `chopTree()`, `burnLogs()`, `fletchLogs()`, `smithAtAnvil()`, `craftLeather()`
|
||||
- Combat: `attackNpc(target)`, `eatFood(target)`, `castSpellOnNpc(target, spell)`
|
||||
- Combat: `attack(target)`, `eatFood(target)`, `castSpell(target, spell)`
|
||||
- Interaction: `interactLoc(target, option)`, `interactNpc(target, option)`, `talkTo(target)`
|
||||
- Banking: `openBank()`, `depositItem()`, `withdrawItem()`
|
||||
- Shopping: `openShop()`, `buyFromShop()`, `sellToShop()`
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
{
|
||||
uri: 'file://../sdk/API.md',
|
||||
name: 'SDK API Reference',
|
||||
description: 'Auto-generated reference for bot.* (high-level actions: chopTree, walkTo, attackNpc, openBank, ...) and sdk.* (low-level: getState, sendWalk, findNearbyNpc, ...).',
|
||||
description: 'Auto-generated reference for bot.* (high-level actions: chopTree, walkTo, attack, openBank, ...) and sdk.* (low-level: getState, sendWalk, findNearbyNpc, ...).',
|
||||
mimeType: 'text/markdown'
|
||||
}
|
||||
]
|
||||
|
||||
+25
-7
@@ -90,8 +90,10 @@
|
||||
| `getEquipment(): InventoryItem[]` | Get all currently equipped items. |
|
||||
| `findEquippedItem(pattern: string \| RegExp): InventoryItem \| null` | Find an equipped item by name pattern. |
|
||||
| `async eatFood(target: InventoryItem \| string \| RegExp): Promise<EatResult>` | Eat food to restore hitpoints. |
|
||||
| `async attackNpc(target: NearbyNpc \| string \| RegExp, timeout: number = 5000): Promise<AttackResult>` | Attack an NPC, walking to it if needed. |
|
||||
| `async castSpellOnNpc(target: NearbyNpc \| string \| RegExp, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult>` | Cast a combat spell on an NPC. |
|
||||
| `async attack(target: CombatTarget, timeout: number = 5000): Promise<AttackResult>` | Attack an NPC or another player, walking to the target if needed. Takes either an entity (from `sdk.findNearbyNpc`/`sdk.findNearbyPlayer`) or a name/pattern, matched against NPCs first and players second - so pass the entity when a player shares a name with a monster. ```ts await bot.attack(/^chicken$/i); await bot.attack(sdk.findNearbyPlayer('Zezima')!); ``` PvP attacks are refused outside the wilderness and across too big a level gap; those come back as `reason: 'not_attackable'` with the server's own wording in `message`. |
|
||||
| `async attackPlayer(target: NearbyPlayer \| string \| RegExp, timeout: number = 5000): Promise<AttackResult>` | Attack another player (OPPLAYER2). Prefer {@link attack}, which also takes NPCs. |
|
||||
| `async castSpell(target: CombatTarget, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult>` | Cast a combat spell on an NPC or another player. The two are the same action to the server (OPNPCT vs OPPLAYERT), so this takes either: an entity from `sdk.findNearbyNpc`/`sdk.findNearbyPlayer`, or a name/pattern matched against NPCs first and players second. ```ts await bot.castSpell('goblin', Spells.WIND_STRIKE); await bot.castSpell(sdk.findNearbyPlayer('Zezima')!, Spells.FIRE_STRIKE); ``` Magic XP is the evidence of a cast landing, so a splash still counts as success with `hit: false`. |
|
||||
| `async castSpellOnPlayer(target: NearbyPlayer \| string \| RegExp, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult>` | Cast a combat spell on another player (OPPLAYERT). Prefer {@link castSpell}. |
|
||||
|
||||
### Condition Waiting
|
||||
|
||||
@@ -165,6 +167,8 @@
|
||||
| `getNearbyNpc(index: number): NearbyNpc \| null` | Get NPC by index. |
|
||||
| `findNearbyNpc(pattern: string \| RegExp): NearbyNpc \| null` | Find NPC by name pattern. |
|
||||
| `getNearbyNpcs(): NearbyNpc[]` | Get all nearby NPCs. |
|
||||
| `findNearbyPlayer(pattern: string \| RegExp): NearbyPlayer \| null` | Find a nearby player by name pattern (nearest match first). |
|
||||
| `getNearbyPlayers(): NearbyPlayer[]` | Get all nearby players, nearest first. |
|
||||
| `getNearbyLoc(x: number, z: number, id: number): NearbyLoc \| null` | Get location (object) by coordinates and ID. |
|
||||
| `findNearbyLoc(pattern: string \| RegExp): NearbyLoc \| null` | Find location by name pattern. |
|
||||
| `getNearbyLocs(): NearbyLoc[]` | Get all nearby locations (trees, rocks, etc). |
|
||||
@@ -215,7 +219,7 @@
|
||||
| `async sendWalk(x: number, z: number, running: boolean = true): Promise<ActionResult>` | Send walk command to coordinates. |
|
||||
| `async sendInteractLoc(x: number, z: number, locId: number, option: number = 1): Promise<ActionResult>` | Interact with a location (tree, rock, door, etc). |
|
||||
| `async sendInteractNpc(npcIndex: number, option: number = 1): Promise<ActionResult>` | Interact with an NPC by index and option. |
|
||||
| `async sendInteractPlayer(playerIndex: number, option: number = 2): Promise<ActionResult>` | Interact with a player by index and option. Option 2 = Attack (wilderness), 3 = Follow, 4 = Trade. |
|
||||
| `async sendInteractPlayer(playerIndex: number, option: number = 2): Promise<ActionResult>` | Interact with a player by index and option (1-5). Option 2 = Attack (wilderness), 3 = Follow, 4 = Trade. |
|
||||
| `async sendTalkToNpc(npcIndex: number): Promise<ActionResult>` | Talk to an NPC by index. |
|
||||
| `async sendPickup(x: number, z: number, itemId: number): Promise<ActionResult>` | Pick up a ground item. |
|
||||
| `async sendUseItem(slot: number, option: number = 1): Promise<ActionResult>` | Use an inventory item (eat, equip, etc). |
|
||||
@@ -238,7 +242,9 @@
|
||||
| `async sendCountDialog(value: number): Promise<ActionResult>` | Submit a numeric value to an open p_countdialog (Enter Amount) prompt. |
|
||||
| `async sendSetCombatStyle(style: number): Promise<ActionResult>` | Set combat style (0-3). |
|
||||
| `async sendTogglePrayer(prayer: PrayerName \| number): Promise<ActionResult>` | Toggle a prayer on or off by name or index (0-14). |
|
||||
| `async sendSpellOnNpc(npcIndex: number, spellComponent: number): Promise<ActionResult>` | Cast spell on NPC using spell component ID. |
|
||||
| `async sendSpellOnNpc(npcIndex: number, spellComponent: number): Promise<ActionResult>` | Cast spell on NPC using spell component ID (OPNPCT). |
|
||||
| `async sendSpellOnPlayer(playerIndex: number, spellComponent: number): Promise<ActionResult>` | Cast spell on another player using spell component ID (OPPLAYERT). `playerIndex` is a world slot from `nearbyPlayers`, a different space from npc indices - use {@link sendSpellOnTarget} to avoid mixing them up. |
|
||||
| `async sendSpellOnTarget(target: NearbyNpc \| NearbyPlayer, spellComponent: number): Promise<ActionResult>` | Cast a spell on whatever the target is - npc or player - picking the right packet from `target.kind`. This is the one to reach for in code that fights both, e.g. `sdk.sendSpellOnTarget(sdk.findNearbyPlayer('Zezima'), Spells.WIND_STRIKE)`. |
|
||||
| `async sendSpellOnItem(slot: number, spellComponent: number): Promise<ActionResult>` | Cast spell on inventory item. |
|
||||
| `async sendSpellOnGroundItem(x: number, z: number, itemId: number, spellComponent: number): Promise<ActionResult>` | Cast spell on ground item (e.g., Telekinetic Grab). |
|
||||
| `async sendSetTab(tabIndex: number): Promise<ActionResult>` | Switch to a UI tab by index. |
|
||||
@@ -268,8 +274,10 @@ Combat state tracking for player
|
||||
interface PlayerCombatState {
|
||||
/** Currently engaged in combat (has a target) */
|
||||
inCombat: boolean;
|
||||
/** Index of NPC/player we're targeting (-1 if none) */
|
||||
/** Index of the NPC/player we're targeting (-1 if none), already decoded: the client packs player targets as index + 32768, this does not. Read alongside `targetType` - index 7 is a different entity in each space. */
|
||||
targetIndex: number;
|
||||
/** What `targetIndex` refers to. */
|
||||
targetType: 'npc' | 'player' | 'none';
|
||||
/** Tick when we last took damage (-1 if never) */
|
||||
lastDamageTick: number;
|
||||
}
|
||||
@@ -371,6 +379,10 @@ interface CombatStyleState {
|
||||
currentStyle: number;
|
||||
weaponName: string;
|
||||
styles: CombatStyleOption[];
|
||||
/** Interface id of the combat tab the server installed - the weapon category, effectively. */
|
||||
tabInterfaceId: number;
|
||||
/** False when the combat tab is unrecognised and style metadata is unknown rather than guessed. */
|
||||
known: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -550,7 +562,10 @@ interface EatResult {
|
||||
interface AttackResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
reason?: 'npc_not_found' | 'no_attack_option' | 'out_of_reach' | 'already_in_combat' | 'died' | 'timeout';
|
||||
/** `npc_not_found` also covers players: no target matched. */
|
||||
reason?: 'npc_not_found' | 'no_attack_option' | 'out_of_reach' | 'already_in_combat' | 'not_attackable' | 'died' | 'timeout';
|
||||
/** What the resolved target was, when one was found. */
|
||||
targetType?: 'npc' | 'player';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -562,7 +577,10 @@ interface CastSpellResult {
|
||||
message: string;
|
||||
hit?: boolean;
|
||||
xpGained?: number;
|
||||
reason?: 'npc_not_found' | 'out_of_reach' | 'no_runes' | 'timeout';
|
||||
/** `npc_not_found` also covers players: no target matched. */
|
||||
reason?: 'npc_not_found' | 'out_of_reach' | 'no_runes' | 'not_attackable' | 'timeout';
|
||||
/** What the resolved target was, when one was found. */
|
||||
targetType?: 'npc' | 'player';
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -5,14 +5,76 @@ import { BotSDK } from './index';
|
||||
import type {
|
||||
NearbyLoc,
|
||||
NearbyNpc,
|
||||
NearbyPlayer,
|
||||
CombatTarget,
|
||||
InventoryItem,
|
||||
GroundItem,
|
||||
ShopItem,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Server refusals that stop a PvP attack or spell before it happens, lowercased.
|
||||
* From `~pvp_is_attackable` and friends in
|
||||
* server/content/scripts/skill_combat/scripts/pvp/pvp_combat.rs2.
|
||||
*/
|
||||
export const PVP_REFUSALS = [
|
||||
"you can't attack players who aren't in the wilderness",
|
||||
'your level difference is too great',
|
||||
"i'm already under attack",
|
||||
'someone else is already fighting your opponent',
|
||||
'kolodion allows only magical combat'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Refusals that clear on their own: single-way combat locks a fight to two
|
||||
* participants for 8 ticks after the last hit, so retrying later can work. The
|
||||
* other {@link PVP_REFUSALS} need you to move or pick a different opponent.
|
||||
*
|
||||
* Both entity spaces are here - the npc wording comes from player_combat.rs2
|
||||
* ("I'm already under attack!", "Someone else is fighting that.") and the pvp
|
||||
* wording from pvp_combat.rs2.
|
||||
*/
|
||||
export const ALREADY_FIGHTING_REFUSALS = [
|
||||
'already under attack',
|
||||
'someone else is fighting',
|
||||
'someone else is already fighting your opponent'
|
||||
] as const;
|
||||
|
||||
/** "You do not have enough..." / "You don't have enough...", the missing-rune refusal. */
|
||||
export const NO_RUNES_REFUSALS = [
|
||||
'do not have enough',
|
||||
"don't have enough"
|
||||
] as const;
|
||||
|
||||
export class ActionHelpers {
|
||||
constructor(private sdk: BotSDK) {}
|
||||
|
||||
/**
|
||||
* The first message published since a getMessageTick() cursor that contains
|
||||
* one of `phrases` (lowercase), or null. Refusals are the reason a combat
|
||||
* action silently does nothing: the packet lands, the server checks its
|
||||
* rules, and answers with a game message instead of an effect.
|
||||
*/
|
||||
findRefusal(sinceMessageTick: number, phrases: readonly string[]): string | null {
|
||||
const state = this.sdk.getState();
|
||||
if (!state) return null;
|
||||
|
||||
for (const msg of state.gameMessages) {
|
||||
if (this.isMessageAfter(msg, sinceMessageTick)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (phrases.some(phrase => text.includes(phrase))) {
|
||||
return msg.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The first PvP refusal since a getMessageTick() cursor, or null. */
|
||||
findPvpRefusal(sinceMessageTick: number): string | null {
|
||||
return this.findRefusal(sinceMessageTick, PVP_REFUSALS);
|
||||
}
|
||||
|
||||
// ============ Door Retry Wrapper ============
|
||||
|
||||
/**
|
||||
@@ -448,6 +510,21 @@ export class ActionHelpers {
|
||||
return this.sdk.findNearbyNpc(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a combat target that may be an npc or another player.
|
||||
*
|
||||
* A passed-in entity is used as-is (its `kind` says which it is). A name or
|
||||
* pattern is matched against nearby NPCs first and players second, so a
|
||||
* player called "Goblin" standing next to a goblin does not steal the fight;
|
||||
* pass the entity from `sdk.findNearbyPlayer(...)` when you mean the player.
|
||||
*/
|
||||
resolveCombatTarget(target: CombatTarget): NearbyNpc | NearbyPlayer | null {
|
||||
if (typeof target === 'object' && 'index' in target) {
|
||||
return target;
|
||||
}
|
||||
return this.sdk.findNearbyNpc(target) ?? this.sdk.findNearbyPlayer(target);
|
||||
}
|
||||
|
||||
resolveShopItem(
|
||||
target: ShopItem | InventoryItem | string | RegExp,
|
||||
items: ShopItem[]
|
||||
|
||||
+242
-71
@@ -3,7 +3,7 @@
|
||||
// Actions resolve when the EFFECT is complete (not just acknowledged)
|
||||
|
||||
import { BotSDK } from './index';
|
||||
import { ActionHelpers } from './actions-helpers';
|
||||
import { ActionHelpers, ALREADY_FIGHTING_REFUSALS, NO_RUNES_REFUSALS } from './actions-helpers';
|
||||
import { findDoorsAlongPath, isTileWalkable } from './pathfinding';
|
||||
import type {
|
||||
ActionResult,
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
InventoryItem,
|
||||
BankItem,
|
||||
NearbyNpc,
|
||||
NearbyPlayer,
|
||||
CombatTarget,
|
||||
NearbyLoc,
|
||||
GroundItem,
|
||||
ShopItem,
|
||||
@@ -1759,14 +1761,49 @@ export class BotActions {
|
||||
}
|
||||
}
|
||||
|
||||
/** Attack an NPC, walking to it if needed. */
|
||||
async attackNpc(target: NearbyNpc | string | RegExp, timeout: number = 5000): Promise<AttackResult> {
|
||||
/**
|
||||
* Attack an NPC or another player, walking to the target if needed.
|
||||
*
|
||||
* Takes either an entity (from `sdk.findNearbyNpc`/`sdk.findNearbyPlayer`) or
|
||||
* a name/pattern, matched against NPCs first and players second - so pass the
|
||||
* entity when a player shares a name with a monster.
|
||||
*
|
||||
* ```ts
|
||||
* await bot.attack(/^chicken$/i);
|
||||
* await bot.attack(sdk.findNearbyPlayer('Zezima')!);
|
||||
* ```
|
||||
*
|
||||
* PvP attacks are refused outside the wilderness and across too big a level
|
||||
* gap; those come back as `reason: 'not_attackable'` with the server's own
|
||||
* wording in `message`.
|
||||
*/
|
||||
async attack(target: CombatTarget, timeout: number = 5000): Promise<AttackResult> {
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
const npc = this.helpers.resolveNpc(target);
|
||||
if (!npc) {
|
||||
return { success: false, message: `NPC not found: ${target}`, reason: 'npc_not_found' };
|
||||
const entity = this.helpers.resolveCombatTarget(target);
|
||||
if (!entity) {
|
||||
return { success: false, message: `Target not found: ${target}`, reason: 'npc_not_found' };
|
||||
}
|
||||
return entity.kind === 'player'
|
||||
? this.attackPlayerEntity(entity, timeout)
|
||||
: this.attackNpcEntity(entity, timeout);
|
||||
}
|
||||
|
||||
/** Attack another player (OPPLAYER2). Prefer {@link attack}, which also takes NPCs. */
|
||||
async attackPlayer(target: NearbyPlayer | string | RegExp, timeout: number = 5000): Promise<AttackResult> {
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
const player = typeof target === 'object' && 'index' in target
|
||||
? target
|
||||
: this.sdk.findNearbyPlayer(target);
|
||||
if (!player) {
|
||||
return { success: false, message: `Player not found: ${target}`, reason: 'npc_not_found' };
|
||||
}
|
||||
return this.attackPlayerEntity(player, timeout);
|
||||
}
|
||||
|
||||
private async attackNpcEntity(npc: NearbyNpc, timeout: number): Promise<AttackResult> {
|
||||
const targetType = 'npc' as const;
|
||||
|
||||
// Sanity check: NPC coordinates should be within reasonable distance of player
|
||||
// If coords are wildly off, the NPC data is corrupted
|
||||
@@ -1778,20 +1815,20 @@ export class BotActions {
|
||||
// If calculated coord distance is way more than reported distance, coords are bad
|
||||
// Allow tolerance of 5 tiles for small distances (handles distance=0 edge case)
|
||||
if (coordDist > 200 || (npc.distance > 0 && npc.distance < 50 && coordDist > Math.max(5, npc.distance * 3))) {
|
||||
return { success: false, message: `NPC "${npc.name}" has invalid coordinates`, reason: 'npc_not_found' };
|
||||
return { success: false, message: `NPC "${npc.name}" has invalid coordinates`, reason: 'npc_not_found', targetType };
|
||||
}
|
||||
}
|
||||
|
||||
const attackOpt = npc.optionsWithIndex.find(o => /attack/i.test(o.text));
|
||||
if (!attackOpt) {
|
||||
return { success: false, message: `No attack option on ${npc.name}`, reason: 'no_attack_option' };
|
||||
return { success: false, message: `No attack option on ${npc.name}`, reason: 'no_attack_option', targetType };
|
||||
}
|
||||
|
||||
// Walk near NPC first - this handles doors
|
||||
if (npc.distance > 2) {
|
||||
const walkResult = await this.walkTo(npc.x, npc.z, 2);
|
||||
if (!walkResult.success) {
|
||||
return { success: false, message: `Cannot reach ${npc.name}: ${walkResult.message}`, reason: 'out_of_reach' };
|
||||
return { success: false, message: `Cannot reach ${npc.name}: ${walkResult.message}`, reason: 'out_of_reach', targetType };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1807,21 +1844,16 @@ export class BotActions {
|
||||
return {
|
||||
success: false,
|
||||
message: result.message,
|
||||
reason: result.reason === 'cant_reach' ? 'out_of_reach' : 'timeout'
|
||||
reason: result.reason === 'cant_reach' ? 'out_of_reach' : 'timeout',
|
||||
targetType
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const finalState = await this.waitForActionCondition(state => {
|
||||
for (const msg of state.gameMessages) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("someone else is fighting") ||
|
||||
text.includes("already under attack") ||
|
||||
text.includes("can't reach")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (this.helpers.findRefusal(msgBaseline, ALREADY_FIGHTING_REFUSALS) ||
|
||||
this.helpers.checkCantReachMessage(msgBaseline)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const targetNpc = state.nearbyNpcs.find(n => n.index === npc.index);
|
||||
@@ -1833,7 +1865,9 @@ export class BotActions {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (state.player?.combat.inCombat && state.player.combat.targetIndex === npc.index) {
|
||||
if (state.player?.combat.inCombat &&
|
||||
state.player.combat.targetType === 'npc' &&
|
||||
state.player.combat.targetIndex === npc.index) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1847,11 +1881,12 @@ export class BotActions {
|
||||
|
||||
if (finalState.player?.isDead ||
|
||||
(startLifeId !== undefined && finalState.player?.lifeId !== startLifeId)) {
|
||||
return { success: false, message: `Died while attacking ${npc.name}`, reason: 'died' };
|
||||
return { success: false, message: `Died while attacking ${npc.name}`, reason: 'died', targetType };
|
||||
}
|
||||
|
||||
const engagedRequestedTarget =
|
||||
(finalState.player?.combat.inCombat &&
|
||||
finalState.player.combat.targetType === 'npc' &&
|
||||
finalState.player.combat.targetIndex === npc.index) ||
|
||||
finalState.combatEvents.some(event =>
|
||||
eventAfterStart(event) &&
|
||||
@@ -1860,71 +1895,207 @@ export class BotActions {
|
||||
event.targetIndex === npc.index
|
||||
);
|
||||
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (this.helpers.isMessageAfter(msg, 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. Only requested-target evidence can turn
|
||||
// this refusal into success.
|
||||
if (engagedRequestedTarget) {
|
||||
return { success: true, message: `Already fighting ${npc.name}` };
|
||||
}
|
||||
return { success: false, message: `${npc.name} is already in combat`, reason: 'already_in_combat' };
|
||||
}
|
||||
if (this.helpers.checkCantReachMessage(msgBaseline)) {
|
||||
return { success: false, message: `Cannot reach ${npc.name}`, reason: 'out_of_reach', targetType };
|
||||
}
|
||||
|
||||
if (this.helpers.findRefusal(msgBaseline, ALREADY_FIGHTING_REFUSALS)) {
|
||||
// "I'm already under attack!" can race with a successful
|
||||
// engagement. Only requested-target evidence can turn this
|
||||
// refusal into success.
|
||||
if (engagedRequestedTarget) {
|
||||
return { success: true, message: `Already fighting ${npc.name}`, targetType };
|
||||
}
|
||||
return { success: false, message: `${npc.name} is already in combat`, reason: 'already_in_combat', targetType };
|
||||
}
|
||||
|
||||
if (engagedRequestedTarget) {
|
||||
return { success: true, message: `Attacking ${npc.name}` };
|
||||
return { success: true, message: `Attacking ${npc.name}`, targetType };
|
||||
}
|
||||
|
||||
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' };
|
||||
? { success: false, message: `No combat effect observed for ${npc.name}`, reason: 'timeout', targetType }
|
||||
: { success: false, message: `${npc.name} disappeared before combat was observed`, reason: 'npc_not_found', targetType };
|
||||
} catch {
|
||||
return { success: false, message: `Timeout waiting to attack ${npc.name}`, reason: 'timeout' };
|
||||
return { success: false, message: `Timeout waiting to attack ${npc.name}`, reason: 'timeout', targetType };
|
||||
}
|
||||
}
|
||||
|
||||
/** Cast a combat spell on an NPC. */
|
||||
async castSpellOnNpc(target: NearbyNpc | string | RegExp, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult> {
|
||||
await this.dismissBlockingUI();
|
||||
/**
|
||||
* Attack a resolved player with OPPLAYER2, the option `[opplayer2,_]` in
|
||||
* pvp_combat.rs2 handles. Evidence is the same shape as for NPCs - engagement
|
||||
* or a damage splat on the requested target - plus the PvP refusals, which
|
||||
* arrive as ordinary game messages and otherwise look like a silent no-op.
|
||||
*/
|
||||
private async attackPlayerEntity(player: NearbyPlayer, timeout: number): Promise<AttackResult> {
|
||||
const targetType = 'player' as const;
|
||||
|
||||
const npc = this.helpers.resolveNpc(target);
|
||||
if (!npc) {
|
||||
return { success: false, message: `NPC not found: ${target}`, reason: 'npc_not_found' };
|
||||
// Walk near the target first - this handles doors. Players move, so this
|
||||
// only closes the gap; the server routes the last step from the packet.
|
||||
if (player.distance > 2) {
|
||||
const walkResult = await this.walkTo(player.x, player.z, 2);
|
||||
if (!walkResult.success) {
|
||||
return { success: false, message: `Cannot reach ${player.name}: ${walkResult.message}`, reason: 'out_of_reach', targetType };
|
||||
}
|
||||
}
|
||||
|
||||
const startState = this.sdk.getState();
|
||||
const startTick = startState?.tick || 0;
|
||||
const startRevision = startState?.revision ?? startTick;
|
||||
const startLifeId = startState?.player?.lifeId;
|
||||
const eventAfterStart = (event: { tick: number; observationId?: number }) =>
|
||||
(event.observationId ?? event.tick) > startRevision;
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
|
||||
const engaged = (state: NonNullable<ReturnType<BotSDK['getState']>>) =>
|
||||
(state.player?.combat.inCombat === true &&
|
||||
state.player.combat.targetType === 'player' &&
|
||||
state.player.combat.targetIndex === player.index) ||
|
||||
state.combatEvents.some(event =>
|
||||
eventAfterStart(event) &&
|
||||
event.type === 'damage_dealt' &&
|
||||
event.targetType === 'other_player' &&
|
||||
event.targetIndex === player.index
|
||||
);
|
||||
|
||||
const result = await this.sdk.sendInteractPlayer(player.index, 2);
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.message,
|
||||
reason: result.reason === 'cant_reach' ? 'out_of_reach' : 'timeout',
|
||||
targetType
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const finalState = await this.waitForActionCondition(state => {
|
||||
if (this.helpers.findPvpRefusal(msgBaseline)) return true;
|
||||
|
||||
for (const msg of state.gameMessages) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline) &&
|
||||
msg.text.toLowerCase().includes("can't reach")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.player?.isDead || (startLifeId !== undefined && state.player?.lifeId !== startLifeId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Target logged out or ran out of view
|
||||
if (!state.nearbyPlayers.some(candidate => candidate.index === player.index)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return engaged(state);
|
||||
}, timeout);
|
||||
|
||||
if (finalState.player?.isDead ||
|
||||
(startLifeId !== undefined && finalState.player?.lifeId !== startLifeId)) {
|
||||
return { success: false, message: `Died while attacking ${player.name}`, reason: 'died', targetType };
|
||||
}
|
||||
|
||||
const engagedRequestedTarget = engaged(finalState);
|
||||
|
||||
const refusal = this.helpers.findPvpRefusal(msgBaseline);
|
||||
if (refusal) {
|
||||
// Auto-retaliate can land a hit in the same tick the server
|
||||
// refuses the manual attack; only requested-target evidence
|
||||
// turns the refusal into success.
|
||||
if (engagedRequestedTarget) {
|
||||
return { success: true, message: `Already fighting ${player.name}`, targetType };
|
||||
}
|
||||
const alreadyFighting = ALREADY_FIGHTING_REFUSALS.some(text => refusal.toLowerCase().includes(text));
|
||||
return {
|
||||
success: false,
|
||||
message: `Cannot attack ${player.name}: ${refusal}`,
|
||||
reason: alreadyFighting ? 'already_in_combat' : 'not_attackable',
|
||||
targetType
|
||||
};
|
||||
}
|
||||
|
||||
if (this.helpers.checkCantReachMessage(msgBaseline)) {
|
||||
return { success: false, message: `Cannot reach ${player.name}`, reason: 'out_of_reach', targetType };
|
||||
}
|
||||
|
||||
if (engagedRequestedTarget) {
|
||||
return { success: true, message: `Attacking ${player.name}`, targetType };
|
||||
}
|
||||
|
||||
const targetStillVisible = finalState.nearbyPlayers.some(candidate => candidate.index === player.index);
|
||||
return targetStillVisible
|
||||
? { success: false, message: `No combat effect observed for ${player.name}`, reason: 'timeout', targetType }
|
||||
: { success: false, message: `${player.name} left before combat was observed`, reason: 'npc_not_found', targetType };
|
||||
} catch {
|
||||
return { success: false, message: `Timeout waiting to attack ${player.name}`, reason: 'timeout', targetType };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a combat spell on an NPC or another player.
|
||||
*
|
||||
* The two are the same action to the server (OPNPCT vs OPPLAYERT), so this
|
||||
* takes either: an entity from `sdk.findNearbyNpc`/`sdk.findNearbyPlayer`, or
|
||||
* a name/pattern matched against NPCs first and players second.
|
||||
*
|
||||
* ```ts
|
||||
* await bot.castSpell('goblin', Spells.WIND_STRIKE);
|
||||
* await bot.castSpell(sdk.findNearbyPlayer('Zezima')!, Spells.FIRE_STRIKE);
|
||||
* ```
|
||||
*
|
||||
* Magic XP is the evidence of a cast landing, so a splash still counts as
|
||||
* success with `hit: false`.
|
||||
*/
|
||||
async castSpell(target: CombatTarget, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult> {
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
const entity = this.helpers.resolveCombatTarget(target);
|
||||
if (!entity) {
|
||||
return { success: false, message: `Target not found: ${target}`, reason: 'npc_not_found' };
|
||||
}
|
||||
return this.castSpellOnEntity(entity, spellComponent, timeout);
|
||||
}
|
||||
|
||||
/** Cast a combat spell on another player (OPPLAYERT). Prefer {@link castSpell}. */
|
||||
async castSpellOnPlayer(target: NearbyPlayer | string | RegExp, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult> {
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
const player = typeof target === 'object' && 'index' in target
|
||||
? target
|
||||
: this.sdk.findNearbyPlayer(target);
|
||||
if (!player) {
|
||||
return { success: false, message: `Player not found: ${target}`, reason: 'npc_not_found' };
|
||||
}
|
||||
return this.castSpellOnEntity(player, spellComponent, timeout);
|
||||
}
|
||||
|
||||
private async castSpellOnEntity(
|
||||
entity: NearbyNpc | NearbyPlayer,
|
||||
spellComponent: number,
|
||||
timeout: number
|
||||
): Promise<CastSpellResult> {
|
||||
const targetType = entity.kind === 'player' ? 'player' : 'npc';
|
||||
|
||||
const startState = this.sdk.getState();
|
||||
if (!startState) {
|
||||
return { success: false, message: 'No game state available' };
|
||||
return { success: false, message: 'No game state available', targetType };
|
||||
}
|
||||
const startTick = startState.tick;
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
const startMagicXp = startState.skills.find(s => s.name === 'Magic')?.experience ?? 0;
|
||||
|
||||
const result = await this.sdk.sendSpellOnNpc(npc.index, spellComponent);
|
||||
const result = await this.sdk.sendSpellOnTarget(entity, spellComponent);
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message };
|
||||
return { success: false, message: result.message, targetType };
|
||||
}
|
||||
|
||||
try {
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
for (const msg of state.gameMessages) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
||||
return true;
|
||||
}
|
||||
if (text.includes("do not have enough") || text.includes("don't have enough")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (this.helpers.checkCantReachMessage(msgBaseline) ||
|
||||
this.helpers.findRefusal(msgBaseline, NO_RUNES_REFUSALS) ||
|
||||
this.helpers.findPvpRefusal(msgBaseline)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentMagicXp = state.skills.find(s => s.name === 'Magic')?.experience ?? 0;
|
||||
@@ -1936,28 +2107,28 @@ export class BotActions {
|
||||
}, timeout);
|
||||
|
||||
// Check for "not enough runes" first
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("do not have enough") || text.includes("don't have enough")) {
|
||||
return { success: false, message: `Not enough runes to cast spell`, reason: 'no_runes' };
|
||||
}
|
||||
}
|
||||
if (this.helpers.findRefusal(msgBaseline, NO_RUNES_REFUSALS)) {
|
||||
return { success: false, message: `Not enough runes to cast spell`, reason: 'no_runes', targetType };
|
||||
}
|
||||
|
||||
const refusal = this.helpers.findPvpRefusal(msgBaseline);
|
||||
if (refusal) {
|
||||
return { success: false, message: `Cannot cast on ${entity.name}: ${refusal}`, reason: 'not_attackable', targetType };
|
||||
}
|
||||
|
||||
if (this.helpers.checkCantReachMessage(msgBaseline)) {
|
||||
return { success: false, message: `Cannot reach ${npc.name} - obstacle in the way`, reason: 'out_of_reach' };
|
||||
return { success: false, message: `Cannot reach ${entity.name} - obstacle in the way`, reason: 'out_of_reach', targetType };
|
||||
}
|
||||
|
||||
const finalMagicXp = finalState.skills.find(s => s.name === 'Magic')?.experience ?? 0;
|
||||
const xpGained = finalMagicXp - startMagicXp;
|
||||
if (xpGained > 0) {
|
||||
return { success: true, message: `Hit ${npc.name} for ${xpGained} Magic XP`, hit: true, xpGained };
|
||||
return { success: true, message: `Hit ${entity.name} for ${xpGained} Magic XP`, hit: true, xpGained, targetType };
|
||||
}
|
||||
|
||||
return { success: true, message: `Splashed on ${npc.name}`, hit: false, xpGained: 0 };
|
||||
return { success: true, message: `Splashed on ${entity.name}`, hit: false, xpGained: 0, targetType };
|
||||
} catch {
|
||||
return { success: true, message: `Splashed on ${npc.name} (timeout)`, hit: false, xpGained: 0 };
|
||||
return { success: true, message: `Splashed on ${entity.name} (timeout)`, hit: false, xpGained: 0, targetType };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -312,7 +312,8 @@ export function formatWorldState(
|
||||
lines.push(`Weapon: ${state.combatStyle.weaponName}`);
|
||||
const current = state.combatStyle.styles[state.combatStyle.currentStyle];
|
||||
if (current) {
|
||||
lines.push(`Style: ${current.name} (${current.type}) - trains ${current.trainedSkill}`);
|
||||
const trains = current.trainsSkills.length > 0 ? current.trainsSkills.join('/') : 'unknown';
|
||||
lines.push(`Style: ${current.name} (${current.type}, ${current.damageType}) - trains ${trains}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-2
@@ -10,6 +10,7 @@ import type {
|
||||
SkillState,
|
||||
InventoryItem,
|
||||
NearbyNpc,
|
||||
NearbyPlayer,
|
||||
NearbyLoc,
|
||||
GroundItem,
|
||||
DialogState,
|
||||
@@ -845,6 +846,20 @@ export class BotSDK {
|
||||
return this.state?.nearbyNpcs || [];
|
||||
}
|
||||
|
||||
/** Find a nearby player by name pattern (nearest match first). */
|
||||
findNearbyPlayer(pattern: string | RegExp): NearbyPlayer | null {
|
||||
if (!this.state) return null;
|
||||
const regex = typeof pattern === 'string'
|
||||
? new RegExp(pattern, 'i')
|
||||
: pattern;
|
||||
return this.state.nearbyPlayers.find(p => regex.test(p.name)) || null;
|
||||
}
|
||||
|
||||
/** Get all nearby players, nearest first. */
|
||||
getNearbyPlayers(): NearbyPlayer[] {
|
||||
return this.state?.nearbyPlayers || [];
|
||||
}
|
||||
|
||||
/** Get location (object) by coordinates and ID. */
|
||||
getNearbyLoc(x: number, z: number, id: number): NearbyLoc | null {
|
||||
if (!this.state) return null;
|
||||
@@ -1011,7 +1026,7 @@ export class BotSDK {
|
||||
return this.sendAction({ type: 'interactNpc', npcIndex, optionIndex: option, reason: 'SDK' });
|
||||
}
|
||||
|
||||
/** Interact with a player by index and option. Option 2 = Attack (wilderness), 3 = Follow, 4 = Trade. */
|
||||
/** Interact with a player by index and option (1-5). Option 2 = Attack (wilderness), 3 = Follow, 4 = Trade. */
|
||||
async sendInteractPlayer(playerIndex: number, option: number = 2): Promise<ActionResult> {
|
||||
return this.sendAction({ type: 'interactPlayer', playerIndex, optionIndex: option, reason: 'SDK' });
|
||||
}
|
||||
@@ -1275,11 +1290,32 @@ export class BotSDK {
|
||||
.filter((name): name is PrayerName => name !== null);
|
||||
}
|
||||
|
||||
/** Cast spell on NPC using spell component ID. */
|
||||
/** Cast spell on NPC using spell component ID (OPNPCT). */
|
||||
async sendSpellOnNpc(npcIndex: number, spellComponent: number): Promise<ActionResult> {
|
||||
return this.sendAction({ type: 'spellOnNpc', npcIndex, spellComponent, reason: 'SDK' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast spell on another player using spell component ID (OPPLAYERT).
|
||||
*
|
||||
* `playerIndex` is a world slot from `nearbyPlayers`, a different space from
|
||||
* npc indices - use {@link sendSpellOnTarget} to avoid mixing them up.
|
||||
*/
|
||||
async sendSpellOnPlayer(playerIndex: number, spellComponent: number): Promise<ActionResult> {
|
||||
return this.sendAction({ type: 'spellOnPlayer', playerIndex, spellComponent, reason: 'SDK' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a spell on whatever the target is - npc or player - picking the right
|
||||
* packet from `target.kind`. This is the one to reach for in code that fights
|
||||
* both, e.g. `sdk.sendSpellOnTarget(sdk.findNearbyPlayer('Zezima'), Spells.WIND_STRIKE)`.
|
||||
*/
|
||||
async sendSpellOnTarget(target: NearbyNpc | NearbyPlayer, spellComponent: number): Promise<ActionResult> {
|
||||
return target.kind === 'player'
|
||||
? this.sendSpellOnPlayer(target.index, spellComponent)
|
||||
: this.sendSpellOnNpc(target.index, spellComponent);
|
||||
}
|
||||
|
||||
/** Cast spell on inventory item. */
|
||||
async sendSpellOnItem(slot: number, spellComponent: number): Promise<ActionResult> {
|
||||
return this.sendAction({ type: 'spellOnItem', slot, spellComponent, reason: 'SDK' });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BotActions } from '../actions';
|
||||
import type { NearbyNpc } from '../types';
|
||||
|
||||
const target: NearbyNpc = {
|
||||
kind: 'npc',
|
||||
index: 42,
|
||||
name: 'Guard',
|
||||
combatLevel: 21,
|
||||
@@ -30,7 +31,7 @@ function state(targetIndex: number, message?: string, attackXp: number = 0) {
|
||||
worldZ: 3200,
|
||||
lifeId: 1,
|
||||
isDead: false,
|
||||
combat: { inCombat: targetIndex >= 0, targetIndex, lastDamageTick: -1 }
|
||||
combat: { inCombat: targetIndex >= 0, targetIndex, targetType: targetIndex >= 0 ? 'npc' : 'none', lastDamageTick: -1 }
|
||||
},
|
||||
nearbyNpcs: [target],
|
||||
skills: [
|
||||
@@ -72,22 +73,22 @@ function createBot(finalTargetIndex: number, finalAttackXp: number = 0, interrup
|
||||
return { bot: new BotActions(sdk as any), getDismissCount: () => dismissCount };
|
||||
}
|
||||
|
||||
describe('BotActions.attackNpc observation', () => {
|
||||
describe('BotActions.attack observation (npc)', () => {
|
||||
test('treats a refusal race as success when the requested target is engaged', async () => {
|
||||
const result = await createBot(target.index).bot.attackNpc(target);
|
||||
expect(result).toEqual({ success: true, message: 'Already fighting Guard' });
|
||||
const result = await createBot(target.index).bot.attack(target);
|
||||
expect(result).toEqual({ success: true, message: 'Already fighting Guard', targetType: 'npc' });
|
||||
});
|
||||
|
||||
test('does not use XP from an existing fight as proof the requested target was engaged', async () => {
|
||||
// The player is fighting NPC A (#7), gains XP from A, and receives a
|
||||
// refusal after trying NPC B (#42). B must not be reported as engaged.
|
||||
const result = await createBot(7, 40).bot.attackNpc(target);
|
||||
const result = await createBot(7, 40).bot.attack(target);
|
||||
expect(result).toMatchObject({ success: false, reason: 'already_in_combat' });
|
||||
});
|
||||
|
||||
test('dismisses a safe modal before returning satisfied combat evidence', async () => {
|
||||
const harness = createBot(target.index, 0, true);
|
||||
const result = await harness.bot.attackNpc(target);
|
||||
const result = await harness.bot.attack(target);
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(harness.getDismissCount()).toBe(1);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Attack Out of Reach Test (SDK)
|
||||
* Tests the attackNpc failure mode when trying to attack from an enclosed area.
|
||||
* Tests the attack failure mode when trying to attack from an enclosed area.
|
||||
*
|
||||
* Scenario:
|
||||
* - Position the player inside the Lumbridge chicken coop (enclosed by fence)
|
||||
* - Try to attack an NPC outside the coop (cow, goblin, etc.)
|
||||
* - Verify that attackNpc returns success: false with reason: 'out_of_reach'
|
||||
* - Verify that attack returns success: false with reason: 'out_of_reach'
|
||||
*
|
||||
* This tests bot.attackNpc() which detects "I can't reach that!" messages from the server
|
||||
* This tests bot.attack() which detects "I can't reach that!" messages from the server
|
||||
* when pathfinding cannot find a route to the target.
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,7 @@ const INSIDE_CHICKEN_COOP = { x: 3233, z: 3297, level: 0 };
|
||||
|
||||
async function runTest(): Promise<boolean> {
|
||||
console.log('=== Attack Out of Reach Test (SDK) ===');
|
||||
console.log('Goal: Verify attackNpc detects "can\'t reach" failure from enclosed area');
|
||||
console.log('Goal: Verify attack detects "can\'t reach" failure from enclosed area');
|
||||
|
||||
// Create save inside chicken coop
|
||||
console.log(`Creating save file for '${BOT_NAME}' inside chicken coop...`);
|
||||
@@ -75,9 +75,9 @@ async function runTest(): Promise<boolean> {
|
||||
// Attempt to attack NPC outside the enclosed coop
|
||||
// This should fail with "can't reach" since we're fenced in
|
||||
console.log('\n--- Attempting to attack NPC from inside enclosed coop ---');
|
||||
console.log(`Using: bot.attackNpc(${targetNpc.name})`);
|
||||
console.log(`Using: bot.attack(${targetNpc.name})`);
|
||||
|
||||
const attackResult = await bot.attackNpc(targetNpc, 20000); // 20s timeout
|
||||
const attackResult = await bot.attack(targetNpc, 20000); // 20s timeout
|
||||
|
||||
console.log(`\nAttack result:`);
|
||||
console.log(` success: ${attackResult.success}`);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Combat Style Metadata Test
|
||||
*
|
||||
* Equips one weapon per category and checks the published combat styles against
|
||||
* the server's own tables (skill_combat/configs/combat.dbrow). The styles used to
|
||||
* be guessed from the weapon's *name*, which put a controlled style on a bronze
|
||||
* sword's index 2 - the server has aggressive/Strength there.
|
||||
*
|
||||
* Run against a local server:
|
||||
* bun sdk/test/combat-style-metadata.ts
|
||||
* HEADLESS=true PUPPETEER=true bun sdk/test/combat-style-metadata.ts
|
||||
*/
|
||||
|
||||
import { launchBotWithSDK, sleep } from './utils/browser';
|
||||
import { generateSave, Items, Locations } from './utils/save-generator';
|
||||
|
||||
const BOT_NAME = process.env.BOT_NAME || 'stylebot';
|
||||
|
||||
/** What each weapon's tab should publish, per combat.dbrow + the tab's own labels. */
|
||||
const EXPECTED = [
|
||||
{
|
||||
weapon: 'Bronze sword', id: Items.BRONZE_SWORD, tab: 2276, // weapon_stab
|
||||
styles: ['Stab/Accurate/Attack/Stab', 'Lunge/Aggressive/Strength/Stab', 'Slash/Aggressive/Strength/Slash', 'Block/Defensive/Defence/Stab']
|
||||
},
|
||||
{
|
||||
weapon: 'Bronze scimitar', id: 1321, tab: 2423, // weapon_slash
|
||||
styles: ['Chop/Accurate/Attack/Slash', 'Slash/Aggressive/Strength/Slash', 'Lunge/Controlled/Attack+Strength+Defence/Stab', 'Block/Defensive/Defence/Slash']
|
||||
},
|
||||
{
|
||||
weapon: 'Bronze mace', id: 1422, tab: 425, // weapon_blunt
|
||||
styles: ['Pound/Accurate/Attack/Crush', 'Pummel/Aggressive/Strength/Crush', 'Block/Defensive/Defence/Crush']
|
||||
},
|
||||
{
|
||||
weapon: 'Bronze axe', id: Items.BRONZE_AXE, tab: 1698, // weapon_axe
|
||||
styles: ['Chop/Accurate/Attack/Slash', 'Hack/Aggressive/Strength/Slash', 'Smash/Aggressive/Strength/Crush', 'Block/Defensive/Defence/Slash']
|
||||
},
|
||||
{
|
||||
weapon: 'Bronze 2h sword', id: 1307, tab: 4705, // weapon_2h_sword
|
||||
styles: ['Chop/Accurate/Attack/Slash', 'Slash/Aggressive/Strength/Slash', 'Smash/Aggressive/Strength/Crush', 'Block/Defensive/Defence/Slash']
|
||||
},
|
||||
{
|
||||
weapon: 'Shortbow', id: Items.SHORTBOW, tab: 1764, // weapon_bow
|
||||
styles: ['Accurate/Accurate/Ranged/Ranged', 'Rapid/Rapid/Ranged/Ranged', 'Longrange/Longrange/Ranged+Defence/Ranged']
|
||||
}
|
||||
];
|
||||
|
||||
const UNARMED = ['Punch/Accurate/Attack/Crush', 'Kick/Aggressive/Strength/Crush', 'Block/Defensive/Defence/Crush'];
|
||||
|
||||
const describe = (styles: Array<{ name: string; type: string; trainsSkills: string[]; damageType: string }>) =>
|
||||
styles.map(s => `${s.name}/${s.type}/${s.trainsSkills.join('+')}/${s.damageType}`);
|
||||
|
||||
async function main(): Promise<boolean> {
|
||||
await generateSave(BOT_NAME, {
|
||||
position: Locations.LUMBRIDGE_CASTLE,
|
||||
skills: { Attack: 20, Strength: 20, Defence: 20, Hitpoints: 20, Ranged: 20, Magic: 20 },
|
||||
inventory: EXPECTED.map(e => ({ id: e.id, count: 1 })),
|
||||
varps: { 281: 1000 } // tutorial complete
|
||||
});
|
||||
|
||||
const session = await launchBotWithSDK(BOT_NAME, {
|
||||
headless: process.env.HEADLESS === 'true',
|
||||
usePuppeteer: process.env.PUPPETEER === 'true',
|
||||
exitOnCleanup: false
|
||||
});
|
||||
const { sdk, bot } = session;
|
||||
let passed = true;
|
||||
|
||||
try {
|
||||
const only = process.env.ONLY?.toLowerCase();
|
||||
for (const expected of EXPECTED.filter(e => !only || e.weapon.toLowerCase().includes(only))) {
|
||||
const equipped = await bot.equipItem(expected.weapon);
|
||||
if (!equipped.success) {
|
||||
console.log(`FAIL ${expected.weapon}: could not equip (${equipped.message})`);
|
||||
passed = false;
|
||||
continue;
|
||||
}
|
||||
await sleep(900); // the server sends the new tab a tick or two later
|
||||
|
||||
const style = sdk.getState()?.combatStyle;
|
||||
const actual = describe(style?.styles ?? []);
|
||||
const ok = style?.known === true
|
||||
&& style.tabInterfaceId === expected.tab
|
||||
&& JSON.stringify(actual) === JSON.stringify(expected.styles);
|
||||
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${expected.weapon} (tab ${style?.tabInterfaceId}, known=${style?.known})`);
|
||||
if (!ok) {
|
||||
console.log(` expected: ${expected.styles.join(' | ')}`);
|
||||
console.log(` actual: ${actual.join(' | ')}`);
|
||||
passed = false;
|
||||
}
|
||||
|
||||
// Every style must be selectable through the component we published.
|
||||
const last = (style?.styles.length ?? 1) - 1;
|
||||
await sdk.sendSetCombatStyle(last);
|
||||
await sleep(700);
|
||||
const selected = sdk.getState()?.combatStyle?.currentStyle;
|
||||
if (selected !== last) {
|
||||
console.log(`FAIL ${expected.weapon}: com_mode did not follow sendSetCombatStyle(${last}) (got ${selected})`);
|
||||
passed = false;
|
||||
}
|
||||
|
||||
await bot.unequipItem(expected.weapon);
|
||||
await sleep(700);
|
||||
}
|
||||
|
||||
const unarmed = sdk.getState()?.combatStyle;
|
||||
const unarmedStyles = describe(unarmed?.styles ?? []);
|
||||
const unarmedOk = unarmed?.tabInterfaceId === 5855 && JSON.stringify(unarmedStyles) === JSON.stringify(UNARMED);
|
||||
console.log(`${unarmedOk ? 'PASS' : 'FAIL'} Unarmed (tab ${unarmed?.tabInterfaceId})`);
|
||||
if (!unarmedOk) {
|
||||
console.log(` actual: ${unarmedStyles.join(' | ')}`);
|
||||
passed = false;
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
console.log(passed ? '\n=== ALL COMBAT STYLE CHECKS PASSED ===' : '\n=== COMBAT STYLE CHECKS FAILED ===');
|
||||
return passed;
|
||||
}
|
||||
|
||||
const success = await main();
|
||||
process.exit(success ? 0 : 1);
|
||||
@@ -57,9 +57,13 @@ export async function runCombatTrainingBot(
|
||||
const getStyleForSkill = (skill: string): number | null => {
|
||||
const styleState = sdk.getState()?.combatStyle;
|
||||
if (!styleState) return null;
|
||||
const match = styleState.styles.find(s =>
|
||||
s.trainedSkill.toLowerCase() === skill.toLowerCase()
|
||||
);
|
||||
// A controlled style trains three skills at once, so prefer a style
|
||||
// dedicated to the one we want and fall back to a shared style (a spear
|
||||
// only has those).
|
||||
const trains = (s: { trainsSkills: string[] }) =>
|
||||
s.trainsSkills.some(trained => trained.toLowerCase() === skill.toLowerCase());
|
||||
const match = styleState.styles.find(s => trains(s) && s.trainsSkills.length === 1)
|
||||
?? styleState.styles.find(trains);
|
||||
return match?.index ?? null;
|
||||
};
|
||||
|
||||
@@ -67,7 +71,7 @@ export async function runCombatTrainingBot(
|
||||
await sleep(300); // Wait for weapon equip to update styles
|
||||
const styleState = sdk.getState()?.combatStyle;
|
||||
if (styleState) {
|
||||
log(`Combat styles: ${styleState.styles.map(s => `${s.index}:${s.name}(${s.trainedSkill})`).join(', ')}`);
|
||||
log(`Combat styles: ${styleState.styles.map(s => `${s.index}:${s.name}(${s.trainsSkills.join('+') || 'unknown'})`).join(', ')}`);
|
||||
}
|
||||
|
||||
let currentTrainingSkill = 'Strength';
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ runTest({
|
||||
}
|
||||
|
||||
// Cast Wind Strike on the NPC
|
||||
const castResult = await bot.castSpellOnNpc(target, Spells.WIND_STRIKE);
|
||||
const castResult = await bot.castSpell(target, Spells.WIND_STRIKE);
|
||||
casts++;
|
||||
lastCastTurn = turn;
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { BotActions } from '../actions';
|
||||
import type { NearbyNpc, NearbyPlayer } from '../types';
|
||||
|
||||
const goblin: NearbyNpc = {
|
||||
kind: 'npc',
|
||||
index: 4,
|
||||
name: 'Goblin',
|
||||
combatLevel: 2,
|
||||
x: 3200,
|
||||
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']
|
||||
};
|
||||
|
||||
const rival: NearbyPlayer = {
|
||||
kind: 'player',
|
||||
index: 4, // deliberately the same number as the goblin's npc index
|
||||
name: 'Zezima',
|
||||
combatLevel: 42,
|
||||
x: 3200,
|
||||
z: 3200,
|
||||
distance: 1
|
||||
};
|
||||
|
||||
interface StateOptions {
|
||||
/** Who we are facing, if anyone. */
|
||||
engaged?: 'npc' | 'player';
|
||||
message?: string;
|
||||
magicXp?: number;
|
||||
}
|
||||
|
||||
function state({ engaged, message, magicXp = 0 }: StateOptions) {
|
||||
return {
|
||||
tick: message ? 2 : 1,
|
||||
revision: message ? 2 : 1,
|
||||
player: {
|
||||
worldX: 3200,
|
||||
worldZ: 3200,
|
||||
lifeId: 1,
|
||||
isDead: false,
|
||||
combat: {
|
||||
inCombat: engaged !== undefined,
|
||||
targetType: engaged ?? 'none',
|
||||
targetIndex: engaged ? 4 : -1,
|
||||
lastDamageTick: -1
|
||||
}
|
||||
},
|
||||
nearbyNpcs: [goblin],
|
||||
nearbyPlayers: [rival],
|
||||
skills: [{ name: 'Magic', level: 10, baseLevel: 10, experience: magicXp }],
|
||||
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;
|
||||
}
|
||||
|
||||
/** A bot whose state flips to `final` as soon as an action has been dispatched. */
|
||||
function createBot(final: ReturnType<typeof state>) {
|
||||
const dispatched: Array<{ call: string; args: unknown[] }> = [];
|
||||
let current = state({});
|
||||
const record = (call: string) => async (...args: unknown[]) => {
|
||||
dispatched.push({ call, args });
|
||||
return { success: true, message: 'dispatched', phase: 'dispatch' };
|
||||
};
|
||||
const sdk = {
|
||||
getState: () => current,
|
||||
findNearbyNpc: (pattern: string | RegExp) =>
|
||||
(typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern).test(goblin.name) ? goblin : null,
|
||||
findNearbyPlayer: (pattern: string | RegExp) =>
|
||||
(typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern).test(rival.name) ? rival : null,
|
||||
sendInteractNpc: record('sendInteractNpc'),
|
||||
sendInteractPlayer: record('sendInteractPlayer'),
|
||||
sendSpellOnTarget: record('sendSpellOnTarget'),
|
||||
waitForStateChange: async () => {
|
||||
current = final;
|
||||
return current;
|
||||
},
|
||||
waitForCondition: async () => {
|
||||
current = final;
|
||||
return current;
|
||||
}
|
||||
};
|
||||
return { bot: new BotActions(sdk as any), dispatched };
|
||||
}
|
||||
|
||||
describe('BotActions.attack target dispatch', () => {
|
||||
test('sends the player attack option for a player target', async () => {
|
||||
const harness = createBot(state({ engaged: 'player' }));
|
||||
const result = await harness.bot.attack(rival);
|
||||
|
||||
expect(result).toMatchObject({ success: true, targetType: 'player' });
|
||||
expect(harness.dispatched).toEqual([{ call: 'sendInteractPlayer', args: [rival.index, 2] }]);
|
||||
});
|
||||
|
||||
test('does not mistake an npc fight for engagement with the same-numbered player', async () => {
|
||||
// npc index 4 and player slot 4 are different entities; only targetType tells them apart.
|
||||
const harness = createBot(state({ engaged: 'npc' }));
|
||||
const result = await harness.bot.attack(rival, 200);
|
||||
|
||||
expect(result).toMatchObject({ success: false, reason: 'timeout', targetType: 'player' });
|
||||
});
|
||||
|
||||
test('reports the wilderness refusal instead of a silent no-op', async () => {
|
||||
const harness = createBot(state({ message: "You can't attack players who aren't in the wilderness." }));
|
||||
const result = await harness.bot.attack(rival);
|
||||
|
||||
expect(result).toMatchObject({ success: false, reason: 'not_attackable', targetType: 'player' });
|
||||
expect(result.message).toContain("aren't in the wilderness");
|
||||
});
|
||||
|
||||
test('reports a single-way combat lock as retryable', async () => {
|
||||
const harness = createBot(state({ message: "I'm already under attack." }));
|
||||
const result = await harness.bot.attack(rival);
|
||||
|
||||
expect(result).toMatchObject({ success: false, reason: 'already_in_combat' });
|
||||
});
|
||||
|
||||
test('matches a name against npcs before players', async () => {
|
||||
const harness = createBot(state({ engaged: 'npc' }));
|
||||
const result = await harness.bot.attack('goblin');
|
||||
|
||||
expect(result).toMatchObject({ success: true, targetType: 'npc' });
|
||||
expect(harness.dispatched).toEqual([{ call: 'sendInteractNpc', args: [goblin.index, 2] }]);
|
||||
});
|
||||
|
||||
test('falls through to players when no npc matches', async () => {
|
||||
const harness = createBot(state({ engaged: 'player' }));
|
||||
const result = await harness.bot.attack('Zezima');
|
||||
|
||||
expect(result).toMatchObject({ success: true, targetType: 'player' });
|
||||
expect(harness.dispatched).toEqual([{ call: 'sendInteractPlayer', args: [rival.index, 2] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BotActions.castSpell target dispatch', () => {
|
||||
test('routes a player target through the player spell packet', async () => {
|
||||
const harness = createBot(state({ magicXp: 20 }));
|
||||
const result = await harness.bot.castSpell(rival, 1152);
|
||||
|
||||
expect(result).toMatchObject({ success: true, hit: true, xpGained: 20, targetType: 'player' });
|
||||
expect(harness.dispatched).toEqual([{ call: 'sendSpellOnTarget', args: [rival, 1152] }]);
|
||||
});
|
||||
|
||||
test('reports a PvP refusal rather than claiming a splash', async () => {
|
||||
const harness = createBot(state({ message: 'Your level difference is too great!' }));
|
||||
const result = await harness.bot.castSpell(rival, 1152);
|
||||
|
||||
expect(result).toMatchObject({ success: false, reason: 'not_attackable', targetType: 'player' });
|
||||
});
|
||||
|
||||
test('still splashes on npcs without magic xp', async () => {
|
||||
const harness = createBot(state({}));
|
||||
const result = await harness.bot.castSpell(goblin, 1152);
|
||||
|
||||
expect(result).toMatchObject({ success: true, hit: false, xpGained: 0, targetType: 'npc' });
|
||||
expect(harness.dispatched).toEqual([{ call: 'sendSpellOnTarget', args: [goblin, 1152] }]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,13 @@ import { tmpdir } from 'os';
|
||||
|
||||
const BOT_URL = process.env.BOT_URL || 'http://localhost:8888/bot';
|
||||
|
||||
/**
|
||||
* Password the test bots log in (and auto-register) with. The SDK must send the
|
||||
* same one: with LOGIN_SERVER=true the gateway authenticates the SDK connection
|
||||
* separately from the browser page, and an empty password is rejected.
|
||||
*/
|
||||
const BOT_PASSWORD = process.env.BOT_PASSWORD || 'test';
|
||||
|
||||
// Persistent browser endpoint file - allows cross-process browser sharing
|
||||
const BROWSER_ENDPOINT_FILE = join(tmpdir(), 'rs-agent-browser-endpoint.txt');
|
||||
|
||||
@@ -235,7 +242,7 @@ export async function launchBotBrowser(
|
||||
|
||||
// Navigate to bot URL with all params - page handles auto-login, fps, etc.
|
||||
// tst=1 indicates running via test (hides agent panel by default)
|
||||
await page.goto(`${BOT_URL}?bot=${name}&password=test&fps=15&tst=1`, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
await page.goto(`${BOT_URL}?bot=${name}&password=${encodeURIComponent(BOT_PASSWORD)}&fps=15&tst=1`, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
|
||||
// Wait for in-game (page auto-logs in via URL params)
|
||||
let attempts = 0;
|
||||
@@ -351,6 +358,7 @@ export async function launchBotWithSDK(
|
||||
|
||||
const sdk = new BotSDK({
|
||||
botUsername: browser.botName,
|
||||
password: BOT_PASSWORD,
|
||||
autoLaunchBrowser: false // Puppeteer already launched
|
||||
});
|
||||
await sdk.connect();
|
||||
@@ -386,7 +394,7 @@ export async function launchBotWithSDK(
|
||||
|
||||
const sdk = new BotSDK({
|
||||
botUsername: name,
|
||||
password: 'test',
|
||||
password: BOT_PASSWORD,
|
||||
autoLaunchBrowser: true,
|
||||
browserLaunchUrl: BOT_URL,
|
||||
});
|
||||
|
||||
+39
-4
@@ -7,8 +7,14 @@
|
||||
export interface PlayerCombatState {
|
||||
/** Currently engaged in combat (has a target) */
|
||||
inCombat: boolean;
|
||||
/** Index of NPC/player we're targeting (-1 if none) */
|
||||
/**
|
||||
* Index of the NPC/player we're targeting (-1 if none), already decoded:
|
||||
* the client packs player targets as index + 32768, this does not.
|
||||
* Read alongside `targetType` - index 7 is a different entity in each space.
|
||||
*/
|
||||
targetIndex: number;
|
||||
/** What `targetIndex` refers to. */
|
||||
targetType: 'npc' | 'player' | 'none';
|
||||
/** Tick when we last took damage (-1 if never) */
|
||||
lastDamageTick: number;
|
||||
}
|
||||
@@ -70,6 +76,8 @@ export interface NpcOption {
|
||||
}
|
||||
|
||||
export interface NearbyNpc {
|
||||
/** Discriminator so npc and player targets can share one API. */
|
||||
kind: 'npc';
|
||||
index: number;
|
||||
name: string;
|
||||
combatLevel: number;
|
||||
@@ -93,6 +101,9 @@ export interface NearbyNpc {
|
||||
}
|
||||
|
||||
export interface NearbyPlayer {
|
||||
/** Discriminator so npc and player targets can share one API. */
|
||||
kind: 'player';
|
||||
/** World slot of this player - what interact/spell packets address. */
|
||||
index: number;
|
||||
name: string;
|
||||
combatLevel: number;
|
||||
@@ -101,6 +112,13 @@ export interface NearbyPlayer {
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything combat can be pointed at: a resolved entity, or a name/pattern to
|
||||
* look one up by. `bot.attack` and `bot.castSpell` take this - see
|
||||
* {@link BotActions.attack} for how a pattern is matched.
|
||||
*/
|
||||
export type CombatTarget = NearbyNpc | NearbyPlayer | string | RegExp;
|
||||
|
||||
export interface GroundItem {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -234,16 +252,26 @@ export interface BankState {
|
||||
}
|
||||
|
||||
export interface CombatStyleOption {
|
||||
/** Value com_mode takes for this style, and what sendSetCombatStyle expects. */
|
||||
index: number;
|
||||
/** Label from the combat tab ("Chop", "Lunge", ...), or "Style N" if unrecognised. */
|
||||
name: string;
|
||||
/** "Accurate", "Aggressive", "Controlled", "Defensive", "Rapid", "Longrange", "Unknown". */
|
||||
type: string;
|
||||
trainedSkill: string;
|
||||
/** Every skill this style trains, e.g. ["Attack","Strength","Defence"] for controlled. Hitpoints is always trained on top. Empty when the tab is unrecognised. */
|
||||
trainsSkills: string[];
|
||||
/** Damage type rolled against defence bonuses: "Stab", "Slash", "Crush", "Ranged", "Unknown". */
|
||||
damageType: string;
|
||||
}
|
||||
|
||||
export interface CombatStyleState {
|
||||
currentStyle: number;
|
||||
weaponName: string;
|
||||
styles: CombatStyleOption[];
|
||||
/** Interface id of the combat tab the server installed - the weapon category, effectively. */
|
||||
tabInterfaceId: number;
|
||||
/** False when the combat tab is unrecognised and style metadata is unknown rather than guessed. */
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
export interface CombatEvent {
|
||||
@@ -369,6 +397,7 @@ export type BotAction =
|
||||
| { type: 'useItemOnNpc'; itemSlot: number; npcIndex: number; reason: string }
|
||||
| { type: 'say'; message: string; reason: string }
|
||||
| { type: 'spellOnNpc'; npcIndex: number; spellComponent: number; reason: string }
|
||||
| { type: 'spellOnPlayer'; playerIndex: number; spellComponent: number; reason: string }
|
||||
| { type: 'spellOnItem'; slot: number; spellComponent: number; reason: string }
|
||||
| { type: 'spellOnGroundItem'; x: number; z: number; itemId: number; spellComponent: number; reason: string }
|
||||
| { type: 'setTab'; tabIndex: number; reason: string }
|
||||
@@ -545,7 +574,10 @@ export interface EatResult {
|
||||
export interface AttackResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
reason?: 'npc_not_found' | 'no_attack_option' | 'out_of_reach' | 'already_in_combat' | 'died' | 'timeout';
|
||||
/** `npc_not_found` also covers players: no target matched. */
|
||||
reason?: 'npc_not_found' | 'no_attack_option' | 'out_of_reach' | 'already_in_combat' | 'not_attackable' | 'died' | 'timeout';
|
||||
/** What the resolved target was, when one was found. */
|
||||
targetType?: 'npc' | 'player';
|
||||
}
|
||||
|
||||
export interface CastSpellResult {
|
||||
@@ -553,7 +585,10 @@ export interface CastSpellResult {
|
||||
message: string;
|
||||
hit?: boolean;
|
||||
xpGained?: number;
|
||||
reason?: 'npc_not_found' | 'out_of_reach' | 'no_runes' | 'timeout';
|
||||
/** `npc_not_found` also covers players: no target matched. */
|
||||
reason?: 'npc_not_found' | 'out_of_reach' | 'no_runes' | 'not_attackable' | 'timeout';
|
||||
/** What the resolved target was, when one was found. */
|
||||
targetType?: 'npc' | 'player';
|
||||
}
|
||||
|
||||
export interface OpenDoorResult {
|
||||
|
||||
@@ -221,6 +221,16 @@ export class ActionExecutor {
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'spellOnPlayer': {
|
||||
const result = this.wrapClientAction(
|
||||
this.client.spellOnPlayer(action.playerIndex, action.spellComponent),
|
||||
`Casting spell on player #${action.playerIndex}`,
|
||||
'Failed to cast spell on player'
|
||||
);
|
||||
if (result.success) this.showPlayerClickVisual(action.playerIndex);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'spellOnItem':
|
||||
return this.wrapBool(
|
||||
this.client.spellOnItem(action.slot, action.spellComponent),
|
||||
@@ -434,6 +444,8 @@ export function formatAction(action: BotAction): string {
|
||||
case 'clickComponentWithOption': return `Click component ${action.componentId} option ${action.optionIndex}`;
|
||||
case 'useItemOnItem': return `Use slot ${action.sourceSlot} on ${action.targetSlot}`;
|
||||
case 'useItemOnNpc': return `Use slot ${action.itemSlot} on NPC #${action.npcIndex}`;
|
||||
case 'spellOnNpc': return `Cast ${action.spellComponent} on NPC #${action.npcIndex}`;
|
||||
case 'spellOnPlayer': return `Cast ${action.spellComponent} on player #${action.playerIndex}`;
|
||||
case 'shopBuy': return `Buy slot ${action.slot} x${action.amount}`;
|
||||
case 'shopSell': return `Sell slot ${action.slot} x${action.amount}`;
|
||||
case 'wait': return `Wait ${action.ticks || 1} ticks`;
|
||||
|
||||
@@ -102,6 +102,40 @@ describe('ranged spell dispatch', () => {
|
||||
expect(payload).toEqual([42, 1152]);
|
||||
});
|
||||
|
||||
test('dispatches a combat spell at another player (OPPLAYERT)', () => {
|
||||
// Without this the only PvP magic was autocast, which needs a staff.
|
||||
const opcodes: number[] = [];
|
||||
const payload: number[] = [];
|
||||
const client = {
|
||||
ingame: true,
|
||||
out: { p2: (value: number) => payload.push(value) },
|
||||
localPlayer: { routeX: [1], routeZ: [1] },
|
||||
players: { 3: { routeX: [4], routeZ: [4] } },
|
||||
tryMove: () => true,
|
||||
writePacketOpcode: (opcode: number) => opcodes.push(opcode)
|
||||
};
|
||||
|
||||
const result = Client.prototype.spellOnPlayer.call(client, 3, 1152);
|
||||
|
||||
expect(result).toEqual({ success: true, routed: true });
|
||||
expect(opcodes).toEqual([240]); // ClientProt.OPPLAYERT
|
||||
expect(payload).toEqual([3, 1152]);
|
||||
});
|
||||
|
||||
test('refuses to cast at a player the client cannot see', () => {
|
||||
const client = {
|
||||
ingame: true,
|
||||
out: { p2: () => { throw new Error('should not send'); } },
|
||||
localPlayer: { routeX: [1], routeZ: [1] },
|
||||
players: {},
|
||||
tryMove: () => true,
|
||||
writePacketOpcode: () => { throw new Error('should not send'); }
|
||||
};
|
||||
|
||||
expect(Client.prototype.spellOnPlayer.call(client, 9, 1152))
|
||||
.toEqual({ success: false, reason: 'target_not_found' });
|
||||
});
|
||||
|
||||
test('dispatches Telekinetic Grab even when adjacency routing fails', () => {
|
||||
const opcodes: number[] = [];
|
||||
const payload: number[] = [];
|
||||
@@ -129,6 +163,38 @@ describe('ranged spell dispatch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactPlayer', () => {
|
||||
function playerClient(opcodes: number[], payload: number[]) {
|
||||
return {
|
||||
ingame: true,
|
||||
out: { p2: (value: number) => payload.push(value) },
|
||||
localPlayer: { routeX: [1], routeZ: [1] },
|
||||
players: { 5: { routeX: [2], routeZ: [2] } },
|
||||
tryMove: () => true,
|
||||
writePacketOpcode: (opcode: number) => opcodes.push(opcode)
|
||||
};
|
||||
}
|
||||
|
||||
test('sends OPPLAYER2 for the attack option', () => {
|
||||
const opcodes: number[] = [];
|
||||
const payload: number[] = [];
|
||||
|
||||
expect(Client.prototype.interactPlayer.call(playerClient(opcodes, payload), 5, 2))
|
||||
.toEqual({ success: true });
|
||||
expect(opcodes).toEqual([166]); // ClientProt.OPPLAYER2
|
||||
expect(payload).toEqual([5]);
|
||||
});
|
||||
|
||||
test('sends OPPLAYER5, which the engine binds but the client used to reject', () => {
|
||||
const opcodes: number[] = [];
|
||||
const payload: number[] = [];
|
||||
|
||||
expect(Client.prototype.interactPlayer.call(playerClient(opcodes, payload), 5, 5))
|
||||
.toEqual({ success: true });
|
||||
expect(opcodes).toEqual([174]); // ClientProt.OPPLAYER5
|
||||
});
|
||||
});
|
||||
|
||||
describe('clickComponent', () => {
|
||||
test('closes the modal for a close-type button instead of sending IF_BUTTON', () => {
|
||||
// shop_template:com_77 ("Close Window"). The server has no if_button
|
||||
|
||||
@@ -25,7 +25,7 @@ return sdk.getInventory();
|
||||
const CONTEXT_PREAMBLE = `You are writing a script for an MMO game bot.
|
||||
|
||||
The script body runs in an async function with two globals already in scope:
|
||||
- \`bot\` - high-level actions that wait for effects (chopTree, walkTo, attackNpc, ...)
|
||||
- \`bot\` - high-level actions that wait for effects (chopTree, walkTo, attack, ...)
|
||||
- \`sdk\` - low-level state/queries (getState, getInventory, findNearbyNpc, ...)
|
||||
|
||||
Write plain JavaScript (no imports, no TypeScript annotations). \`await\` is allowed
|
||||
|
||||
@@ -26,6 +26,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
};
|
||||
|
||||
const { BotStateCollector } = await import('./StateCollector.js');
|
||||
const { default: IfType } = await import('#/config/IfType.js');
|
||||
|
||||
function createClient() {
|
||||
const skills = new Array(21).fill(1);
|
||||
@@ -137,6 +138,16 @@ describe('BotStateCollector combat state', () => {
|
||||
expect(npc.healthPercent).toBeNull();
|
||||
});
|
||||
|
||||
test('decodes player targets, which the client packs above the npc index space', () => {
|
||||
const client = createClient();
|
||||
client.localPlayer.faceEntity = 32768 + 3; // player slot 3, not npc 32771
|
||||
|
||||
const collector = new BotStateCollector(client);
|
||||
const player = (collector as any).collectPlayerState(1, 100);
|
||||
|
||||
expect(player.combat).toMatchObject({ inCombat: true, targetType: 'player', targetIndex: 3 });
|
||||
});
|
||||
|
||||
test('exposes death and respawn transitions with a stable life id', () => {
|
||||
const client = createClient();
|
||||
const collector = new BotStateCollector(client);
|
||||
@@ -152,3 +163,88 @@ describe('BotStateCollector combat state', () => {
|
||||
expect(respawned).toMatchObject({ isDead: false, lifeId: 2, respawnCount: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('BotStateCollector combat styles', () => {
|
||||
const SELECT_BUTTON = 5;
|
||||
|
||||
/**
|
||||
* Install a combat tab shaped like the real ones: a layer holding one
|
||||
* select-button per style, each carrying its com_mode value in scriptOperand.
|
||||
*/
|
||||
function defineCombatTab(interfaceId: number, styleCount: number): void {
|
||||
const layerId = interfaceId + 100;
|
||||
const buttonIds = Array.from({ length: styleCount }, (_, i) => interfaceId + 200 + i);
|
||||
|
||||
(IfType.list as any)[interfaceId] = { id: interfaceId, children: [layerId] };
|
||||
(IfType.list as any)[layerId] = { id: layerId, children: buttonIds };
|
||||
buttonIds.forEach((id, index) => {
|
||||
(IfType.list as any)[id] = { id, buttonType: SELECT_BUTTON, scriptOperand: [index] };
|
||||
});
|
||||
}
|
||||
|
||||
function collectStyles(tabInterfaceId: number, comMode: number) {
|
||||
const client = createClient();
|
||||
client.sideIcon = [tabInterfaceId, -1, -1];
|
||||
client.var = [];
|
||||
client.var[43] = comMode; // com_mode
|
||||
return (new BotStateCollector(client) as any).collectCombatStyle();
|
||||
}
|
||||
|
||||
test('reads the style table from the combat tab, not the weapon name', () => {
|
||||
// combat_stabsword (weapon_stab): a bronze sword's index 2 is aggressive,
|
||||
// trains Strength and hits with Slash. Guessing from the name "sword"
|
||||
// used to report Controlled/Shared here, which trains nothing it claims.
|
||||
defineCombatTab(2276, 4);
|
||||
const style = collectStyles(2276, 0);
|
||||
|
||||
expect(style.known).toBeTrue();
|
||||
expect(style.tabInterfaceId).toBe(2276);
|
||||
expect(style.styles.map((s: any) => `${s.name}/${s.type}/${s.trainsSkills.join('+')}/${s.damageType}`)).toEqual([
|
||||
'Stab/Accurate/Attack/Stab',
|
||||
'Lunge/Aggressive/Strength/Stab',
|
||||
'Slash/Aggressive/Strength/Slash',
|
||||
'Block/Defensive/Defence/Stab'
|
||||
]);
|
||||
});
|
||||
|
||||
test('separates the two sword tabs: scimitars really are controlled at index 2', () => {
|
||||
defineCombatTab(2423, 4); // combat_hacksword -> weapon_slash_table
|
||||
const style = collectStyles(2423, 2);
|
||||
|
||||
expect(style.styles[2]).toMatchObject({
|
||||
name: 'Lunge',
|
||||
type: 'Controlled',
|
||||
trainsSkills: ['Attack', 'Strength', 'Defence'],
|
||||
damageType: 'Stab'
|
||||
});
|
||||
expect(style.currentStyle).toBe(2);
|
||||
});
|
||||
|
||||
test('reports longrange as training Ranged and Defence', () => {
|
||||
defineCombatTab(1764, 3); // combat_bow
|
||||
const style = collectStyles(1764, 2);
|
||||
|
||||
expect(style.styles[2]).toMatchObject({
|
||||
name: 'Longrange',
|
||||
type: 'Longrange',
|
||||
trainsSkills: ['Ranged', 'Defence'],
|
||||
damageType: 'Ranged'
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps com_mode to the styles the tab actually has, as the server does', () => {
|
||||
defineCombatTab(425, 3); // combat_blunt has 3 styles
|
||||
expect(collectStyles(425, 3).currentStyle).toBe(2);
|
||||
});
|
||||
|
||||
test('says unknown rather than guessing when the tab is unrecognised', () => {
|
||||
defineCombatTab(6666, 2);
|
||||
const style = collectStyles(6666, 0);
|
||||
|
||||
expect(style.known).toBeFalse();
|
||||
expect(style.styles).toEqual([
|
||||
{ index: 0, name: 'Style 0', type: 'Unknown', trainsSkills: [], damageType: 'Unknown' },
|
||||
{ index: 1, name: 'Style 1', type: 'Unknown', trainsSkills: [], damageType: 'Unknown' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,7 @@ export class BotStateCollector implements ScanProvider {
|
||||
private lastPlayerDamageTick: number = -1;
|
||||
private lastNpcDamageCycles: Map<number, Int32Array> = new Map();
|
||||
private lastNpcCombatTicks: Map<number, number> = new Map();
|
||||
private lastOtherPlayerDamageCycles: Map<number, Int32Array> = new Map();
|
||||
private static readonly MAX_EVENTS = 50;
|
||||
private static readonly EVENT_EXPIRY_TICKS = 50;
|
||||
private lastDeadState: boolean | null = null;
|
||||
@@ -286,6 +287,20 @@ export class BotStateCollector implements ScanProvider {
|
||||
return { isOpen, interfaceId, options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the client's faceEntity field, which packs both entity spaces into
|
||||
* one number: npc indices sit below 32768, player slots above it.
|
||||
*/
|
||||
private static decodeFaceEntity(faceEntity: number): { type: 'npc' | 'player' | 'none'; index: number } {
|
||||
if (faceEntity === undefined || faceEntity === null || faceEntity < 0) {
|
||||
return { type: 'none', index: -1 };
|
||||
}
|
||||
if (faceEntity >= 32768) {
|
||||
return { type: 'player', index: faceEntity - 32768 };
|
||||
}
|
||||
return { type: 'npc', index: faceEntity };
|
||||
}
|
||||
|
||||
private collectCombatEvents(currentTick: number): void {
|
||||
const c = this.client as any;
|
||||
const player = c.localPlayer;
|
||||
@@ -302,15 +317,17 @@ export class BotStateCollector implements ScanProvider {
|
||||
const lastCycle = this.lastPlayerDamageCycles[i] || 0;
|
||||
|
||||
if (cycle > lastCycle && cycle > 0) {
|
||||
// New damage detected
|
||||
// New damage detected. The splat carries no attacker, so the
|
||||
// entity we face is the best available guess at the source.
|
||||
const damage = player.damageValues[i] || 0;
|
||||
const facing = BotStateCollector.decodeFaceEntity(player.faceEntity ?? -1);
|
||||
this.lastPlayerDamageTick = currentTick;
|
||||
this.combatEvents.push({
|
||||
tick: currentTick,
|
||||
type: 'damage_taken',
|
||||
damage,
|
||||
sourceType: 'npc', // Assume NPC source for now
|
||||
sourceIndex: player.faceEntity ?? -1,
|
||||
sourceType: facing.type === 'player' ? 'other_player' : 'npc',
|
||||
sourceIndex: facing.index,
|
||||
targetType: 'player',
|
||||
targetIndex: -1 // Self
|
||||
});
|
||||
@@ -344,8 +361,8 @@ export class BotStateCollector implements ScanProvider {
|
||||
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?.faceEntity ?? -1;
|
||||
const isPlayerSource = playerTarget === npcIndex;
|
||||
const facing = BotStateCollector.decodeFaceEntity(player?.faceEntity ?? -1);
|
||||
const isPlayerSource = facing.type === 'npc' && facing.index === npcIndex;
|
||||
|
||||
this.combatEvents.push({
|
||||
tick: currentTick,
|
||||
@@ -361,6 +378,46 @@ export class BotStateCollector implements ScanProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect damage on other players. Only splats on the player we are
|
||||
// fighting are attributed to us - a splat on a bystander says nothing
|
||||
// about who hit them, so it is not published as an event at all.
|
||||
const playerArray = c.players || [];
|
||||
const playerIds = c.playerIds || [];
|
||||
const playerCount = c.playerCount || 0;
|
||||
const facingPlayer = BotStateCollector.decodeFaceEntity(player?.faceEntity ?? -1);
|
||||
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const otherIndex = playerIds[i];
|
||||
const other = playerArray[otherIndex];
|
||||
if (!other || other === player || !other.damageCycles || !other.damageValues) continue;
|
||||
|
||||
let lastCycles = this.lastOtherPlayerDamageCycles.get(otherIndex);
|
||||
if (!lastCycles) {
|
||||
lastCycles = new Int32Array(4);
|
||||
this.lastOtherPlayerDamageCycles.set(otherIndex, lastCycles);
|
||||
}
|
||||
|
||||
const isOurTarget = facingPlayer.type === 'player' && facingPlayer.index === otherIndex;
|
||||
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const cycle = other.damageCycles[j] || 0;
|
||||
const lastCycle = lastCycles[j] || 0;
|
||||
|
||||
if (cycle > lastCycle && cycle > 0 && isOurTarget) {
|
||||
this.combatEvents.push({
|
||||
tick: currentTick,
|
||||
type: 'damage_dealt',
|
||||
damage: other.damageValues[j] || 0,
|
||||
sourceType: 'player',
|
||||
sourceIndex: -1,
|
||||
targetType: 'other_player',
|
||||
targetIndex: otherIndex
|
||||
});
|
||||
}
|
||||
lastCycles[j] = cycle;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up tracking for NPCs no longer nearby
|
||||
const activeNpcIndices = new Set<number>();
|
||||
for (let i = 0; i < npcCount; i++) {
|
||||
@@ -373,6 +430,17 @@ export class BotStateCollector implements ScanProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// ...and for players who left the scene
|
||||
const activePlayerIndices = new Set<number>();
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
activePlayerIndices.add(playerIds[i]);
|
||||
}
|
||||
for (const otherIndex of this.lastOtherPlayerDamageCycles.keys()) {
|
||||
if (!activePlayerIndices.has(otherIndex)) {
|
||||
this.lastOtherPlayerDamageCycles.delete(otherIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Limit event buffer size
|
||||
if (this.combatEvents.length > BotStateCollector.MAX_EVENTS) {
|
||||
this.combatEvents = this.combatEvents.slice(-BotStateCollector.MAX_EVENTS);
|
||||
@@ -386,11 +454,11 @@ export class BotStateCollector implements ScanProvider {
|
||||
if (!player) return null;
|
||||
|
||||
// Get player's combat state
|
||||
const targetId = player.faceEntity ?? -1;
|
||||
const target = BotStateCollector.decodeFaceEntity(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 = targetId !== -1 || combatCycle > clientCycle;
|
||||
const inCombat = target.type !== 'none' || combatCycle > clientCycle;
|
||||
|
||||
// Hitpoints is skill index 3
|
||||
const skillLevel = c.statEffectiveLevel || [];
|
||||
@@ -428,7 +496,8 @@ export class BotStateCollector implements ScanProvider {
|
||||
spotanimId: player.spotanimId ?? -1,
|
||||
combat: {
|
||||
inCombat,
|
||||
targetIndex: targetId,
|
||||
targetIndex: target.index,
|
||||
targetType: target.type,
|
||||
lastDamageTick: this.lastPlayerDamageTick
|
||||
},
|
||||
isDead,
|
||||
@@ -458,114 +527,189 @@ export class BotStateCollector implements ScanProvider {
|
||||
return skills;
|
||||
}
|
||||
|
||||
// Combat style varp ID (com_mode) - this is determined by the server's varp pack order
|
||||
// In most RS2 servers, com_mode is at a low index. We'll try common indices.
|
||||
private static readonly VARP_COM_MODE = 43; // Standard RS2 attack style varp
|
||||
/** com_mode, the varp holding the selected attack style (server/content/pack/varp.pack: `43=com_mode`). */
|
||||
private static readonly VARP_COM_MODE = 43;
|
||||
|
||||
/**
|
||||
* What each damagestyle from combat.dbrow means for the player.
|
||||
* Bonuses are from `combat_get_damagestyle_bonuses` and the trained skills
|
||||
* from `give_combat_experience`, both in skill_combat/scripts/combat.rs2.
|
||||
* Every style additionally trains Hitpoints.
|
||||
*/
|
||||
private static readonly DAMAGE_STYLES: Record<string, { type: string; trainsSkills: string[] }> = {
|
||||
accurate: { type: 'Accurate', trainsSkills: ['Attack'] },
|
||||
aggressive: { type: 'Aggressive', trainsSkills: ['Strength'] },
|
||||
controlled: { type: 'Controlled', trainsSkills: ['Attack', 'Strength', 'Defence'] },
|
||||
defensive: { type: 'Defensive', trainsSkills: ['Defence'] },
|
||||
ranged_accurate: { type: 'Accurate', trainsSkills: ['Ranged'] },
|
||||
ranged_rapid: { type: 'Rapid', trainsSkills: ['Ranged'] },
|
||||
ranged_longrange: { type: 'Longrange', trainsSkills: ['Ranged', 'Defence'] }
|
||||
};
|
||||
|
||||
/**
|
||||
* Combat style tables keyed by the interface the server installs in the
|
||||
* combat tab, which *is* the weapon category: `update_weapon_category`
|
||||
* (skill_combat/scripts/player/player_attackstyles.rs2) switches on
|
||||
* `oc_category(weapon)` to choose the tab, and `combat_get_weapon_style_data`
|
||||
* (skill_combat/scripts/combat.rs2) switches on the same category to choose
|
||||
* the matching row of skill_combat/configs/combat.dbrow.
|
||||
*
|
||||
* Entries are `[name, damagestyle, damagetype]` in com_mode order: names from
|
||||
* the tab's own labels, the rest transcribed from combat.dbrow.
|
||||
*
|
||||
* Never infer these from the weapon's name. A bronze sword (weapon_stab) and
|
||||
* a scimitar (weapon_slash) are both "swords" and have different tables -
|
||||
* index 2 is aggressive/Slash on one and controlled/Stab on the other.
|
||||
*/
|
||||
private static readonly COMBAT_STYLE_TABLES: Record<number, Array<[string, string, string]>> = {
|
||||
5855: [ // combat_unarmed -> weapon_unarmed_table
|
||||
['Punch', 'accurate', 'Crush'], ['Kick', 'aggressive', 'Crush'], ['Block', 'defensive', 'Crush']
|
||||
],
|
||||
2276: [ // combat_stabsword -> weapon_stab_table (daggers, short/longswords)
|
||||
['Stab', 'accurate', 'Stab'], ['Lunge', 'aggressive', 'Stab'],
|
||||
['Slash', 'aggressive', 'Slash'], ['Block', 'defensive', 'Stab']
|
||||
],
|
||||
2423: [ // combat_hacksword -> weapon_slash_table (scimitars)
|
||||
['Chop', 'accurate', 'Slash'], ['Slash', 'aggressive', 'Slash'],
|
||||
['Lunge', 'controlled', 'Stab'], ['Block', 'defensive', 'Slash']
|
||||
],
|
||||
4705: [ // combat_heavysword -> weapon_2h_sword_table
|
||||
['Chop', 'accurate', 'Slash'], ['Slash', 'aggressive', 'Slash'],
|
||||
['Smash', 'aggressive', 'Crush'], ['Block', 'defensive', 'Slash']
|
||||
],
|
||||
1698: [ // combat_axe -> weapon_axe_table
|
||||
['Chop', 'accurate', 'Slash'], ['Hack', 'aggressive', 'Slash'],
|
||||
['Smash', 'aggressive', 'Crush'], ['Block', 'defensive', 'Slash']
|
||||
],
|
||||
425: [ // combat_blunt -> weapon_blunt_table (maces, hammers)
|
||||
['Pound', 'accurate', 'Crush'], ['Pummel', 'aggressive', 'Crush'], ['Block', 'defensive', 'Crush']
|
||||
],
|
||||
5570: [ // combat_pickaxe -> weapon_pickaxe_table
|
||||
['Spike', 'accurate', 'Stab'], ['Impale', 'aggressive', 'Stab'],
|
||||
['Smash', 'aggressive', 'Crush'], ['Block', 'defensive', 'Stab']
|
||||
],
|
||||
776: [ // combat_scythe -> weapon_scythe_table
|
||||
['Reap', 'accurate', 'Slash'], ['Chop', 'aggressive', 'Stab'],
|
||||
['Jab', 'aggressive', 'Crush'], ['Block', 'defensive', 'Slash']
|
||||
],
|
||||
4679: [ // combat_spear -> weapon_spear_table (controlled on every attacking style)
|
||||
['Lunge', 'controlled', 'Stab'], ['Swipe', 'controlled', 'Slash'],
|
||||
['Pound', 'controlled', 'Crush'], ['Block', 'defensive', 'Stab']
|
||||
],
|
||||
3796: [ // combat_spiked -> weapon_spiked_table
|
||||
['Pound', 'accurate', 'Crush'], ['Pummel', 'aggressive', 'Crush'],
|
||||
['Spike', 'controlled', 'Stab'], ['Block', 'defensive', 'Crush']
|
||||
],
|
||||
7762: [ // combat_claw -> weapon_claws_table
|
||||
['Chop', 'accurate', 'Slash'], ['Slash', 'aggressive', 'Slash'],
|
||||
['Lunge', 'controlled', 'Stab'], ['Block', 'defensive', 'Slash']
|
||||
],
|
||||
8460: [ // combat_polearm -> weapon_polearm_table (3 styles, no accurate)
|
||||
['Jab', 'controlled', 'Stab'], ['Swipe', 'aggressive', 'Slash'], ['Fend', 'defensive', 'Stab']
|
||||
],
|
||||
328: [ // combat_staff_2 -> weapon_staff_table
|
||||
['Jab', 'accurate', 'Crush'], ['Pound', 'aggressive', 'Crush'], ['Focus', 'defensive', 'Crush']
|
||||
],
|
||||
1764: [ // combat_bow -> weapon_bow_table
|
||||
['Accurate', 'ranged_accurate', 'Ranged'], ['Rapid', 'ranged_rapid', 'Ranged'],
|
||||
['Longrange', 'ranged_longrange', 'Ranged']
|
||||
],
|
||||
1749: [ // combat_crossbow -> weapon_crossbow_table
|
||||
['Accurate', 'ranged_accurate', 'Ranged'], ['Rapid', 'ranged_rapid', 'Ranged'],
|
||||
['Longrange', 'ranged_longrange', 'Ranged']
|
||||
],
|
||||
4446: [ // combat_thrown -> weapon_thrown_table (also javelins)
|
||||
['Accurate', 'ranged_accurate', 'Ranged'], ['Rapid', 'ranged_rapid', 'Ranged'],
|
||||
['Longrange', 'ranged_longrange', 'Ranged']
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* The com_mode values an unrecognised combat tab offers, read from its
|
||||
* select-buttons the same way Client.setCombatStyle does: each carries the
|
||||
* style it selects in scriptOperand[0].
|
||||
*/
|
||||
private findCombatStyleIndices(interfaceId: number): number[] {
|
||||
const styles = new Set<number>();
|
||||
const root = IfType.list[interfaceId] as any;
|
||||
if (!root) return [];
|
||||
|
||||
const visit = (comId: number, depth: number): void => {
|
||||
const com = IfType.list[comId] as any;
|
||||
if (!com || depth > 3) return;
|
||||
if (com.buttonType === 5 && com.scriptOperand && typeof com.scriptOperand[0] === 'number') {
|
||||
styles.add(com.scriptOperand[0]);
|
||||
}
|
||||
if (com.children) {
|
||||
for (const childId of com.children) visit(childId, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (root.children) {
|
||||
for (const childId of root.children) visit(childId, 1);
|
||||
}
|
||||
return [...styles].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private collectCombatStyle(): CombatStyleState {
|
||||
const c = this.client as any;
|
||||
|
||||
// Default state
|
||||
const defaultState: CombatStyleState = {
|
||||
currentStyle: 0,
|
||||
weaponName: 'Unarmed',
|
||||
styles: []
|
||||
styles: [],
|
||||
tabInterfaceId: -1,
|
||||
known: false
|
||||
};
|
||||
|
||||
try {
|
||||
// Get current combat style from varp
|
||||
// Try to find the varp by checking what's a reasonable value (0-3)
|
||||
let currentStyle = 0;
|
||||
const varps = c.var || [];
|
||||
// The combat tab (sideIcon[0]) is set by the server from the weapon's
|
||||
// category, so it - not the weapon's name - identifies the style table.
|
||||
const tabInterfaceId = c.sideIcon?.[0] ?? -1;
|
||||
const table = BotStateCollector.COMBAT_STYLE_TABLES[tabInterfaceId];
|
||||
|
||||
// Try common varp indices for com_mode
|
||||
// Check indices that could contain 0-3 values
|
||||
for (const tryIndex of [43, 11, 12, 13, 42, 44]) {
|
||||
const val = varps[tryIndex];
|
||||
if (val !== undefined && val >= 0 && val <= 3) {
|
||||
currentStyle = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get equipped weapon
|
||||
const equipment = this.collectInventory(EQUIPMENT_INTERFACE_ID);
|
||||
const weapon = equipment.find(item => item.slot === 3); // Slot 3 is right hand (weapon)
|
||||
const weaponName = weapon?.name || 'Unarmed';
|
||||
|
||||
// Determine combat styles based on weapon type
|
||||
// For simplicity, we'll use the standard melee styles
|
||||
// More sophisticated logic would check weapon category
|
||||
let styles: CombatStyleOption[] = [];
|
||||
|
||||
if (!weapon || weaponName === 'Unarmed') {
|
||||
// Unarmed combat
|
||||
styles = [
|
||||
{ index: 0, name: 'Punch', type: 'Accurate', trainedSkill: 'Attack' },
|
||||
{ index: 1, name: 'Kick', type: 'Aggressive', trainedSkill: 'Strength' },
|
||||
{ index: 2, name: 'Block', type: 'Defensive', trainedSkill: 'Defence' }
|
||||
];
|
||||
let styles: CombatStyleOption[];
|
||||
if (table) {
|
||||
styles = table.map(([name, damageStyle, damageType], index) => {
|
||||
const meta = BotStateCollector.DAMAGE_STYLES[damageStyle];
|
||||
return {
|
||||
index,
|
||||
name,
|
||||
type: meta.type,
|
||||
trainsSkills: [...meta.trainsSkills],
|
||||
damageType
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Check weapon name to determine style types
|
||||
const wn = weaponName.toLowerCase();
|
||||
|
||||
if (wn.includes('bow') || wn.includes('crossbow')) {
|
||||
// Ranged weapons
|
||||
styles = [
|
||||
{ index: 0, name: 'Accurate', type: 'Accurate', trainedSkill: 'Ranged' },
|
||||
{ index: 1, name: 'Rapid', type: 'Rapid', trainedSkill: 'Ranged' },
|
||||
{ index: 2, name: 'Longrange', type: 'Longrange', trainedSkill: 'Defence' }
|
||||
];
|
||||
} else if (wn.includes('staff') || wn.includes('wand')) {
|
||||
// Magic weapons
|
||||
styles = [
|
||||
{ index: 0, name: 'Bash', type: 'Accurate', trainedSkill: 'Attack' },
|
||||
{ index: 1, name: 'Pound', type: 'Aggressive', trainedSkill: 'Strength' },
|
||||
{ index: 2, name: 'Focus', type: 'Defensive', trainedSkill: 'Defence' }
|
||||
];
|
||||
} else if (wn.includes('scimitar') || wn.includes('sword') || wn.includes('dagger') || wn.includes('longsword')) {
|
||||
// Slashing/stabbing weapons (4 styles)
|
||||
styles = [
|
||||
{ index: 0, name: 'Chop', type: 'Accurate', trainedSkill: 'Attack' },
|
||||
{ index: 1, name: 'Slash', type: 'Aggressive', trainedSkill: 'Strength' },
|
||||
{ index: 2, name: 'Lunge', type: 'Controlled', trainedSkill: 'Shared' },
|
||||
{ index: 3, name: 'Block', type: 'Defensive', trainedSkill: 'Defence' }
|
||||
];
|
||||
} else if (wn.includes('mace') || wn.includes('hammer') || wn.includes('maul')) {
|
||||
// Crush weapons (3 styles)
|
||||
styles = [
|
||||
{ index: 0, name: 'Pound', type: 'Accurate', trainedSkill: 'Attack' },
|
||||
{ index: 1, name: 'Pummel', type: 'Aggressive', trainedSkill: 'Strength' },
|
||||
{ index: 2, name: 'Block', type: 'Defensive', trainedSkill: 'Defence' }
|
||||
];
|
||||
} else if (wn.includes('2h') || wn.includes('godsword') || wn.includes('battleaxe')) {
|
||||
// 2-handed weapons (4 styles)
|
||||
styles = [
|
||||
{ index: 0, name: 'Chop', type: 'Accurate', trainedSkill: 'Attack' },
|
||||
{ index: 1, name: 'Slash', type: 'Aggressive', trainedSkill: 'Strength' },
|
||||
{ index: 2, name: 'Smash', type: 'Aggressive', trainedSkill: 'Strength' },
|
||||
{ index: 3, name: 'Block', type: 'Defensive', trainedSkill: 'Defence' }
|
||||
];
|
||||
} else {
|
||||
// Default melee (4 styles - sword-like)
|
||||
styles = [
|
||||
{ index: 0, name: 'Stab', type: 'Accurate', trainedSkill: 'Attack' },
|
||||
{ index: 1, name: 'Lunge', type: 'Aggressive', trainedSkill: 'Strength' },
|
||||
{ index: 2, name: 'Slash', type: 'Controlled', trainedSkill: 'Shared' },
|
||||
{ index: 3, name: 'Block', type: 'Defensive', trainedSkill: 'Defence' }
|
||||
];
|
||||
}
|
||||
// Unrecognised tab: report the buttons that are actually there
|
||||
// rather than inventing a table that may train the wrong skill.
|
||||
styles = this.findCombatStyleIndices(tabInterfaceId).map(index => ({
|
||||
index,
|
||||
name: `Style ${index}`,
|
||||
type: 'Unknown',
|
||||
trainsSkills: [],
|
||||
damageType: 'Unknown'
|
||||
}));
|
||||
}
|
||||
|
||||
// Ensure currentStyle is within valid range
|
||||
if (currentStyle >= styles.length) {
|
||||
currentStyle = 0;
|
||||
// com_mode, clamped the way player_combat_stat.rs2 clamps it.
|
||||
const varps = c.var ?? [];
|
||||
const rawStyle = varps[BotStateCollector.VARP_COM_MODE];
|
||||
let currentStyle = typeof rawStyle === 'number' && rawStyle >= 0 ? rawStyle : 0;
|
||||
if (styles.length > 0 && currentStyle >= styles.length) {
|
||||
currentStyle = styles.length - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
currentStyle,
|
||||
weaponName,
|
||||
styles
|
||||
styles,
|
||||
tabInterfaceId,
|
||||
known: Boolean(table)
|
||||
};
|
||||
} catch {
|
||||
return defaultState;
|
||||
@@ -680,6 +824,7 @@ export class BotStateCollector implements ScanProvider {
|
||||
const npcWorldZ = baseZ + (npcZ >> 7);
|
||||
|
||||
npcs.push({
|
||||
kind: 'npc',
|
||||
index: npcIndex,
|
||||
name: npcType.name || 'Unknown',
|
||||
combatLevel: npcType.vislevel || 0,
|
||||
@@ -733,6 +878,7 @@ export class BotStateCollector implements ScanProvider {
|
||||
const playerWorldZ = baseZ + ((player.z || 0) >> 7);
|
||||
|
||||
players.push({
|
||||
kind: 'player',
|
||||
index: playerIndex,
|
||||
name: player.name,
|
||||
combatLevel: player.combatLevel || 0,
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface NpcOption {
|
||||
}
|
||||
|
||||
export interface NearbyNpc {
|
||||
/** Discriminator so npc and player targets can share one API. */
|
||||
kind: 'npc';
|
||||
index: number;
|
||||
name: string;
|
||||
combatLevel: number;
|
||||
@@ -79,6 +81,9 @@ export interface NearbyNpc {
|
||||
}
|
||||
|
||||
export interface NearbyPlayer {
|
||||
/** Discriminator so npc and player targets can share one API. */
|
||||
kind: 'player';
|
||||
/** World slot of this player - what OPPLAYER1-5 and OPPLAYERT address. */
|
||||
index: number;
|
||||
name: string;
|
||||
combatLevel: number;
|
||||
@@ -178,8 +183,14 @@ export interface BankState {
|
||||
export interface PlayerCombatState {
|
||||
/** Currently engaged in combat (has a target) */
|
||||
inCombat: boolean;
|
||||
/** Index of NPC/player we're targeting (-1 if none) */
|
||||
/**
|
||||
* Index of the NPC/player we're targeting (-1 if none), already decoded:
|
||||
* the client packs player targets as index + 32768, this does not.
|
||||
* Read alongside `targetType` - index 7 is a different entity in each space.
|
||||
*/
|
||||
targetIndex: number;
|
||||
/** What `targetIndex` refers to. */
|
||||
targetType: 'npc' | 'player' | 'none';
|
||||
/** Tick when we last took damage (-1 if never) */
|
||||
lastDamageTick: number;
|
||||
}
|
||||
@@ -216,16 +227,23 @@ export interface PlayerState {
|
||||
|
||||
// Combat style state
|
||||
export interface CombatStyleOption {
|
||||
index: number; // 0-3
|
||||
name: string; // "Punch", "Kick", "Block", etc.
|
||||
type: string; // "Accurate", "Aggressive", "Defensive", "Controlled"
|
||||
trainedSkill: string; // "Attack", "Strength", "Defence", "Shared"
|
||||
index: number; // 0-3, the value com_mode takes for this style
|
||||
name: string; // "Punch", "Kick", "Block", etc. ("Style 2" if the tab is unrecognised)
|
||||
type: string; // "Accurate", "Aggressive", "Defensive", "Controlled", "Rapid", "Longrange", "Unknown"
|
||||
/** Every skill this style trains, e.g. ["Attack","Strength","Defence"] for controlled. Empty when the tab is unrecognised. */
|
||||
trainsSkills: string[];
|
||||
/** Damage type rolled against defence bonuses: "Stab", "Slash", "Crush", "Ranged", "Unknown". */
|
||||
damageType: string;
|
||||
}
|
||||
|
||||
export interface CombatStyleState {
|
||||
currentStyle: number; // 0-3, the selected style
|
||||
weaponName: string; // Name of equipped weapon or "Unarmed"
|
||||
styles: CombatStyleOption[]; // Available combat styles for this weapon
|
||||
/** Interface id of the combat tab the server installed - the weapon category, effectively. */
|
||||
tabInterfaceId: number;
|
||||
/** False when the combat tab is unrecognised and style metadata is a best guess. */
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
/** Combat event for tracking damage, kills, etc. */
|
||||
@@ -353,6 +371,7 @@ export type BotAction =
|
||||
| { type: 'closeModal'; reason: string }
|
||||
| { type: 'setCombatStyle'; style: number; reason: string }
|
||||
| { type: 'spellOnNpc'; npcIndex: number; spellComponent: number; reason: string }
|
||||
| { type: 'spellOnPlayer'; playerIndex: number; spellComponent: number; reason: string }
|
||||
| { type: 'spellOnItem'; slot: number; spellComponent: number; reason: string }
|
||||
| { type: 'spellOnGroundItem'; x: number; z: number; itemId: number; spellComponent: number; reason: string }
|
||||
| { type: 'setTab'; tabIndex: number; reason: string }
|
||||
|
||||
@@ -1103,7 +1103,7 @@ export class Client extends GameShell {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interact with a player using a specific option (1-4)
|
||||
* Interact with a player using a specific option (1-5)
|
||||
* Option 2 is Attack (wilderness only), Option 3 is Follow, Option 4 is Trade
|
||||
*/
|
||||
interactPlayer(playerIndex: number, optionIndex: number): ClientActionResult {
|
||||
@@ -1113,7 +1113,7 @@ export class Client extends GameShell {
|
||||
if (playerIndex < 0) {
|
||||
return { success: false, reason: 'invalid_target' };
|
||||
}
|
||||
if (optionIndex < 1 || optionIndex > 4) {
|
||||
if (optionIndex < 1 || optionIndex > 5) {
|
||||
return { success: false, reason: 'invalid_option' };
|
||||
}
|
||||
|
||||
@@ -1134,7 +1134,8 @@ export class Client extends GameShell {
|
||||
ClientProt.OPPLAYER1,
|
||||
ClientProt.OPPLAYER2,
|
||||
ClientProt.OPPLAYER3,
|
||||
ClientProt.OPPLAYER4
|
||||
ClientProt.OPPLAYER4,
|
||||
ClientProt.OPPLAYER5
|
||||
];
|
||||
this.writePacketOpcode(opcodes[optionIndex - 1]);
|
||||
this.out.p2(playerIndex);
|
||||
@@ -1182,6 +1183,48 @@ export class Client extends GameShell {
|
||||
return { success: true, routed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a spell on another player (sends OPPLAYERT)
|
||||
* The PvP counterpart of spellOnNpc - the server routes it to [applayert,...]
|
||||
*
|
||||
* playerIndex: the world slot of the target player (NearbyPlayer.index)
|
||||
* spellComponent: the interface component ID of the spell (e.g., 1152 for Wind Strike)
|
||||
*/
|
||||
spellOnPlayer(playerIndex: number, spellComponent: 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' };
|
||||
}
|
||||
|
||||
const p = this.players[playerIndex];
|
||||
if (!p) {
|
||||
return { success: false, reason: 'target_not_found' };
|
||||
}
|
||||
|
||||
// Try to move towards the player. As with spellOnNpc the packet still goes
|
||||
// out when routing fails: spells have reach, so the server may well allow
|
||||
// the cast from where we stand.
|
||||
const routed = this.tryMove(
|
||||
this.localPlayer.routeX[0],
|
||||
this.localPlayer.routeZ[0],
|
||||
p.routeX[0],
|
||||
p.routeZ[0],
|
||||
false, // tryNearest
|
||||
1, 1, // player size
|
||||
0, 0, // angle, shape
|
||||
0, // forceapproach
|
||||
2 // type = MOVE_OPCLICK
|
||||
);
|
||||
// Send OPPLAYERT packet
|
||||
this.writePacketOpcode(ClientProt.OPPLAYERT);
|
||||
this.out.p2(playerIndex);
|
||||
this.out.p2(spellComponent);
|
||||
|
||||
return { success: true, routed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a spell on an inventory item (sends OPHELDT)
|
||||
* Used for spells like Low/High Alchemy, Enchant, Superheat
|
||||
@@ -1857,15 +1900,14 @@ export class Client extends GameShell {
|
||||
|
||||
/**
|
||||
* Get current combat style (0-3)
|
||||
*
|
||||
* Reads com_mode (varp 43, see server/content/pack/varp.pack) and nothing else:
|
||||
* scanning neighbouring varps for a plausible 0-3 value happily returns some
|
||||
* other setting's value when com_mode has not been sent yet.
|
||||
*/
|
||||
getCombatStyle(): number {
|
||||
for (const tryIndex of [43, 11, 12, 13, 42, 44]) {
|
||||
const val = this.var[tryIndex];
|
||||
if (val !== undefined && val >= 0 && val <= 3) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
const val = this.var[43];
|
||||
return typeof val === 'number' && val >= 0 ? val : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user