Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 49 additions & 22 deletions learnings/combat.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,54 @@ const attackOpt = npc.optionsWithIndex.find(o => /attack/i.test(o.text));
await ctx.sdk.sendInteractNpc(npc.index, attackOpt.opIndex);
```

## Combat Style Selection

Use skill names instead of opaque indices. The SDK resolves the correct
index automatically based on the equipped weapon:

```typescript
// Porcelain (recommended) - waits for effect, returns typed result
await ctx.bot.setCombatStyle('Strength'); // Works with any weapon
await ctx.bot.setCombatStyle('Defence'); // No need to know weapon-specific indices
await ctx.bot.setCombatStyle('Attack');

// Plumbing - resolves skill name to index, returns on acknowledgement
await ctx.sdk.sendSetCombatStyle('Strength');
await ctx.sdk.sendSetCombatStyle('Ranged'); // Works when bow equipped

// Raw index still works for backwards compatibility
await ctx.sdk.sendSetCombatStyle(0);
```

Inspect available styles for the current weapon:

```typescript
const styles = ctx.sdk.getCombatStyle();
// { weaponName: 'Bronze sword', currentStyle: 0,
// styles: [{index:0, name:'Chop', type:'Accurate', trainedSkill:'Attack'}, ...] }
```

Invalid skill names return clear errors:

```typescript
const result = await ctx.bot.setCombatStyle('Ranged'); // with sword equipped
// { success: false, reason: 'no_matching_style',
// message: "No style training 'Ranged' for Bronze sword. Available: Chop(Attack), ..." }
```

## Combat Style Cycling

Rotate styles for balanced training:
Rotate styles for balanced training using skill names:

```typescript
// Combat style indices
const STYLES = {
ATTACK: 0, // Train Attack
STRENGTH: 1, // Train Strength
STRENGTH2: 2, // Also Strength (some weapons)
DEFENCE: 3, // Train Defence
};

// Cycle every 30 seconds or on level-up
let lastStyleChange = Date.now();
const SKILLS = ['Attack', 'Strength', 'Defence'] as const;
let currentIndex = 0;
const CYCLE_INTERVAL = 30_000;
let lastStyleChange = Date.now();

