mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
fix state publication and action queue races
This commit is contained in:
1 parent
9337aae9af
commit
2547bc7546
21 files changed
+516
-145
No files matched your search
@@ -117,11 +117,17 @@ export class ActionHelpers {
|
||||
// every "since this tick" check match ancient messages.
|
||||
let maxTick = 0;
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > maxTick) maxTick = msg.tick;
|
||||
const cursor = msg.observationId ?? msg.tick;
|
||||
if (cursor > maxTick) maxTick = cursor;
|
||||
}
|
||||
return maxTick;
|
||||
}
|
||||
|
||||
/** True when a message was published after a cursor from getMessageTick(). */
|
||||
isMessageAfter(message: { tick: number; observationId?: number }, cursor: number): boolean {
|
||||
return (message.observationId ?? message.tick) > cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check recent game messages for "can't reach" indicators.
|
||||
* @param sinceMessageTick - Only check messages with tick > this value.
|
||||
@@ -131,7 +137,7 @@ export class ActionHelpers {
|
||||
if (!state) return false;
|
||||
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > sinceMessageTick) {
|
||||
if (this.isMessageAfter(msg, sinceMessageTick)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach") || text.includes("i can't reach")) {
|
||||
return true;
|
||||
@@ -356,7 +362,7 @@ export class ActionHelpers {
|
||||
let failReason: 'locked' | 'cant_reach' | null = null;
|
||||
await this.sdk.waitForCondition(state => {
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("locked")) { failReason = 'locked'; return true; }
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) { failReason = 'cant_reach'; return true; }
|
||||
|
||||
+57
-53
@@ -236,19 +236,32 @@ export class BotActions {
|
||||
timeout: number
|
||||
): Promise<NonNullable<ReturnType<BotSDK['getState']>>> {
|
||||
const deadline = Date.now() + timeout;
|
||||
const hasSafeBlockingUI = (state: NonNullable<ReturnType<BotSDK['getState']>>) =>
|
||||
state.dialog.isOpen ||
|
||||
(state.interface?.isOpen && !state.shop?.isOpen && !state.bank?.isOpen
|
||||
&& !NEVER_AUTO_CLOSE.has(state.interface.interfaceId));
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const current = this.sdk.getState();
|
||||
if (current && predicate(current)) return current;
|
||||
let current = this.sdk.getState();
|
||||
|
||||
if (current?.dialog.isOpen ||
|
||||
(current?.interface?.isOpen && !current.shop?.isOpen && !current.bank?.isOpen
|
||||
&& !NEVER_AUTO_CLOSE.has(current.interface.interfaceId))) {
|
||||
if (current && hasSafeBlockingUI(current)) {
|
||||
await this.dismissBlockingUI();
|
||||
const afterDismiss = this.sdk.getState();
|
||||
if (afterDismiss && predicate(afterDismiss)) return afterDismiss;
|
||||
current = this.sdk.getState();
|
||||
// A dismissal can be acknowledged before the state update, or
|
||||
// fail after the retry cap. Never report success while the
|
||||
// blocker is still observably open.
|
||||
if (current && hasSafeBlockingUI(current)) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
await this.sdk.waitForStateChange(Math.min(remaining, 2_000)).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate success only after clearing a safe incidental interrupt,
|
||||
// so the next action never inherits a level-up/modal blocker.
|
||||
if (current && predicate(current)) return current;
|
||||
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
await this.sdk.waitForStateChange(Math.min(remaining, 2_000)).catch(() => {});
|
||||
@@ -314,7 +327,7 @@ export class BotActions {
|
||||
try {
|
||||
await this.sdk.waitForCondition(state => {
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
||||
return true;
|
||||
@@ -425,7 +438,7 @@ export class BotActions {
|
||||
await this.sdk.waitForCondition(state => {
|
||||
// Check for "can't reach" messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
||||
return true;
|
||||
@@ -511,7 +524,7 @@ export class BotActions {
|
||||
await this.sdk.waitForCondition(state => {
|
||||
// Check for "can't reach" messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
||||
return true;
|
||||
@@ -614,7 +627,7 @@ export class BotActions {
|
||||
|
||||
const failureMessages = ["can't light a fire", "you need to move", "can't do that here"];
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (failureMessages.some(f => text.includes(f))) {
|
||||
return true;
|
||||
@@ -687,7 +700,7 @@ export class BotActions {
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
// Check for failure messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
||||
return true;
|
||||
@@ -710,7 +723,7 @@ export class BotActions {
|
||||
|
||||
// Check for failure reasons
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
||||
return { success: false, message: `Cannot reach ${item.name} - path blocked`, reason: 'cant_reach' };
|
||||
@@ -771,7 +784,7 @@ export class BotActions {
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
// Check for can't-reach messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) return true;
|
||||
}
|
||||
@@ -825,7 +838,6 @@ export class BotActions {
|
||||
const MAX_DOOR_RETRIES = 3;
|
||||
let doorRetryCount = 0;
|
||||
let poorProgressCount = 0;
|
||||
const blockedDoors = new Set<string>(); // Doors confirmed locked
|
||||
const doorFailCounts = new Map<string, number>(); // Tracks reach failures per door
|
||||
|
||||
// Try to open a blocking door. Returns true if door was opened.
|
||||
@@ -849,8 +861,7 @@ export class BotActions {
|
||||
doorFailCounts.set(key, fails);
|
||||
// Temporarily exclude after repeated reach failures. The
|
||||
// session-owned evidence expires automatically.
|
||||
if (fails >= 3 && !blockedDoors.has(key)) {
|
||||
blockedDoors.add(key);
|
||||
if (fails >= 3 && !this.sdk.isDoorTemporarilyBlocked(level, nearest.x, nearest.z)) {
|
||||
this.sdk.blockDoorTemporarily(level, nearest.x, nearest.z);
|
||||
console.log(`[walkTo] Temporarily avoiding impassable door at (${nearest.x}, ${nearest.z}) — re-routing`);
|
||||
}
|
||||
@@ -894,7 +905,7 @@ export class BotActions {
|
||||
// Find which required door is closest to this waypoint
|
||||
for (const door of requiredDoors) {
|
||||
const dk = `${door.x},${door.z}`;
|
||||
if (blockedDoors.has(dk)) break; // Already known locked
|
||||
if (this.sdk.isDoorTemporarilyBlocked(door.level, door.x, door.z)) break;
|
||||
const doorDist = Math.abs(door.x - wp.x) + Math.abs(door.z - wp.z);
|
||||
if (doorDist <= 1) {
|
||||
const result = await this.helpers.openDoorAt(door.x, door.z);
|
||||
@@ -904,7 +915,6 @@ export class BotActions {
|
||||
} else if (result === 'locked') {
|
||||
// Definitely locked right now — avoid it for
|
||||
// this session's next path queries.
|
||||
blockedDoors.add(dk);
|
||||
this.sdk.blockDoorTemporarily(door.level, door.x, door.z);
|
||||
requiredDoorKeys.delete(dk);
|
||||
console.log(`[walkTo] Temporarily avoiding locked door at (${door.x}, ${door.z}) — re-routing`);
|
||||
@@ -914,7 +924,6 @@ export class BotActions {
|
||||
const fails = (doorFailCounts.get(dk) ?? 0) + 1;
|
||||
doorFailCounts.set(dk, fails);
|
||||
if (fails >= 3) {
|
||||
blockedDoors.add(dk);
|
||||
this.sdk.blockDoorTemporarily(door.level, door.x, door.z);
|
||||
requiredDoorKeys.delete(dk);
|
||||
console.log(`[walkTo] Temporarily avoiding impassable door at (${door.x}, ${door.z}) after ${fails} failures — re-routing`);
|
||||
@@ -1150,7 +1159,7 @@ export class BotActions {
|
||||
try {
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't sell this item")) {
|
||||
return true;
|
||||
@@ -1163,7 +1172,7 @@ export class BotActions {
|
||||
}, 5000);
|
||||
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't sell this item to this shop")) {
|
||||
return { success: false, message: `Shop doesn't buy ${sellItem.name}`, rejected: true };
|
||||
@@ -1221,7 +1230,7 @@ export class BotActions {
|
||||
try {
|
||||
const finalState = await this.sdk.waitForCondition(s => {
|
||||
for (const msg of s.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
if (msg.text.toLowerCase().includes("can't sell this item")) {
|
||||
return true;
|
||||
}
|
||||
@@ -1233,7 +1242,7 @@ export class BotActions {
|
||||
}, 3000);
|
||||
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't sell this item to this shop")) {
|
||||
return {
|
||||
@@ -1652,11 +1661,10 @@ export class BotActions {
|
||||
|
||||
const startState = this.sdk.getState();
|
||||
const startTick = startState?.tick || 0;
|
||||
const startRevision = startState?.revision ?? startTick;
|
||||
const startLifeId = startState?.player?.lifeId;
|
||||
const combatSkillNames = new Set(['Attack', 'Strength', 'Defence', 'Hitpoints', 'Ranged', 'Magic']);
|
||||
const startCombatXp = (startState?.skills ?? [])
|
||||
.filter(skill => combatSkillNames.has(skill.name))
|
||||
.reduce((total, skill) => total + skill.experience, 0);
|
||||
const eventAfterStart = (event: { tick: number; observationId?: number }) =>
|
||||
(event.observationId ?? event.tick) > startRevision;
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
const result = await this.sdk.sendInteractNpc(npc.index, attackOpt.opIndex);
|
||||
if (!result.success) {
|
||||
@@ -1670,7 +1678,7 @@ export class BotActions {
|
||||
try {
|
||||
const finalState = await this.waitForActionCondition(state => {
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("someone else is fighting") ||
|
||||
text.includes("already under attack") ||
|
||||
@@ -1694,13 +1702,11 @@ export class BotActions {
|
||||
}
|
||||
|
||||
return state.combatEvents.some(event =>
|
||||
event.tick > startTick &&
|
||||
eventAfterStart(event) &&
|
||||
event.type === 'damage_dealt' &&
|
||||
event.targetType === 'npc' &&
|
||||
event.targetIndex === npc.index
|
||||
) || state.skills
|
||||
.filter(skill => combatSkillNames.has(skill.name))
|
||||
.reduce((total, skill) => total + skill.experience, 0) > startCombatXp;
|
||||
);
|
||||
}, timeout);
|
||||
|
||||
if (finalState.player?.isDead ||
|
||||
@@ -1712,25 +1718,23 @@ export class BotActions {
|
||||
(finalState.player?.combat.inCombat &&
|
||||
finalState.player.combat.targetIndex === npc.index) ||
|
||||
finalState.combatEvents.some(event =>
|
||||
event.tick > startTick &&
|
||||
eventAfterStart(event) &&
|
||||
event.type === 'damage_dealt' &&
|
||||
event.targetType === 'npc' &&
|
||||
event.targetIndex === npc.index
|
||||
);
|
||||
const gainedCombatXp = finalState.skills
|
||||
.filter(skill => combatSkillNames.has(skill.name))
|
||||
.reduce((total, skill) => total + skill.experience, 0) > startCombatXp;
|
||||
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
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. Prefer observed target/damage/XP evidence.
|
||||
if (engagedRequestedTarget || gainedCombatXp) {
|
||||
// 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' };
|
||||
@@ -1738,7 +1742,7 @@ export class BotActions {
|
||||
}
|
||||
}
|
||||
|
||||
if (engagedRequestedTarget || gainedCombatXp) {
|
||||
if (engagedRequestedTarget) {
|
||||
return { success: true, message: `Attacking ${npc.name}` };
|
||||
}
|
||||
|
||||
@@ -1776,7 +1780,7 @@ export class BotActions {
|
||||
try {
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
||||
return true;
|
||||
@@ -1797,7 +1801,7 @@ export class BotActions {
|
||||
|
||||
// Check for "not enough runes" first
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
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' };
|
||||
@@ -2088,7 +2092,7 @@ export class BotActions {
|
||||
|
||||
// Check for failure messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("need a higher") || text.includes("level to")) {
|
||||
return { success: false, message: 'Fletching level too low' };
|
||||
@@ -2231,7 +2235,7 @@ export class BotActions {
|
||||
|
||||
// Check for failure messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("need a crafting level") || text.includes("level to")) {
|
||||
return { success: false, message: 'Crafting level too low', reason: 'level_too_low' };
|
||||
@@ -2449,7 +2453,7 @@ export class BotActions {
|
||||
|
||||
// Check for failure messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("need a smithing level") || text.includes("level to")) {
|
||||
return { success: false, message: 'Smithing level too low', reason: 'level_too_low' };
|
||||
@@ -2571,7 +2575,7 @@ export class BotActions {
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
// Check for can't-reach messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) return true;
|
||||
}
|
||||
@@ -2681,7 +2685,7 @@ export class BotActions {
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
// Check for can't-reach messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes("cannot reach")) return true;
|
||||
}
|
||||
@@ -2759,7 +2763,7 @@ export class BotActions {
|
||||
|
||||
// Check game messages for stun/catch or can't reach
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes('stunned') || text.includes('caught') || text.includes('stun')) return true;
|
||||
if (text.includes("can't reach") || text.includes('cannot reach')) return true;
|
||||
@@ -2771,7 +2775,7 @@ export class BotActions {
|
||||
|
||||
// Check what happened
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't reach") || text.includes('cannot reach')) {
|
||||
return { success: false, message: `Can't reach ${npc.name}`, reason: 'cant_reach' };
|
||||
@@ -3081,7 +3085,7 @@ export class BotActions {
|
||||
|
||||
// Check for failure messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("need a crafting level") || text.includes("level to")) {
|
||||
return { success: false, message: 'Crafting level too low', reason: 'level_too_low' };
|
||||
@@ -3188,7 +3192,7 @@ export class BotActions {
|
||||
|
||||
// Check for failure messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("do not have enough") || text.includes("don't have enough") || text.includes("need runes")) {
|
||||
return { success: false, message: 'Not enough runes', reason: 'no_runes' };
|
||||
@@ -3292,7 +3296,7 @@ export class BotActions {
|
||||
|
||||
// Check for failure messages
|
||||
for (const msg of state.gameMessages) {
|
||||
if (msg.tick > msgBaseline) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("need a crafting level") || text.includes("level to")) {
|
||||
return { success: false, message: 'Crafting level too low', reason: 'level_too_low' };
|
||||
|
||||
+18
-15
@@ -9,10 +9,9 @@
|
||||
* last {@link CHAT_HISTORY_LIMIT} messages seen since connect, regardless of
|
||||
* how fast the client ring churns.
|
||||
*
|
||||
* Merging relies on two properties of the client ring:
|
||||
* - `tick` (Client.loopCycle) is monotonically non-decreasing per message,
|
||||
* except when the page reloads (loopCycle restarts near 0 with a fresh ring).
|
||||
* - Messages sharing a tick keep their relative order in every snapshot.
|
||||
* Merging prefers `observationId`, the monotonic state-publication revision
|
||||
* when a message first became agent-visible. Older clients fall back to
|
||||
* `tick`. A lower cursor means the browser page reloaded with a fresh ring.
|
||||
*/
|
||||
|
||||
import type { GameMessage } from './types';
|
||||
@@ -20,6 +19,10 @@ import type { GameMessage } from './types';
|
||||
/** Messages retained by the SDK, independent of the client's 100-deep ring. */
|
||||
export const CHAT_HISTORY_LIMIT = 500;
|
||||
|
||||
function messageCursor(message: GameMessage): number {
|
||||
return message.observationId ?? message.tick;
|
||||
}
|
||||
|
||||
export class ChatHistory {
|
||||
private messages: GameMessage[] = [];
|
||||
private totalRecorded = 0;
|
||||
@@ -49,27 +52,27 @@ export class ChatHistory {
|
||||
|
||||
if (!last) {
|
||||
fresh = snapshot.slice();
|
||||
} else if (snapshot[snapshot.length - 1]!.tick < last.tick) {
|
||||
// Ticks went backwards: the client page reloaded, so loopCycle
|
||||
// restarted and the ring was rebuilt from empty. Nothing in this
|
||||
} else if (messageCursor(snapshot[snapshot.length - 1]!) < messageCursor(last)) {
|
||||
// Publication cursor went backwards: the client page reloaded and
|
||||
// the ring was rebuilt from empty. Nothing in this
|
||||
// snapshot can overlap what we already hold.
|
||||
fresh = snapshot.slice();
|
||||
} else {
|
||||
// Normal case: the snapshot overlaps our tail. Everything newer
|
||||
// than our last tick is new. Messages AT our last tick may be
|
||||
// split across snapshots (a chat packet processed after the
|
||||
// PLAYER_INFO that triggered the previous sync gets the same
|
||||
// loopCycle), so count how many of that tick we already hold and
|
||||
// keep only the surplus.
|
||||
// than our last publication cursor is new. Messages AT our last
|
||||
// cursor can share one publication, so count how many we already
|
||||
// hold and keep only the surplus.
|
||||
const lastCursor = messageCursor(last);
|
||||
let held = 0;
|
||||
for (let i = this.messages.length - 1; i >= 0 && this.messages[i]!.tick === last.tick; i--) {
|
||||
for (let i = this.messages.length - 1; i >= 0 && messageCursor(this.messages[i]!) === lastCursor; i--) {
|
||||
held++;
|
||||
}
|
||||
let matched = 0;
|
||||
fresh = [];
|
||||
for (const m of snapshot) {
|
||||
if (m.tick < last.tick) continue;
|
||||
if (m.tick === last.tick && ++matched <= held) continue;
|
||||
const cursor = messageCursor(m);
|
||||
if (cursor < lastCursor) continue;
|
||||
if (cursor === lastCursor && ++matched <= held) continue;
|
||||
fresh.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -958,7 +958,8 @@ export class BotSDK {
|
||||
type: 'sdk_action',
|
||||
username: this.config.botUsername,
|
||||
actionId,
|
||||
action
|
||||
action,
|
||||
actionTimeoutMs: this.config.actionTimeout
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1370,6 +1371,11 @@ export class BotSDK {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Check this SDK session's non-expired temporary door evidence. */
|
||||
isDoorTemporarilyBlocked(level: number, x: number, z: number): boolean {
|
||||
return this.temporaryDoorBlocks.has(level, x, z);
|
||||
}
|
||||
|
||||
/** Find path to destination (async alias for findPath). */
|
||||
async sendFindPath(
|
||||
destX: number,
|
||||
|
||||
@@ -51,6 +51,11 @@ export class TemporaryDoorBlocklist {
|
||||
return [...this.entries.values()].map(entry => entry.door);
|
||||
}
|
||||
|
||||
has(level: number, x: number, z: number): boolean {
|
||||
this.active();
|
||||
return this.entries.has(doorKey(level, x, z));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ const target: NearbyNpc = {
|
||||
options: ['Attack']
|
||||
};
|
||||
|
||||
function state(targetIndex: number, message?: string) {
|
||||
function state(targetIndex: number, message?: string, attackXp: number = 0) {
|
||||
return {
|
||||
tick: message ? 2 : 1,
|
||||
revision: message ? 2 : 1,
|
||||
player: {
|
||||
worldX: 3200,
|
||||
worldZ: 3200,
|
||||
@@ -33,7 +34,7 @@ function state(targetIndex: number, message?: string) {
|
||||
},
|
||||
nearbyNpcs: [target],
|
||||
skills: [
|
||||
{ name: 'Attack', level: 1, baseLevel: 1, experience: 0 },
|
||||
{ name: 'Attack', level: 1, baseLevel: 1, experience: attackXp },
|
||||
{ name: 'Strength', level: 1, baseLevel: 1, experience: 0 },
|
||||
{ name: 'Defence', level: 1, baseLevel: 1, experience: 0 },
|
||||
{ name: 'Hitpoints', level: 10, baseLevel: 10, experience: 1_154 }
|
||||
@@ -49,28 +50,46 @@ function state(targetIndex: number, message?: string) {
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createBot(finalTargetIndex: number) {
|
||||
let current = state(-1);
|
||||
const final = state(finalTargetIndex, "I'm already under attack!");
|
||||
function createBot(finalTargetIndex: number, finalAttackXp: number = 0, interrupted: boolean = false) {
|
||||
const initial = state(-1);
|
||||
let current = initial;
|
||||
const final = state(finalTargetIndex, "I'm already under attack!", finalAttackXp);
|
||||
final.dialog.isOpen = interrupted;
|
||||
let dismissCount = 0;
|
||||
const sdk = {
|
||||
getState: () => current,
|
||||
sendInteractNpc: async () => ({ success: true, message: 'dispatched', phase: 'dispatch' }),
|
||||
waitForStateChange: async () => {
|
||||
current = final;
|
||||
return final;
|
||||
if (current === initial) current = final;
|
||||
return current;
|
||||
},
|
||||
sendClickDialog: async () => {
|
||||
dismissCount++;
|
||||
current = { ...current, dialog: { ...current.dialog, isOpen: false } };
|
||||
return { success: true, message: 'dismissed' };
|
||||
}
|
||||
};
|
||||
return new BotActions(sdk as any);
|
||||
return { bot: new BotActions(sdk as any), getDismissCount: () => dismissCount };
|
||||
}
|
||||
|
||||
describe('BotActions.attackNpc observation', () => {
|
||||
test('treats a refusal race as success when the requested target is engaged', async () => {
|
||||
const result = await createBot(target.index).attackNpc(target);
|
||||
const result = await createBot(target.index).bot.attackNpc(target);
|
||||
expect(result).toEqual({ success: true, message: 'Already fighting Guard' });
|
||||
});
|
||||
|
||||
test('keeps the refusal truthful when a different target is engaged', async () => {
|
||||
const result = await createBot(7).attackNpc(target);
|
||||
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);
|
||||
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);
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(harness.getDismissCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
* Logic tests for the SDK chat system — no bot required.
|
||||
*
|
||||
* Covers the accumulated ChatHistory buffer (merge/dedup across state-sync
|
||||
* snapshots, tick-split edge case, page-reload tick reset, retention cap) and
|
||||
* snapshots, publication-split edge case, page-reload cursor reset, retention cap) and
|
||||
* the SDK surface built on it (getChat, getNewChat cursor, getChatFrom,
|
||||
* waitForChat), driving the SDK through its real handleMessage path.
|
||||
*/
|
||||
@@ -31,6 +31,7 @@ function msg(tick: number, text: string, opts: Partial<GameMessage> = {}): GameM
|
||||
text,
|
||||
sender: opts.sender ?? 'partner',
|
||||
tick,
|
||||
observationId: opts.observationId,
|
||||
fromSelf: opts.fromSelf ?? false,
|
||||
};
|
||||
}
|
||||
@@ -88,6 +89,19 @@ async function run(): Promise<void> {
|
||||
`fresh=${texts(fresh)}`);
|
||||
}
|
||||
|
||||
// A duplicate identical message at the same raw game tick remains distinct
|
||||
// when it first becomes visible in a later publication.
|
||||
{
|
||||
const h = new ChatHistory();
|
||||
h.record([msg(11, 'same', { observationId: 2 })]);
|
||||
const fresh = h.record([
|
||||
msg(11, 'same', { observationId: 2 }),
|
||||
msg(11, 'same', { observationId: 3 })
|
||||
]);
|
||||
check('keeps identical same-tick messages from later publications',
|
||||
fresh.length === 1 && fresh[0]!.observationId === 3 && h.all().length === 2);
|
||||
}
|
||||
|
||||
// 5. Snapshot window slid past history (heavy spam): older-than-held
|
||||
// messages in the snapshot are new... they can't be — the window only
|
||||
// slides forward. All-newer snapshot appends everything.
|
||||
|
||||
@@ -17,9 +17,11 @@ describe('TemporaryDoorBlocklist', () => {
|
||||
|
||||
blocklist.block(door, 500);
|
||||
expect(blocklist.active()).toEqual([door]);
|
||||
expect(blocklist.has(door.level, door.x, door.z)).toBeTrue();
|
||||
|
||||
now = 1_500;
|
||||
expect(blocklist.active()).toEqual([]);
|
||||
expect(blocklist.has(door.level, door.x, door.z)).toBeFalse();
|
||||
});
|
||||
|
||||
test('is isolated per SDK session', () => {
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface GameMessage {
|
||||
/** Sender name with @cr/@col codes stripped (empty for system messages). */
|
||||
sender: string;
|
||||
tick: number;
|
||||
/** Monotonic publication revision when this message first became agent-visible. */
|
||||
observationId?: number;
|
||||
/** True if this client sent the message (own public speech or sent PM). */
|
||||
fromSelf: boolean;
|
||||
}
|
||||
@@ -148,6 +150,8 @@ export function isPlayerChat(type: number): boolean {
|
||||
export interface DialogEntry {
|
||||
text: string[]; // Lines of text in the dialog
|
||||
tick: number; // Game tick when captured
|
||||
/** Monotonic publication revision when this dialog first became agent-visible. */
|
||||
observationId?: number;
|
||||
interfaceId: number; // Interface ID of the dialog
|
||||
}
|
||||
|
||||
@@ -231,6 +235,8 @@ export interface CombatStyleState {
|
||||
|
||||
export interface CombatEvent {
|
||||
tick: number;
|
||||
/** Monotonic publication revision when this event first became agent-visible. */
|
||||
observationId?: number;
|
||||
type: 'damage_taken' | 'damage_dealt' | 'kill';
|
||||
damage: number;
|
||||
sourceType: 'player' | 'npc' | 'other_player';
|
||||
@@ -290,6 +296,8 @@ export interface PrayerResult {
|
||||
|
||||
export interface BotWorldState {
|
||||
tick: number;
|
||||
/** Monotonic state publication cursor; advances even for multiple publications in one game tick. */
|
||||
revision?: number;
|
||||
inGame: boolean;
|
||||
player: PlayerState | null;
|
||||
skills: SkillState[];
|
||||
|
||||
@@ -501,7 +501,8 @@ const SyncModule = {
|
||||
this.sendToBot(botSession, {
|
||||
type: 'action',
|
||||
action: message.action,
|
||||
actionId: message.actionId
|
||||
actionId: message.actionId,
|
||||
actionTimeoutMs: message.actionTimeoutMs
|
||||
});
|
||||
|
||||
console.log(`[Gateway] [${botSession.username}] SDK action: ${message.action?.type} (${message.actionId})`);
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface SyncToBotMessage {
|
||||
type: 'action' | 'thinking' | 'error' | 'status' | 'screenshot_request' | 'save_and_disconnect';
|
||||
action?: BotAction;
|
||||
actionId?: string; // For correlation
|
||||
actionTimeoutMs?: number; // SDK deadline, so browser queue can expire work first
|
||||
thinking?: string;
|
||||
error?: string;
|
||||
status?: string;
|
||||
@@ -49,6 +50,7 @@ export interface SDKMessage {
|
||||
mode?: SDKConnectionMode; // Connection mode: 'control' (default) or 'observe'
|
||||
actionId?: string;
|
||||
action?: BotAction;
|
||||
actionTimeoutMs?: number;
|
||||
screenshotId?: string; // For screenshot request correlation
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import { BotActionQueue } from './ActionQueue.js';
|
||||
import type { QueuedBotAction } from './ActionQueue.js';
|
||||
|
||||
function entry(actionId: string): QueuedBotAction {
|
||||
function entry(actionId: string): Omit<QueuedBotAction, 'generation' | 'enqueuedAt' | 'expiresAt'> {
|
||||
return {
|
||||
actionId,
|
||||
action: { type: 'none', reason: 'test' }
|
||||
@@ -15,30 +15,65 @@ describe('BotActionQueue', () => {
|
||||
const first = entry('first');
|
||||
const second = entry('second');
|
||||
|
||||
queue.enqueue(first);
|
||||
queue.enqueue(second);
|
||||
const queuedFirst = queue.enqueue(first)!;
|
||||
const queuedSecond = queue.enqueue(second)!;
|
||||
|
||||
expect(queue.startNext()).toBe(first);
|
||||
expect(queue.startNext()).toBe(queuedFirst);
|
||||
expect(queue.startNext()).toBeNull();
|
||||
expect(queue.active).toBe(first);
|
||||
expect(queue.active).toBe(queuedFirst);
|
||||
expect(queue.pendingCount).toBe(1);
|
||||
|
||||
expect(queue.complete(first)).toBeTrue();
|
||||
expect(queue.startNext()).toBe(second);
|
||||
expect(queue.complete(queuedFirst)).toBeTrue();
|
||||
expect(queue.startNext()).toBe(queuedSecond);
|
||||
});
|
||||
|
||||
test('ignores stale async completions after the queue is cleared', () => {
|
||||
const queue = new BotActionQueue();
|
||||
const stale = entry('stale');
|
||||
queue.enqueue(stale);
|
||||
expect(queue.startNext()).toBe(stale);
|
||||
const queuedStale = queue.enqueue(stale)!;
|
||||
expect(queue.startNext()).toBe(queuedStale);
|
||||
|
||||
queue.clear();
|
||||
const fresh = entry('fresh');
|
||||
queue.enqueue(fresh);
|
||||
expect(queue.startNext()).toBe(fresh);
|
||||
const queuedFresh = queue.enqueue(fresh)!;
|
||||
expect(queue.startNext()).toBe(queuedFresh);
|
||||
|
||||
expect(queue.complete(stale)).toBeFalse();
|
||||
expect(queue.active).toBe(fresh);
|
||||
expect(queue.complete(queuedStale)).toBeFalse();
|
||||
expect(queue.active).toBe(queuedFresh);
|
||||
});
|
||||
|
||||
test('holds fresh work behind a stale in-flight promise after reconnect', () => {
|
||||
const queue = new BotActionQueue();
|
||||
const stale = queue.enqueue(entry('stale'))!;
|
||||
expect(queue.startNext()).toBe(stale);
|
||||
|
||||
queue.beginGeneration();
|
||||
const fresh = queue.enqueue(entry('fresh'))!;
|
||||
|
||||
expect(queue.isCurrentGeneration(stale)).toBeFalse();
|
||||
expect(queue.startNext()).toBeNull();
|
||||
expect(queue.active).toBe(stale);
|
||||
|
||||
expect(queue.complete(stale)).toBeTrue();
|
||||
expect(queue.startNext()).toBe(fresh);
|
||||
});
|
||||
|
||||
test('rejects saturation instead of accepting unbounded ghost actions', () => {
|
||||
const queue = new BotActionQueue(Date.now, 2);
|
||||
expect(queue.enqueue(entry('one'))).not.toBeNull();
|
||||
expect(queue.enqueue(entry('two'))).not.toBeNull();
|
||||
expect(queue.enqueue(entry('three'))).toBeNull();
|
||||
});
|
||||
|
||||
test('expires pending entries before the SDK action timeout', () => {
|
||||
let now = 1_000;
|
||||
const queue = new BotActionQueue(() => now, 64, 55_000);
|
||||
const queued = queue.enqueue(entry('expires'), 4_000)!;
|
||||
|
||||
now += 3_999;
|
||||
expect(queue.expirePending()).toEqual([]);
|
||||
now += 1;
|
||||
expect(queue.expirePending()).toEqual([queued]);
|
||||
expect(queue.startNext()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@ import type { BotAction } from './types.js';
|
||||
export interface QueuedBotAction {
|
||||
action: BotAction;
|
||||
actionId: string | null;
|
||||
generation: number;
|
||||
enqueuedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,9 +18,53 @@ export interface QueuedBotAction {
|
||||
export class BotActionQueue {
|
||||
private entries: QueuedBotAction[] = [];
|
||||
private current: QueuedBotAction | null = null;
|
||||
private generation = 0;
|
||||
|
||||
enqueue(entry: QueuedBotAction): void {
|
||||
this.entries.push(entry);
|
||||
constructor(
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly maxPending: number = 64,
|
||||
private readonly entryTtlMs: number = 55_000
|
||||
) {}
|
||||
|
||||
enqueue(
|
||||
entry: Omit<QueuedBotAction, 'generation' | 'enqueuedAt' | 'expiresAt'>,
|
||||
ttlMs: number = this.entryTtlMs
|
||||
): QueuedBotAction | null {
|
||||
if (this.entries.length >= this.maxPending) return null;
|
||||
const enqueuedAt = this.now();
|
||||
const queued = {
|
||||
...entry,
|
||||
generation: this.generation,
|
||||
enqueuedAt,
|
||||
expiresAt: enqueuedAt + Math.max(0, ttlMs)
|
||||
};
|
||||
this.entries.push(queued);
|
||||
return queued;
|
||||
}
|
||||
|
||||
/** Remove pending work that can no longer complete before SDK timeout. */
|
||||
expirePending(): QueuedBotAction[] {
|
||||
const now = this.now();
|
||||
const expired: QueuedBotAction[] = [];
|
||||
this.entries = this.entries.filter(entry => {
|
||||
if (entry.expiresAt > now) return true;
|
||||
expired.push(entry);
|
||||
return false;
|
||||
});
|
||||
return expired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop work that has not started, but retain an in-flight action as a
|
||||
* quiescence barrier until its promise settles.
|
||||
*/
|
||||
beginGeneration(): void {
|
||||
this.generation++;
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
isCurrentGeneration(entry: QueuedBotAction): boolean {
|
||||
return entry.generation === this.generation;
|
||||
}
|
||||
|
||||
startNext(): QueuedBotAction | null {
|
||||
@@ -43,5 +90,6 @@ export class BotActionQueue {
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
this.current = null;
|
||||
this.generation++;
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,25 @@ export class BotOverlay implements GatewayMessageHandler {
|
||||
|
||||
// ============ GatewayMessageHandler Implementation ============
|
||||
|
||||
onAction(action: BotAction, actionId: string | null): void {
|
||||
onAction(action: BotAction, actionId: string | null, actionTimeoutMs?: number): void {
|
||||
console.log(`[BotOverlay] Received action: ${action.type} (${actionId})`);
|
||||
this.actionQueue.enqueue({ action, actionId });
|
||||
// Leave a small delivery margin so the SDK receives a deterministic
|
||||
// queue_expired result before its own action timeout rejects locally.
|
||||
const queueTtlMs = actionTimeoutMs === undefined
|
||||
? undefined
|
||||
: Math.max(0, actionTimeoutMs - 1_000);
|
||||
const queued = this.actionQueue.enqueue({ action, actionId }, queueTtlMs);
|
||||
if (!queued) {
|
||||
const result: ActionResult = {
|
||||
success: false,
|
||||
message: 'Action queue is full',
|
||||
phase: 'validation',
|
||||
reason: 'busy'
|
||||
};
|
||||
if (actionId) this.gateway.sendActionResult(actionId, result);
|
||||
this.ui.logAction('failed', result.message);
|
||||
return;
|
||||
}
|
||||
this.lastActionTime = Date.now();
|
||||
// Reset idle timer - SDK actions count as activity
|
||||
this.client.idleTimer = performance.now();
|
||||
@@ -106,11 +122,18 @@ export class BotOverlay implements GatewayMessageHandler {
|
||||
onConnected(): void {
|
||||
this.ui.logAction('connected', 'Connected to SDK gateway');
|
||||
|
||||
// Clear any stale pending actions from previous sessions
|
||||
// Drop queued work from the previous connection. An asynchronous action
|
||||
// already executing remains the active quiescence barrier until it
|
||||
// settles, so fresh actions can never overlap its game mutations.
|
||||
const active = this.actionQueue.active;
|
||||
if (this.actionQueue.active || this.actionQueue.pendingCount > 0) {
|
||||
console.log('[BotOverlay] Clearing stale action queue on connect');
|
||||
console.log('[BotOverlay] Advancing action queue generation on connect');
|
||||
}
|
||||
this.actionQueue.beginGeneration();
|
||||
// Wait actions have no background promise and can be released safely.
|
||||
if (active && this.waitTicks > 0) {
|
||||
this.actionQueue.complete(active);
|
||||
}
|
||||
this.actionQueue.clear();
|
||||
this.waitTicks = 0;
|
||||
}
|
||||
|
||||
@@ -143,6 +166,17 @@ export class BotOverlay implements GatewayMessageHandler {
|
||||
// ============ Main Tick Loop ============
|
||||
|
||||
tick(): void {
|
||||
for (const expired of this.actionQueue.expirePending()) {
|
||||
const result: ActionResult = {
|
||||
success: false,
|
||||
message: `Action expired in queue: ${expired.action.type}`,
|
||||
phase: 'validation',
|
||||
reason: 'queue_expired'
|
||||
};
|
||||
if (expired.actionId) this.gateway.sendActionResult(expired.actionId, result);
|
||||
this.ui.logAction('failed', result.message);
|
||||
}
|
||||
|
||||
// Handle wait ticks
|
||||
if (this.waitTicks > 0) {
|
||||
this.waitTicks--;
|
||||
@@ -201,7 +235,7 @@ export class BotOverlay implements GatewayMessageHandler {
|
||||
private collectWorldState(): BotWorldState | null {
|
||||
if (!this.collector || !this.client) return null;
|
||||
|
||||
const baseState = this.collector.collectState(this.serverTick);
|
||||
const baseState = this.collector.collectState(this.serverTick, true);
|
||||
const c = this.client as any;
|
||||
|
||||
// Get dialog state - include componentId for direct clicking
|
||||
@@ -267,14 +301,20 @@ export class BotOverlay implements GatewayMessageHandler {
|
||||
}
|
||||
|
||||
private finishAction(entry: QueuedBotAction, result: ActionResult): void {
|
||||
// An old async completion may arrive after reconnect cleared the queue.
|
||||
// Ignore a completion that is not the active quiescence barrier.
|
||||
if (this.actionQueue.active !== entry) return;
|
||||
|
||||
const publishResult = this.actionQueue.isCurrentGeneration(entry);
|
||||
this.actionQueue.complete(entry);
|
||||
if (!publishResult) {
|
||||
console.log(`[BotOverlay] Released stale action after reconnect: ${entry.action.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.actionId) {
|
||||
this.gateway.sendActionResult(entry.actionId, result);
|
||||
this.ui.logAction(result.success ? 'success' : 'failed', result.message);
|
||||
}
|
||||
this.actionQueue.complete(entry);
|
||||
this.sendState();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
mock.module('#3rdparty/tinymidipcm.js', () => ({
|
||||
stopMidi() {},
|
||||
setMidiVolume() {},
|
||||
playMidi() {}
|
||||
}));
|
||||
mock.module('#/client/MobileKeyboard.js', () => ({
|
||||
default: {
|
||||
draw() {},
|
||||
show() {},
|
||||
hide() {},
|
||||
isDisplayed: () => false,
|
||||
isWithinCanvasKeyboard: () => false,
|
||||
captureMouseDown() {},
|
||||
captureMouseUp() {},
|
||||
notifyTouchMove() {}
|
||||
}
|
||||
}));
|
||||
|
||||
(globalThis as any).window = {
|
||||
audioContext: {
|
||||
currentTime: 0,
|
||||
destination: {},
|
||||
createGain() {
|
||||
return {
|
||||
gain: { setValueAtTime() {}, linearRampToValueAtTime() {} },
|
||||
connect() {}
|
||||
};
|
||||
},
|
||||
createBuffer() {
|
||||
return { copyToChannel() {} };
|
||||
},
|
||||
createBufferSource() {
|
||||
return { connect() {}, start() {}, stop() {} };
|
||||
}
|
||||
}
|
||||
};
|
||||
(globalThis as any).navigator = { userAgent: 'bun-test' };
|
||||
(globalThis as any).fetch = async () => ({
|
||||
arrayBuffer: async () => new ArrayBuffer(0)
|
||||
});
|
||||
(globalThis as any).document = {
|
||||
hidden: false,
|
||||
body: { appendChild() {}, removeChild() {} },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
getElementById() { return null; },
|
||||
createElement(tag: string) {
|
||||
const element = {
|
||||
style: {},
|
||||
setAttribute() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() {},
|
||||
focus() {},
|
||||
blur() {}
|
||||
};
|
||||
if (tag === 'canvas') {
|
||||
return {
|
||||
...element,
|
||||
getContext: () => ({
|
||||
clearRect() {},
|
||||
drawImage() {},
|
||||
getImageData() { return {}; }
|
||||
})
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}
|
||||
};
|
||||
|
||||
const { Client } = await import('../client/Client.js');
|
||||
|
||||
describe('ranged spell dispatch', () => {
|
||||
test('dispatches a combat spell when adjacency routing fails', () => {
|
||||
const opcodes: number[] = [];
|
||||
const payload: number[] = [];
|
||||
const client = {
|
||||
ingame: true,
|
||||
out: { p2: (value: number) => payload.push(value) },
|
||||
localPlayer: { routeX: [1], routeZ: [1] },
|
||||
npc: { 42: { routeX: [5], routeZ: [6] } },
|
||||
tryMove: () => false,
|
||||
writePacketOpcode: (opcode: number) => opcodes.push(opcode)
|
||||
};
|
||||
|
||||
const result = Client.prototype.spellOnNpc.call(client, 42, 1152);
|
||||
|
||||
expect(result).toEqual({ success: true, routed: false });
|
||||
expect(opcodes).toEqual([181]); // ClientProt.OPNPCT
|
||||
expect(payload).toEqual([42, 1152]);
|
||||
});
|
||||
|
||||
test('dispatches Telekinetic Grab even when adjacency routing fails', () => {
|
||||
const opcodes: number[] = [];
|
||||
const payload: number[] = [];
|
||||
const client = {
|
||||
ingame: true,
|
||||
out: { p2: (value: number) => payload.push(value) },
|
||||
localPlayer: { routeX: [1], routeZ: [1] },
|
||||
mapBuildBaseX: 3200,
|
||||
mapBuildBaseZ: 3200,
|
||||
tryMove: () => false,
|
||||
writePacketOpcode: (opcode: number) => opcodes.push(opcode)
|
||||
};
|
||||
|
||||
const result = Client.prototype.spellOnGroundItem.call(
|
||||
client,
|
||||
3205,
|
||||
3206,
|
||||
995,
|
||||
1151
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true, routed: false });
|
||||
expect(opcodes).toEqual([91]); // ClientProt.OPOBJT
|
||||
expect(payload).toEqual([3205, 3206, 995, 1151]);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { BotAction, BotWorldState } from './types.js';
|
||||
|
||||
export interface GatewayMessageHandler {
|
||||
onAction(action: BotAction, actionId: string | null): void;
|
||||
onAction(action: BotAction, actionId: string | null, actionTimeoutMs?: number): void;
|
||||
onScreenshotRequest(screenshotId?: string): void;
|
||||
onConnected(): void;
|
||||
onDisconnected(): void;
|
||||
@@ -176,7 +176,7 @@ export class GatewayConnection {
|
||||
private handleMessage(msg: any): void {
|
||||
if (msg.type === 'action') {
|
||||
console.log(`[GatewayConnection] Received action: ${msg.action?.type} (${msg.actionId})`);
|
||||
this.handler.onAction(msg.action, msg.actionId || null);
|
||||
this.handler.onAction(msg.action, msg.actionId || null, msg.actionTimeoutMs);
|
||||
} else if (msg.type === 'status') {
|
||||
console.log(`[GatewayConnection] Gateway status: ${msg.status}`);
|
||||
} else if (msg.type === 'screenshot_request') {
|
||||
|
||||
@@ -88,23 +88,42 @@ describe('BotStateCollector combat state', () => {
|
||||
expect((collector as any).combatEvents[0].tick).toBe(12);
|
||||
});
|
||||
|
||||
test('normalizes raw client message cycles onto the public state clock', () => {
|
||||
test('publishes UI-observed and same-tick duplicate messages on newer revisions', () => {
|
||||
const client = createClient();
|
||||
client.chatText = ['Hello'];
|
||||
client.chatUsername = ['Guide'];
|
||||
client.chatType = [0];
|
||||
client.messageTick = [9_999];
|
||||
client.messageSequence = [1];
|
||||
const collector = new BotStateCollector(client);
|
||||
|
||||
expect((collector as any).collectGameMessages(5)[0].tick).toBe(5);
|
||||
expect((collector as any).collectGameMessages(6)[0].tick).toBe(5);
|
||||
// UI polling sees the message at server tick 5, but must not consume
|
||||
// the publication cursor before an agent-visible state is sent.
|
||||
const uiState = collector.collectState(5, false);
|
||||
expect(uiState.revision).toBe(0);
|
||||
expect(uiState.gameMessages[0]!.observationId).toBeUndefined();
|
||||
expect(uiState.combatEvents[0]!.observationId).toBeUndefined();
|
||||
|
||||
client.chatText.unshift('New message');
|
||||
client.chatUsername.unshift('');
|
||||
const published = collector.collectState(6, true);
|
||||
expect(published.revision).toBe(1);
|
||||
expect(published.gameMessages[0]).toMatchObject({ tick: 5, observationId: 1 });
|
||||
expect(published.combatEvents[0]).toMatchObject({ tick: 5, observationId: 1 });
|
||||
|
||||
// A repeated publication at the same server tick advances revision,
|
||||
// while old evidence retains its original observation id.
|
||||
const repeated = collector.collectState(6, true);
|
||||
expect(repeated).toMatchObject({ tick: 6, revision: 2 });
|
||||
expect(repeated.gameMessages[0]!.observationId).toBe(1);
|
||||
|
||||
client.chatText.unshift('Hello');
|
||||
client.chatUsername.unshift('Guide');
|
||||
client.chatType.unshift(0);
|
||||
client.messageTick.unshift(10_000);
|
||||
const messages = (collector as any).collectGameMessages(6);
|
||||
expect(messages.at(-1).tick).toBe(6);
|
||||
client.messageTick.unshift(9_999);
|
||||
client.messageSequence.unshift(2);
|
||||
const sameTickDuplicate = collector.collectState(6, true);
|
||||
|
||||
expect(sameTickDuplicate.revision).toBe(3);
|
||||
expect(sameTickDuplicate.gameMessages.map(message => message.observationId)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
test('represents unrevealed NPC health as unknown rather than dead', () => {
|
||||
|
||||
@@ -83,8 +83,9 @@ export class BotStateCollector implements ScanProvider {
|
||||
private lifeId: number = 0;
|
||||
private respawnCount: number = 0;
|
||||
private lastDeathTick: number | null = null;
|
||||
private messagePublicTicks = new Map<string, number>();
|
||||
private dialogPublicTicks = new Map<string, number>();
|
||||
private publicationRevision = 0;
|
||||
private messageObservations = new Map<string, { tick: number; observationId: number }>();
|
||||
private dialogObservations = new Map<string, { tick: number; observationId: number }>();
|
||||
|
||||
// Cached prayer component map (built once on first use)
|
||||
private prayerComponentMap: PrayerComponentMap | null = null;
|
||||
@@ -93,15 +94,22 @@ export class BotStateCollector implements ScanProvider {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
collectState(currentTick: number): BotState {
|
||||
collectState(currentTick: number, publish: boolean = false): BotState {
|
||||
const c = this.client as any; // Access private members
|
||||
const clientCycle = this.client.getClientCycle();
|
||||
if (publish) this.publicationRevision++;
|
||||
|
||||
// Collect combat events (must be done before returning state)
|
||||
this.collectCombatEvents(currentTick);
|
||||
if (publish) {
|
||||
for (const event of this.combatEvents) {
|
||||
if (!event.observationId) event.observationId = this.publicationRevision;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tick: currentTick,
|
||||
revision: this.publicationRevision,
|
||||
player: this.collectPlayerState(currentTick, clientCycle),
|
||||
skills: this.collectSkills(),
|
||||
inventory: this.collectInventory(INVENTORY_INTERFACE_ID),
|
||||
@@ -111,8 +119,8 @@ export class BotStateCollector implements ScanProvider {
|
||||
nearbyPlayers: this.collectNearbyPlayers(),
|
||||
nearbyLocs: this.scanNearbyLocs(),
|
||||
groundItems: this.scanGroundItems(),
|
||||
gameMessages: this.collectGameMessages(currentTick),
|
||||
recentDialogs: this.collectRecentDialogs(currentTick),
|
||||
gameMessages: this.collectGameMessages(currentTick, publish),
|
||||
recentDialogs: this.collectRecentDialogs(currentTick, publish),
|
||||
menuActions: this.collectMenuActions(),
|
||||
shop: this.collectShopState(),
|
||||
bank: this.collectBankState(),
|
||||
@@ -235,18 +243,29 @@ export class BotStateCollector implements ScanProvider {
|
||||
return { isOpen, options, isWaiting };
|
||||
}
|
||||
|
||||
private collectRecentDialogs(currentTick: number): Array<{ text: string[]; tick: number; interfaceId: number }> {
|
||||
private collectRecentDialogs(currentTick: number, publish: boolean): Array<{ text: string[]; tick: number; interfaceId: number; observationId?: number }> {
|
||||
if (typeof this.client.getDialogHistory !== 'function') return [];
|
||||
|
||||
const activeKeys = new Set<string>();
|
||||
const dialogs = this.client.getDialogHistory().map(entry => {
|
||||
const key = `${entry.tick}|${entry.interfaceId}|${entry.text.join('\u0000')}`;
|
||||
activeKeys.add(key);
|
||||
if (!this.dialogPublicTicks.has(key)) this.dialogPublicTicks.set(key, currentTick);
|
||||
return { ...entry, tick: this.dialogPublicTicks.get(key)! };
|
||||
let observation = this.dialogObservations.get(key);
|
||||
if (!observation) {
|
||||
observation = { tick: currentTick, observationId: 0 };
|
||||
this.dialogObservations.set(key, observation);
|
||||
}
|
||||
if (publish && observation.observationId === 0) {
|
||||
observation.observationId = this.publicationRevision;
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
tick: observation.tick,
|
||||
observationId: observation.observationId || undefined
|
||||
};
|
||||
});
|
||||
for (const key of this.dialogPublicTicks.keys()) {
|
||||
if (!activeKeys.has(key)) this.dialogPublicTicks.delete(key);
|
||||
for (const key of this.dialogObservations.keys()) {
|
||||
if (!activeKeys.has(key)) this.dialogObservations.delete(key);
|
||||
}
|
||||
return dialogs;
|
||||
}
|
||||
@@ -961,13 +980,14 @@ export class BotStateCollector implements ScanProvider {
|
||||
|
||||
private static readonly MAX_GAME_MESSAGES = 50;
|
||||
|
||||
private collectGameMessages(currentTick: number): GameMessage[] {
|
||||
private collectGameMessages(currentTick: number, publish: boolean): GameMessage[] {
|
||||
const c = this.client as any;
|
||||
|
||||
const messageText = c.chatText || [];
|
||||
const messageSender = c.chatUsername || [];
|
||||
const messageType = c.chatType || [];
|
||||
const messageTick = c.messageTick || [];
|
||||
const messageSequence = c.messageSequence || [];
|
||||
|
||||
const ownName = String(c.localPlayer?.name ?? '').replace(/@\w+@/g, '').toLowerCase();
|
||||
|
||||
@@ -979,6 +999,7 @@ export class BotStateCollector implements ScanProvider {
|
||||
// (sdk.getChat) so system messages stay available here too.
|
||||
const messages: GameMessage[] = [];
|
||||
const activeKeys = new Set<string>();
|
||||
const fallbackOccurrences = new Map<string, number>();
|
||||
for (let i = 0; i < messageText.length && messages.length < BotStateCollector.MAX_GAME_MESSAGES; i++) {
|
||||
const text = messageText[i];
|
||||
if (!text) continue;
|
||||
@@ -988,20 +1009,32 @@ export class BotStateCollector implements ScanProvider {
|
||||
const sender = String(messageSender[i] || '').replace(/@\w+@/g, '');
|
||||
const fromSelf = type === 6 || (sender !== '' && sender.toLowerCase() === ownName);
|
||||
const rawTick = messageTick[i] || 0;
|
||||
const key = `${rawTick}|${type}|${sender}|${text}`;
|
||||
const baseKey = `${rawTick}|${type}|${sender}|${text}`;
|
||||
const occurrence = fallbackOccurrences.get(baseKey) ?? 0;
|
||||
fallbackOccurrences.set(baseKey, occurrence + 1);
|
||||
const sequence = messageSequence[i] || 0;
|
||||
const key = sequence > 0 ? `seq:${sequence}` : `${baseKey}|occ:${occurrence}`;
|
||||
activeKeys.add(key);
|
||||
if (!this.messagePublicTicks.has(key)) this.messagePublicTicks.set(key, currentTick);
|
||||
let observation = this.messageObservations.get(key);
|
||||
if (!observation) {
|
||||
observation = { tick: currentTick, observationId: 0 };
|
||||
this.messageObservations.set(key, observation);
|
||||
}
|
||||
if (publish && observation.observationId === 0) {
|
||||
observation.observationId = this.publicationRevision;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type,
|
||||
text,
|
||||
sender,
|
||||
tick: this.messagePublicTicks.get(key)!,
|
||||
tick: observation.tick,
|
||||
observationId: observation.observationId || undefined,
|
||||
fromSelf
|
||||
});
|
||||
}
|
||||
for (const key of this.messagePublicTicks.keys()) {
|
||||
if (!activeKeys.has(key)) this.messagePublicTicks.delete(key);
|
||||
for (const key of this.messageObservations.keys()) {
|
||||
if (!activeKeys.has(key)) this.messageObservations.delete(key);
|
||||
}
|
||||
|
||||
// Return in chronological order (oldest first, newest last) so .slice(-N)
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface DoorInfo {
|
||||
export class TemporaryDoorBlocklist {
|
||||
block(_door: DoorInfo, _ttlMs?: number): void {}
|
||||
active(): DoorInfo[] { return []; }
|
||||
has(_level: number, _x: number, _z: number): boolean { return false; }
|
||||
clear(): void {}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,12 +125,16 @@ export interface GameMessage {
|
||||
text: string;
|
||||
sender: string; // @cr/@col codes stripped; empty for system messages
|
||||
tick: number;
|
||||
/** Monotonic publication revision when this message first became agent-visible. */
|
||||
observationId?: number;
|
||||
fromSelf: boolean; // true if this client sent it (own speech or sent PM)
|
||||
}
|
||||
|
||||
export interface DialogEntry {
|
||||
text: string[]; // Lines of text in the dialog
|
||||
tick: number; // Game tick when captured
|
||||
/** Monotonic publication revision when this dialog first became agent-visible. */
|
||||
observationId?: number;
|
||||
interfaceId: number; // Interface ID of the dialog
|
||||
}
|
||||
|
||||
@@ -228,6 +232,8 @@ export interface CombatStyleState {
|
||||
export interface CombatEvent {
|
||||
/** Game tick when event occurred */
|
||||
tick: number;
|
||||
/** Monotonic publication revision when this event first became agent-visible. */
|
||||
observationId?: number;
|
||||
/** Type of combat event */
|
||||
type: 'damage_taken' | 'damage_dealt' | 'kill';
|
||||
/** Damage amount (for damage events) */
|
||||
@@ -265,6 +271,8 @@ export interface PrayerState {
|
||||
|
||||
export interface BotState {
|
||||
tick: number;
|
||||
/** Monotonic publication cursor; advances for each state sent to agents. */
|
||||
revision: number;
|
||||
player: PlayerState | null;
|
||||
skills: SkillState[];
|
||||
inventory: InventoryItem[];
|
||||
|
||||
@@ -117,7 +117,7 @@ export type ClientActionFailureReason =
|
||||
* belongs to the higher-level BotActions API.
|
||||
*/
|
||||
export type ClientActionResult =
|
||||
| { success: true }
|
||||
| { success: true; routed?: boolean }
|
||||
| { success: false; reason: ClientActionFailureReason };
|
||||
|
||||
export class Client extends GameShell {
|
||||
@@ -526,6 +526,8 @@ export class Client extends GameShell {
|
||||
private chatUsername: (string | null)[] = new TypedArray1d(100, null);
|
||||
private chatText: (string | null)[] = new TypedArray1d(100, null);
|
||||
private messageTick: Int32Array = new Int32Array(100); // Track when each message arrived
|
||||
private messageSequence: number[] = new TypedArray1d(100, 0);
|
||||
private nextMessageSequence: number = 0;
|
||||
// Dialog history storage (similar to message history)
|
||||
private dialogHistory: Array<{ text: string[]; tick: number; interfaceId: number }> = [];
|
||||
private dialogHistoryMax: number = 10;
|
||||
@@ -1156,16 +1158,12 @@ export class Client extends GameShell {
|
||||
0, // forceapproach
|
||||
2 // type = MOVE_OPCLICK
|
||||
);
|
||||
if (!routed) {
|
||||
return { success: false, reason: 'cant_reach' };
|
||||
}
|
||||
|
||||
// Send OPNPCT packet
|
||||
this.writePacketOpcode(ClientProt.OPNPCT);
|
||||
this.out.p2(npcIndex);
|
||||
this.out.p2(spellComponent);
|
||||
|
||||
return { success: true };
|
||||
return { success: true, routed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1233,10 +1231,6 @@ export class Client extends GameShell {
|
||||
0,
|
||||
2 // MOVE_OPCLICK
|
||||
);
|
||||
if (!routed) {
|
||||
return { success: false, reason: 'cant_reach' };
|
||||
}
|
||||
|
||||
// Send OPOBJT packet: x, z, itemId, spellComponent
|
||||
this.writePacketOpcode(ClientProt.OPOBJT);
|
||||
this.out.p2(worldX);
|
||||
@@ -1244,7 +1238,7 @@ export class Client extends GameShell {
|
||||
this.out.p2(itemId);
|
||||
this.out.p2(spellComponent);
|
||||
|
||||
return { success: true };
|
||||
return { success: true, routed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3979,6 +3973,7 @@ export class Client extends GameShell {
|
||||
|
||||
for (let i: number = 0; i < 100; i++) {
|
||||
this.chatText[i] = null;
|
||||
this.messageSequence[i] = 0;
|
||||
}
|
||||
|
||||
this.useMode = 0;
|
||||
@@ -13710,12 +13705,14 @@ export class Client extends GameShell {
|
||||
this.chatUsername[i] = this.chatUsername[i - 1];
|
||||
this.chatText[i] = this.chatText[i - 1];
|
||||
this.messageTick[i] = this.messageTick[i - 1];
|
||||
this.messageSequence[i] = this.messageSequence[i - 1];
|
||||
}
|
||||
|
||||
this.chatType[0] = type;
|
||||
this.chatUsername[0] = sender;
|
||||
this.chatText[0] = text;
|
||||
this.messageTick[0] = Client.loopCycle;
|
||||
this.messageSequence[0] = ++this.nextMessageSequence;
|
||||
}
|
||||
|
||||
private isFriend(username: string | null): boolean {
|
||||
|
||||
Reference in new issue
Block a user