fix state publication and action queue races

This commit is contained in:
max bittker committed 2026-07-24 17:27:54 -04:00
1 parent 9337aae9af
commit 2547bc7546
21 files changed
+516 -145

No files matched your search

+9 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+5
View File
@@ -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();
}
+30 -11
View File
@@ -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);
});
});
+15 -1
View File
@@ -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', () => {
+8
View File
@@ -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[];
+2 -1
View File
@@ -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})`);
+2
View File
@@ -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
}
+48 -13
View File
@@ -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();
});
});
+50 -2
View File
@@ -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++;
}
}
+48 -8
View File
@@ -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', () => {
+49 -16
View File
@@ -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 {}
}
+8
View File
@@ -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[];
+8 -11
View File
@@ -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 {