mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
sdk: make skipTutorial survive a fresh login's settling window
On a fresh login the SDK can attach before the character design modal arrives (a throttled background tab widens this), and the first talk to the guide can come back "I can't reach that!". skipTutorial checked for the modal once and failed after a single talk attempt with a timeout. When the guide is nearby, wait briefly for the design modal and accept it, retry the talk up to three times, and name the last game message if it still fails. Off Tutorial Island it returns immediately as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6714fa74e
commit
3b2be2bc2d
2 files changed
+184
-44
No files matched your search
+70
-44
@@ -210,55 +210,81 @@ export class BotActions {
|
||||
return { success: false, message: 'No Talk option on tutorial NPC' };
|
||||
}
|
||||
|
||||
const result = await this.sdk.sendInteractNpc(guide.index, talkOpt.opIndex);
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message };
|
||||
// A fresh login can publish its first in-game state before the design modal
|
||||
// arrives, and a throttled background tab widens that window. Give the modal a
|
||||
// moment so it's accepted before talking to the guide.
|
||||
if (!state.modalOpen) {
|
||||
await this.sdk.waitForCondition(s => s.modalOpen || s.dialog.isOpen, 3000).catch(() => {});
|
||||
await checkAndHandleDesignModal();
|
||||
}
|
||||
|
||||
// Wait for dialog to open
|
||||
try {
|
||||
await this.sdk.waitForCondition(s => s.dialog.isOpen, 5000);
|
||||
await this.sdk.waitForTicks(1);
|
||||
|
||||
// Loop through all dialog pages until closed
|
||||
let clickCount = 0;
|
||||
const MAX_CLICKS = 10;
|
||||
|
||||
while (clickCount < MAX_CLICKS) {
|
||||
// Check for design modal each iteration
|
||||
await checkAndHandleDesignModal();
|
||||
|
||||
const currentState = this.sdk.getState();
|
||||
if (!currentState?.dialog.isOpen) {
|
||||
return { success: true, message: `Tutorial skipped after ${clickCount} dialog clicks` };
|
||||
}
|
||||
|
||||
if (currentState.dialog.isWaiting) {
|
||||
await this.sdk.waitForTicks(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = currentState.dialog.options;
|
||||
if (options.length > 0) {
|
||||
// Smart option selection: skip > yes > confirm > first option
|
||||
const skipOption = options.find(o => /skip|complete|finish/i.test(o.text));
|
||||
const yesOption = options.find(o => /yes|continue|proceed/i.test(o.text));
|
||||
const confirmOption = options.find(o => /confirm|accept|agree|ok/i.test(o.text));
|
||||
|
||||
const selectedOption = skipOption || yesOption || confirmOption || options[0];
|
||||
await this.sdk.sendClickDialog(selectedOption!.index);
|
||||
} else {
|
||||
await this.sdk.sendClickDialog(0);
|
||||
}
|
||||
|
||||
clickCount++;
|
||||
await this.sdk.waitForTicks(1);
|
||||
// Talking straight after login can come back "I can't reach that!" while the
|
||||
// client is still settling; retry rather than fail the whole skip.
|
||||
const TALK_ATTEMPTS = 3;
|
||||
let dialogOpened = false;
|
||||
for (let attempt = 1; attempt <= TALK_ATTEMPTS && !dialogOpened; attempt++) {
|
||||
if (this.sdk.getState()?.dialog.isOpen) {
|
||||
dialogOpened = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return { success: true, message: `Clicked through ${clickCount} dialogs` };
|
||||
} catch {
|
||||
return { success: false, message: 'Timed out waiting for dialog to open' };
|
||||
const result = await this.sdk.sendInteractNpc(guide.index, talkOpt.opIndex);
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sdk.waitForCondition(s => s.dialog.isOpen, 5000);
|
||||
dialogOpened = true;
|
||||
} catch {
|
||||
await checkAndHandleDesignModal();
|
||||
await this.sdk.waitForTicks(2);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dialogOpened) {
|
||||
const lastMessage = this.sdk.getState()?.gameMessages.at(-1)?.text;
|
||||
return { success: false, message: `Timed out waiting for the tutorial guide's dialog after ${TALK_ATTEMPTS} attempts${lastMessage ? ` (last game message: "${lastMessage}")` : ''}` };
|
||||
}
|
||||
|
||||
await this.sdk.waitForTicks(1);
|
||||
|
||||
// Loop through all dialog pages until closed
|
||||
let clickCount = 0;
|
||||
const MAX_CLICKS = 10;
|
||||
|
||||
while (clickCount < MAX_CLICKS) {
|
||||
// Check for design modal each iteration
|
||||
await checkAndHandleDesignModal();
|
||||
|
||||
const currentState = this.sdk.getState();
|
||||
if (!currentState?.dialog.isOpen) {
|
||||
return { success: true, message: `Tutorial skipped after ${clickCount} dialog clicks` };
|
||||
}
|
||||
|
||||
if (currentState.dialog.isWaiting) {
|
||||
await this.sdk.waitForTicks(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = currentState.dialog.options;
|
||||
if (options.length > 0) {
|
||||
// Smart option selection: skip > yes > confirm > first option
|
||||
const skipOption = options.find(o => /skip|complete|finish/i.test(o.text));
|
||||
const yesOption = options.find(o => /yes|continue|proceed/i.test(o.text));
|
||||
const confirmOption = options.find(o => /confirm|accept|agree|ok/i.test(o.text));
|
||||
|
||||
const selectedOption = skipOption || yesOption || confirmOption || options[0];
|
||||
await this.sdk.sendClickDialog(selectedOption!.index);
|
||||
} else {
|
||||
await this.sdk.sendClickDialog(0);
|
||||
}
|
||||
|
||||
clickCount++;
|
||||
await this.sdk.waitForTicks(1);
|
||||
}
|
||||
|
||||
return { success: true, message: `Clicked through ${clickCount} dialogs` };
|
||||
}
|
||||
|
||||
return { success: false, message: 'No tutorial NPC found' };
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Logic test for bot.skipTutorial() — no bot required.
|
||||
*
|
||||
* A fresh login can publish its first in-game state before the character design
|
||||
* modal arrives (worse in a throttled background tab), and the first talk to the
|
||||
* guide can come back "I can't reach that!". skipTutorial used to check for the
|
||||
* modal once and give up after one talk attempt.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { BotSDK } from '../index';
|
||||
import { BotActions } from '../actions';
|
||||
import type { ActionResult, BotWorldState } from '../types';
|
||||
|
||||
type World = {
|
||||
modalOpen: boolean;
|
||||
dialogOpen: boolean;
|
||||
pages: number;
|
||||
messages: string[];
|
||||
};
|
||||
|
||||
function snapshot(w: World): BotWorldState {
|
||||
return {
|
||||
inGame: true,
|
||||
modalOpen: w.modalOpen,
|
||||
modalInterface: w.modalOpen ? 3559 : -1,
|
||||
dialog: { isOpen: w.dialogOpen, options: [], isWaiting: false },
|
||||
gameMessages: w.messages.map(text => ({ text })),
|
||||
} as unknown as BotWorldState;
|
||||
}
|
||||
|
||||
/**
|
||||
* `onWait` runs on every waitForCondition call (the moment time passes), and
|
||||
* `talkOpens` decides per attempt whether talking to the guide opens a dialog.
|
||||
*/
|
||||
function mount(world: World, opts: { onWait?: (w: World) => void; talkOpens: (attempt: number) => boolean }) {
|
||||
const sdk = new BotSDK({ botUsername: 'test' });
|
||||
const log: string[] = [];
|
||||
let talks = 0;
|
||||
|
||||
(sdk as any).getState = () => snapshot(world);
|
||||
(sdk as any).findNearbyNpc = () => ({
|
||||
index: 1,
|
||||
name: 'RuneScape Guide',
|
||||
optionsWithIndex: [{ text: 'Talk-to', opIndex: 1 }],
|
||||
});
|
||||
(sdk as any).waitForTicks = async () => {};
|
||||
(sdk as any).waitForCondition = async (pred: (s: BotWorldState) => boolean) => {
|
||||
opts.onWait?.(world);
|
||||
const s = snapshot(world);
|
||||
if (pred(s)) return s;
|
||||
throw new Error('timeout');
|
||||
};
|
||||
(sdk as any).sendRandomizeCharacterDesign = async (): Promise<ActionResult> => ({ success: true, message: 'ok' });
|
||||
(sdk as any).sendAcceptCharacterDesign = async (): Promise<ActionResult> => {
|
||||
log.push('accept');
|
||||
world.modalOpen = false;
|
||||
return { success: true, message: 'ok' };
|
||||
};
|
||||
(sdk as any).sendInteractNpc = async (): Promise<ActionResult> => {
|
||||
talks++;
|
||||
log.push(`talk${talks}`);
|
||||
if (opts.talkOpens(talks)) {
|
||||
world.dialogOpen = true;
|
||||
} else {
|
||||
world.messages.push("I can't reach that!");
|
||||
}
|
||||
return { success: true, message: 'ok' };
|
||||
};
|
||||
(sdk as any).sendClickDialog = async (): Promise<ActionResult> => {
|
||||
world.pages--;
|
||||
if (world.pages <= 0) world.dialogOpen = false;
|
||||
return { success: true, message: 'ok' };
|
||||
};
|
||||
|
||||
return { bot: new BotActions(sdk), log };
|
||||
}
|
||||
|
||||
const fresh = (): World => ({ modalOpen: false, dialogOpen: false, pages: 3, messages: ['Welcome to RuneScape.'] });
|
||||
|
||||
describe('skipTutorial()', () => {
|
||||
test('accepts a design modal that arrives after the first state, before talking', async () => {
|
||||
const world = fresh();
|
||||
let waits = 0;
|
||||
const { bot, log } = mount(world, {
|
||||
onWait: w => { if (++waits === 1) w.modalOpen = true; },
|
||||
talkOpens: () => true,
|
||||
});
|
||||
|
||||
const result = await bot.skipTutorial({ randomizeAppearance: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(log).toEqual(['accept', 'talk1']);
|
||||
});
|
||||
|
||||
test('retries the guide talk when the first attempt opens no dialog', async () => {
|
||||
const { bot, log } = mount(fresh(), { talkOpens: attempt => attempt === 2 });
|
||||
|
||||
const result = await bot.skipTutorial({ randomizeAppearance: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(log).toEqual(['talk1', 'talk2']);
|
||||
});
|
||||
|
||||
test('names the last game message after exhausting attempts', async () => {
|
||||
const { bot, log } = mount(fresh(), { talkOpens: () => false });
|
||||
|
||||
const result = await bot.skipTutorial({ randomizeAppearance: false });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(log).toEqual(['talk1', 'talk2', 'talk3']);
|
||||
expect(result.message).toContain("I can't reach that!");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user