if (Date.now() - lastStyleChange > CYCLE_INTERVAL) {
currentStyle = (currentStyle + 1) % 4;
if (currentStyle === 2) currentStyle = 3; // Skip duplicate strength
await ctx.sdk.sendSetCombatStyle(currentStyle);
currentIndex = (currentIndex + 1) % SKILLS.length;
await ctx.bot.setCombatStyle(SKILLS[currentIndex]);
lastStyleChange = Date.now();
}
```
Expand Down Expand Up @@ -182,21 +209,21 @@ if (Math.abs(player.worldX - lastX) > 500) {
For balanced progression, automatically train whichever stat is lowest:

```typescript
function getLowestCombatStat(state): { stat: string, style: number } {
function getLowestCombatSkill(state): TrainableSkill {
const skills = state.skills;
const atk = skills.find(s => s.name === 'Attack')?.baseLevel ?? 1;
const str = skills.find(s => s.name === 'Strength')?.baseLevel ?? 1;
const def = skills.find(s => s.name === 'Defence')?.baseLevel ?? 1;

if (def <= atk && def <= str) return { stat: 'Defence', style: 3 };
if (str <= atk) return { stat: 'Strength', style: 1 };
return { stat: 'Attack', style: 0 };
if (def <= atk && def <= str) return 'Defence';
if (str <= atk) return 'Strength';
return 'Attack';
}

// Set combat style based on lowest stat
const { stat, style } = getLowestCombatStat(ctx.sdk.getState());
await ctx.sdk.sendSetCombatStyle(style);
console.log(`Training ${stat} (lowest)`);
// Set combat style based on lowest stat - no index guessing needed
const skill = getLowestCombatSkill(ctx.sdk.getState());
await ctx.bot.setCombatStyle(skill);
console.log(`Training ${skill} (lowest)`);
```

This pattern enabled balanced 60+ in all melee stats.
58 changes: 57 additions & 1 deletion sdk/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ import type {
InteractNpcResult,
PickpocketResult,
PrayerResult,
PrayerName
PrayerName,
SetCombatStyleResult,
TrainableSkill
} from './types';
import { PRAYER_INDICES, PRAYER_NAMES, PRAYER_LEVELS } from './types';

Expand Down Expand Up @@ -2479,6 +2481,60 @@ export class BotActions {
}
}

// ============ Porcelain: Combat Style ============

/**
* Set combat style by trained skill name.
* Resolves the correct style index for the currently equipped weapon,
* so you don't need to know weapon-specific index mappings.
* If multiple styles train the same skill, the first match is used.
*
* @example
* ```ts
* await bot.setCombatStyle('Strength'); // Train strength regardless of weapon
* await bot.setCombatStyle('Defence'); // Works for any weapon type
* ```
*/
async setCombatStyle(skill: TrainableSkill): Promise<SetCombatStyleResult> {
const combatState = this.sdk.getCombatStyle();
if (!combatState) {
return { success: false, message: 'No combat style state available', reason: 'no_combat_state' };
}

const match = combatState.styles.find(s =>
s.trainedSkill.toLowerCase() === skill.toLowerCase()
);
if (!match) {
const available = combatState.styles.map(s => `${s.name}(${s.trainedSkill})`).join(', ');
return {
success: false,
message: `No style training '${skill}' for ${combatState.weaponName}. Available: ${available}`,
reason: 'no_matching_style'
};
}

// Already set
if (combatState.currentStyle === match.index) {
return { success: true, message: `Already using ${match.name} (${match.trainedSkill})`, style: match, reason: 'already_set' };
}

const result = await this.sdk.sendSetCombatStyle(match.index);
if (!result.success) {
return { success: false, message: result.message };
}

// Wait for style change to take effect
try {
await this.sdk.waitForCondition(state => {
return state.combatStyle?.currentStyle === match.index;
}, 5000);
return { success: true, message: `Set combat style to ${match.name} (trains ${match.trainedSkill})`, style: match };
} catch {
// Style command was sent even if we couldn't confirm the state change
return { success: true, message: `Sent combat style change to ${match.name} (trains ${match.trainedSkill})`, style: match };
}
}

// ============ Porcelain: Prayer Actions ============

/**
Expand Down
44 changes: 40 additions & 4 deletions sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import type {
SDKConnectionMode,
BotStatus,
PrayerState,
PrayerName
PrayerName,
CombatStyleState,
TrainableSkill
} from './types';
import { PRAYER_INDICES, PRAYER_NAMES } from './types';
import * as pathfinding from './pathfinding';
Expand Down Expand Up @@ -874,9 +876,43 @@ export class BotSDK {
return this.sendAction({ type: 'closeModal', reason: 'SDK' });
}

/** Set combat style (0-3). */
async sendSetCombatStyle(style: number): Promise<ActionResult> {
return this.sendAction({ type: 'setCombatStyle', style, reason: 'SDK' });
/**
* Set combat style by index (0-3) or by trained skill name.
*
* Using a skill name (e.g. 'Attack', 'Strength', 'Defence') automatically
* resolves to the correct index for the currently equipped weapon.
* If multiple styles train the same skill, the first match is used.
*
* @example
* ```ts
* await sdk.sendSetCombatStyle('Strength'); // Train strength with current weapon
* await sdk.sendSetCombatStyle(0); // Raw index (weapon-dependent)
* ```
*/
async sendSetCombatStyle(style: number | TrainableSkill): Promise<ActionResult> {
let index: number;
if (typeof style === 'number') {
index = style;
} else {
const combatState = this.state?.combatStyle;
if (!combatState) {
return { success: false, message: 'No combat style state available - ensure the combat tab is visible' };
}
const match = combatState.styles.find(s =>
s.trainedSkill.toLowerCase() === style.toLowerCase()
);
if (!match) {
const available = combatState.styles.map(s => `${s.name}(${s.trainedSkill})`).join(', ');
return { success: false, message: `No style training '${style}' for ${combatState.weaponName}. Available: ${available}` };
}
index = match.index;
}
return this.sendAction({ type: 'setCombatStyle', style: index, reason: 'SDK' });
}

/** Get current combat style state (weapon, available styles, current style). */
getCombatStyle(): CombatStyleState | null {
return this.state?.combatStyle || null;
}

// ============ Prayer ============
Expand Down
161 changes: 161 additions & 0 deletions sdk/test/combat-style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env bun
/**
* Combat Style Test (SDK)
* Tests the name-based combat style API (issue #20).
*
* Verifies:
* 1. sdk.sendSetCombatStyle('Strength') resolves skill name to correct index
* 2. bot.setCombatStyle('Attack') porcelain waits for effect
* 3. Invalid skill names return clear errors with available styles
* 4. Styles update correctly after weapon change
* 5. getCombatStyle() convenience method works
*/

import { runTest, sleep } from './utils/test-runner';
import { Locations, Items } from './utils/save-generator';

runTest({
name: 'Combat Style Test (SDK)',
saveConfig: {
position: Locations.LUMBRIDGE_CASTLE,
skills: { Attack: 10, Strength: 10, Defence: 10 },
inventory: [
{ id: Items.BRONZE_SWORD, count: 1 },
{ id: Items.SHORTBOW, count: 1 },
],
},
launchOptions: { skipTutorial: false },
}, async ({ sdk, bot }) => {
console.log('Goal: Verify name-based combat style API');

// Wait for state
await sdk.waitForCondition(s => (s.player?.worldX ?? 0) > 0, 10000);
await sleep(500);

// --- Test 1: getCombatStyle() returns state ---
console.log('\n--- Test 1: getCombatStyle() convenience method ---');
const combatState = sdk.getCombatStyle();
if (!combatState) {
console.log('FAIL: getCombatStyle() returned null');
return false;
}
console.log(` Weapon: ${combatState.weaponName}`);
console.log(` Current style: ${combatState.currentStyle}`);
console.log(` Styles: ${combatState.styles.map(s => `${s.index}:${s.name}(${s.trainedSkill})`).join(', ')}`);
console.log(' PASS: getCombatStyle() returns valid state');

// --- Test 2: sendSetCombatStyle with skill name (plumbing) ---
console.log('\n--- Test 2: sendSetCombatStyle with skill name ---');
const result2 = await sdk.sendSetCombatStyle('Strength');
console.log(` sendSetCombatStyle('Strength'): success=${result2.success}, message=${result2.message}`);
if (!result2.success) {
console.log(' FAIL: Could not set combat style by skill name');
return false;
}
console.log(' PASS: sendSetCombatStyle accepts skill name');

// --- Test 3: sendSetCombatStyle with case-insensitive name ---
console.log('\n--- Test 3: Case-insensitive skill name ---');
const result3 = await sdk.sendSetCombatStyle('defence' as any);
console.log(` sendSetCombatStyle('defence'): success=${result3.success}, message=${result3.message}`);
if (!result3.success) {
console.log(' FAIL: Case-insensitive matching did not work');
return false;
}
console.log(' PASS: Case-insensitive matching works');

// --- Test 4: sendSetCombatStyle with invalid skill name ---
console.log('\n--- Test 4: Invalid skill name returns error ---');
const result4 = await sdk.sendSetCombatStyle('Ranged' as any);
// Should fail because we're using a melee weapon (unarmed or sword), not a bow
if (combatState.weaponName.toLowerCase().includes('bow')) {
console.log(' SKIP: Weapon is a bow, Ranged is valid');
} else {
console.log(` sendSetCombatStyle('Ranged'): success=${result4.success}, message=${result4.message}`);
if (result4.success) {
console.log(' FAIL: Should have returned error for Ranged on melee weapon');
return false;
}
if (!result4.message.includes('Available:')) {
console.log(' FAIL: Error message should list available styles');
return false;
}
console.log(' PASS: Invalid skill name returns descriptive error');
}

// --- Test 5: Porcelain setCombatStyle ---
console.log('\n--- Test 5: Porcelain bot.setCombatStyle() ---');
const result5 = await bot.setCombatStyle('Attack');
console.log(` bot.setCombatStyle('Attack'): success=${result5.success}, message=${result5.message}`);
if (!result5.success) {
console.log(' FAIL: Porcelain setCombatStyle failed');
return false;
}
if (result5.style) {
console.log(` Style set: ${result5.style.name} (${result5.style.type}) -> trains ${result5.style.trainedSkill}`);
}
console.log(' PASS: Porcelain setCombatStyle works');

// --- Test 6: setCombatStyle already_set ---
console.log('\n--- Test 6: setCombatStyle when already set ---');
const result6 = await bot.setCombatStyle('Attack');
console.log(` bot.setCombatStyle('Attack') again: success=${result6.success}, reason=${result6.reason}`);
if (result6.reason !== 'already_set') {
console.log(' FAIL: Should return already_set reason');
return false;
}
console.log(' PASS: Returns already_set when style unchanged');

// --- Test 7: setCombatStyle with no_matching_style ---
console.log('\n--- Test 7: Porcelain no_matching_style ---');
if (!combatState.weaponName.toLowerCase().includes('bow')) {
const result7 = await bot.setCombatStyle('Ranged');
console.log(` bot.setCombatStyle('Ranged'): success=${result7.success}, reason=${result7.reason}`);
if (result7.reason !== 'no_matching_style') {
console.log(' FAIL: Should return no_matching_style reason');
return false;
}
console.log(' PASS: Returns no_matching_style with descriptive error');
} else {
console.log(' SKIP: Current weapon supports Ranged');
}

// --- Test 8: Equip bow and verify styles change ---
console.log('\n--- Test 8: Style resolution after weapon change ---');
const bow = sdk.getInventory().find(i => /bow/i.test(i.name));
if (bow) {
console.log(` Equipping ${bow.name}...`);
await bot.equipItem(bow);
await sleep(500);

const bowState = sdk.getCombatStyle();
if (bowState) {
console.log(` New weapon: ${bowState.weaponName}`);
console.log(` Styles: ${bowState.styles.map(s => `${s.name}(${s.trainedSkill})`).join(', ')}`);

// Now Ranged should work
const result8 = await sdk.sendSetCombatStyle('Ranged');
console.log(` sendSetCombatStyle('Ranged'): success=${result8.success}`);
if (!result8.success) {
console.log(' FAIL: Ranged should work with bow equipped');
return false;
}
console.log(' PASS: Style resolution updates with weapon change');
}
} else {
console.log(' SKIP: No bow in inventory');
}

// --- Test 9: Raw index still works (backwards compat) ---
console.log('\n--- Test 9: Raw index backwards compatibility ---');
const result9 = await sdk.sendSetCombatStyle(0);
console.log(` sendSetCombatStyle(0): success=${result9.success}`);
if (!result9.success) {
console.log(' FAIL: Raw index should still work');
return false;
}
console.log(' PASS: Raw index remains backwards compatible');

console.log('\n=== All combat style tests passed ===');
return true;
});
Loading