combat improvements

This commit is contained in:
max bittker committed 2026-07-26 15:16:37 -04:00
1 parent 448ff85b21
commit 160ffcbb50
24 files changed
+1256 -225

No files matched your search

+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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';
}
```
+77
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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' });
+7 -6
View File
@@ -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);
+6 -6
View File
@@ -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}`);
+124
View File
@@ -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);
+8 -4
View File
@@ -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
View File
@@ -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;
+173
View File
@@ -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] }]);
});
});
+10 -2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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' }
]);
});
});
+239 -93
View File
@@ -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,
+24 -5
View File
@@ -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 }
+52 -10
View File
@@ -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;
}
/**