SDK chat: 500-deep accumulated history + waitForChat

The SDK read chat only from the latest state-sync snapshot (50 messages),
so anything older was unrecoverable and getNewChat missed messages that
scrolled out of the window between polls. Also, chat cursors were
high-water *ticks* (Client.loopCycle), which restart near 0 on page
reload — permanently muting getNewChat and the MCP chat display.

- sdk/chat-history.ts: ChatHistory merges every snapshot into a deduped
  500-deep buffer. Handles the same-tick split (chat packet processed
  after the PLAYER_INFO that triggered the previous sync shares its
  loopCycle) and re-baselines on tick reset instead of dropping messages.
- getChat/getNewChat/getChatFrom read the accumulated history;
  getChat({limit: 0}) returns everything since connect. getNewChat's
  cursor is now a count into the history — immune to resets/eviction.
- sdk.waitForChat({from, matching, types, timeout}): await the next
  matching message (null on timeout), own private cursor so it doesn't
  consume getNewChat's. One-liner for bot<->bot coordination.
- mcp/server.ts: reset lastShownMessageTick when every visible tick is
  below it (client reloaded), instead of muting chat forever.
- learnings/chat.md usage guide, CLAUDE.md quick-ref rows, API.md
  regenerated, 24-case logic test (sdk/test/chat-history.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-07-06 11:45:22 -04:00
1 parent 0e4aeb82a2
commit 0f4e5fae85
7 files changed
+525 -27

No files matched your search

+3 -1
View File
@@ -179,7 +179,9 @@ For the complete method reference, see **[sdk/API.md](sdk/API.md)** (auto-genera
| `sendDropItem(slot)` | Drop inventory item |
| `sendUseItem(slot)` | Use inventory item (bury, etc.) |
| `sendUseItemOnItem(src, dst)` | Combine two items |
| `sendSay(message)` | Send chat message |
| `say(text)` | Send chat, auto-chunked past the length cap |
| `getChat(opts?)` / `getNewChat(opts?)` | Read chat history (500-deep) / only unseen messages |
| `waitForChat(opts?)` | Wait for a message (`{from, matching, timeout}`) |
| `waitForCondition(pred)` | Wait for state predicate |
| `waitForTicks(n)` | Wait n game ticks |
| `scanNearbyLocs(radius?)` | Extended-range loc scan |
+61
View File
@@ -0,0 +1,61 @@
# Chat: reading, sending, and bot↔bot coordination
## Reading chat — use the helpers, not raw state
Don't slice `getState().gameMessages` — that's a 50-message snapshot that churns
fast under system spam. The SDK accumulates chat across state syncs into a
**500-deep history** (per SDK instance, since connect), and all helpers read
from it:
```typescript
sdk.getChat() // last 20 player-chat lines (public + PMs), newest last
sdk.getChat({ limit: 0 }) // full retained history
sdk.getChat({ types: [0] }) // system/game messages instead
sdk.getChat({ includeSelf: true }) // include your own lines
sdk.getChatFrom('partner') // lines from a specific sender (substring match)
sdk.getNewChat() // only lines since your last getNewChat() call
```
`getNewChat()` keeps an internal cursor — poll it in a loop and you'll see each
message exactly once, even if dozens of lines scrolled past between polls.
## Waiting for a message
For coordination, prefer `waitForChat` over polling:
```typescript
await sdk.say('meet me at the bank');
const reply = await sdk.waitForChat({ from: 'partner', timeout: 60_000 });
if (!reply) console.log('no reply within 60s'); // resolves null on timeout
// Filter by content too:
const go = await sdk.waitForChat({ matching: /ready|go/i, timeout: 30_000 });
```
It only matches messages arriving *after* the call, excludes your own messages
by default, and doesn't disturb the `getNewChat()` cursor.
## Sending
- `sdk.sendSay(text)` — one message. The server caps length (default 80 chars,
see `sdk.maxMessageLength`); the result's `data` reports
`{ truncated, filtered, finalText }` so you know if it was clipped/censored.
- `sdk.say(longText)` — auto-chunks onto word boundaries and sends in order.
Use this for anything that might exceed the cap.
## Message anatomy
`GameMessage`: `{ type, text, sender, tick, fromSelf }`.
Type codes: 0 = system/game, 1/2 = public chat, 3/7 = PM received, 6 = PM sent.
Player chat = types 1/2/3/6/7 (the default filter). `sender` has colour/crown
codes already stripped.
## Gotchas
- History starts at connect: a fresh SDK/CLI process only backfills the ~50
messages still in the client's snapshot window. For long-running coordination,
keep one process alive rather than re-running short scripts.
- Each SDK instance has its own history and cursor — two scripts on the same
bot don't share `getNewChat()` state.
- `showChat: false` in the SDK config strips player chat before it reaches the
history, so `getChat()` will only ever return system lines.
+7
View File
@@ -276,6 +276,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
// so the SDK surface stays minimal for script authors.
const state = connection.sdk.getState();
if (state) {
// Message ticks are the client's loopCycle, which restarts near 0
// when the bot page reloads. If every tick is below our cursor the
// client reloaded — reset the cursor or chat would be muted forever.
if (state.gameMessages && state.gameMessages.length > 0 &&
state.gameMessages.every(m => m.tick < connection.lastShownMessageTick)) {
connection.lastShownMessageTick = -1;
}
const sinceTick = connection.lastShownMessageTick;
if (state.gameMessages) {
for (const m of state.gameMessages) {
+8 -1
View File
@@ -132,7 +132,7 @@ These methods resolve when server **acknowledges** them (not when effects comple
| `getStateAge()` | Get age of current state in milliseconds |
| `getChat(opts)` | Read recent chat messages. |
| `getNewChat(opts)` | Read only chat messages that have arrived since the last call (cursor-based, newest last). |
| `getChatFrom(name, opts)` | Read recent chat from a specific sender (case-insensitive, substring match on name), newest last. |
| `getChatFrom(name, opts)` | Read recent chat from a specific sender (case-insensitive, substring match on name), newest last,... |
| `getSkill(name)` | Get a skill by name (case-insensitive). |
| `getSkillXp(name)` | Get XP for a skill by name. |
| `getSkills()` | Get all skills. |
@@ -218,6 +218,13 @@ These methods resolve when server **acknowledges** them (not when effects comple
|--------|-------------|
| `waitForBotConnection(timeout?)` | Wait for bot to connect to gateway after browser launch. |
| `waitForConnection(timeout)` | Wait for WebSocket connection to be established. |
| `waitForChat(opts: {
from?: string;
matching?: RegExp | string;
types?: readonly number[];
includeSelf?: boolean;
timeout?: number;
} = {})` | Wait for the next chat message matching the given filters (messages arriving after this call; you... |
| `waitForReady(timeout)` | Wait for game state to be fully loaded and ready. |
| `waitForStateChange(timeout)` | Wait for next state update from server. |
| `waitForTicks(ticks)` | Wait for a specific number of server ticks (~300ms each). |
+103
View File
@@ -0,0 +1,103 @@
/**
* Accumulated chat history for the SDK.
*
* The client keeps a 100-deep chat ring and the bot page snapshots the 50 most
* recent messages into every state sync (~1 per server tick). Reading only the
* latest snapshot means anything older than 50 messages is gone — a busy grind
* session evicts a teammate's instructions in under a minute. This class merges
* each snapshot into a persistent, deduplicated buffer so the SDK retains the
* 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.
*/
import type { GameMessage } from './types';
/** Messages retained by the SDK, independent of the client's 100-deep ring. */
export const CHAT_HISTORY_LIMIT = 500;
export class ChatHistory {
private messages: GameMessage[] = [];
private totalRecorded = 0;
private readonly limit: number;
constructor(limit: number = CHAT_HISTORY_LIMIT) {
this.limit = limit;
}
/**
* Total messages ever recorded (monotonic, unaffected by trimming). Read
* this as a cursor, then pass it to {@link since} to get what came after.
*/
get seen(): number {
return this.totalRecorded;
}
/**
* Merge a chronological state-sync snapshot into the history. Returns the
* messages that were genuinely new (also chronological).
*/
record(snapshot: GameMessage[]): GameMessage[] {
if (!snapshot || snapshot.length === 0) return [];
const last = this.messages[this.messages.length - 1];
let fresh: GameMessage[];
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
// 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.
let held = 0;
for (let i = this.messages.length - 1; i >= 0 && this.messages[i]!.tick === last.tick; 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;
fresh.push(m);
}
}
if (fresh.length > 0) {
this.messages.push(...fresh);
this.totalRecorded += fresh.length;
if (this.messages.length > this.limit) {
this.messages.splice(0, this.messages.length - this.limit);
}
}
return fresh;
}
/** All retained messages, oldest first. */
all(): GameMessage[] {
return this.messages;
}
/**
* Messages recorded after a cursor previously read from {@link seen},
* oldest first. If more messages arrived than the buffer retains, the
* overflow is silently gone — at 500 retained that takes minutes of
* uninterrupted spam between reads.
*/
since(cursor: number): GameMessage[] {
const missed = this.totalRecorded - cursor;
if (missed <= 0) return [];
return this.messages.slice(Math.max(0, this.messages.length - missed));
}
}
+88 -25
View File
@@ -22,6 +22,7 @@ import type {
GameMessage
} from './types';
import { PRAYER_INDICES, PRAYER_NAMES, PLAYER_CHAT_TYPES, isPlayerChat } from './types';
import { ChatHistory } from './chat-history';
import * as pathfinding from './pathfinding';
/**
@@ -104,8 +105,14 @@ export class BotSDK {
* (sdk_connected / sdk_info). Defaults to the RS wire limit until known.
*/
private serverMaxMessageLength: number = 80;
/** High-water tick consumed by getNewChat(); -1 means "nothing read yet". */
private chatCursor: number = -1;
/**
* Chat accumulated across state syncs. The client ring only holds 100
* messages (and each sync only carries 50), so without this an agent could
* never read further back than ~50 lines. See sdk/chat-history.ts.
*/
private chatHistory = new ChatHistory();
/** Cursor consumed by getNewChat(): value of chatHistory.seen at last read. */
private chatReadCursor: number = 0;
private pendingActions = new Map<string, PendingAction>();
private pendingScreenshots = new Map<string, PendingScreenshot>();
private stateListeners = new Set<(state: BotWorldState) => void>();
@@ -562,19 +569,18 @@ export class BotSDK {
/**
* Read recent chat messages. Returns player chat (public + PMs) by default,
* newest last. The underlying buffer holds the 50 most recent messages, so a
* teammate's line survives system spam (level-ups, combat) — unlike reading
* `getState().gameMessages` raw and slicing, which used to evict it fast.
* newest last. Reads from the SDK's accumulated history — up to 500
* messages retained since connect — so old lines survive both system spam
* (level-ups, combat) and the client's own 100-deep ring eviction.
*
* @param opts.limit Max messages to return (default 20).
* @param opts.limit Max messages to return (default 20; pass 0 for the full history).
* @param opts.types Chat type codes to include (default player chat: 1/2/3/6/7). Pass e.g. `[0]` for system messages.
* @param opts.includeSelf Include your own messages (default false).
*/
getChat(opts: { limit?: number; types?: readonly number[]; includeSelf?: boolean } = {}): GameMessage[] {
const { limit = 20, types = PLAYER_CHAT_TYPES, includeSelf = false } = opts;
const all = this.state?.gameMessages ?? [];
const typeSet = new Set(types);
const filtered = all.filter(m =>
const filtered = this.chatHistory.all().filter(m =>
typeSet.has(m.type) && (includeSelf || !m.fromSelf)
);
return limit > 0 ? filtered.slice(-limit) : filtered;
@@ -582,46 +588,96 @@ export class BotSDK {
/**
* Read only chat messages that have arrived since the last call (cursor-based,
* newest last). Advances an internal high-water tick so repeat polls never
* re-show the same message — no need to hand-roll a baseline tick. Excludes
* your own messages by default.
* newest last). Repeat polls never re-show the same message — no need to
* hand-roll a baseline. The first call returns everything seen since
* connect. Excludes your own messages by default.
*
* @param opts.types Chat type codes to include (default player chat: 1/2/3/6/7).
* @param opts.includeSelf Include your own messages (default false).
*/
getNewChat(opts: { types?: readonly number[]; includeSelf?: boolean } = {}): GameMessage[] {
const { types = PLAYER_CHAT_TYPES, includeSelf = false } = opts;
const all = this.state?.gameMessages ?? [];
const prev = this.chatCursor;
const typeSet = new Set(types);
// Advance the cursor past every message currently in the buffer (including
// system lines) so the next call only sees genuinely newer messages.
let maxTick = prev;
for (const m of all) if (m.tick > maxTick) maxTick = m.tick;
this.chatCursor = maxTick;
// Advance the cursor past everything recorded so far (including system
// lines) so the next call only sees genuinely newer messages.
const prev = this.chatReadCursor;
this.chatReadCursor = this.chatHistory.seen;
return all.filter(m =>
m.tick > prev && typeSet.has(m.type) && (includeSelf || !m.fromSelf)
return this.chatHistory.since(prev).filter(m =>
typeSet.has(m.type) && (includeSelf || !m.fromSelf)
);
}
/**
* Read recent chat from a specific sender (case-insensitive, substring match
* on name), newest last. Handy for "what did my partner say?" without
* regex-matching the sender field yourself.
* on name), newest last, from the accumulated history. Handy for "what did
* my partner say?" without regex-matching the sender field yourself.
*
* @param name Sender name (or substring) to match.
* @param opts.limit Max messages to return (default 20).
* @param opts.limit Max messages to return (default 20; pass 0 for all).
*/
getChatFrom(name: string, opts: { limit?: number } = {}): GameMessage[] {
const { limit = 20 } = opts;
const needle = name.toLowerCase();
const all = this.state?.gameMessages ?? [];
const filtered = all.filter(m => m.sender.toLowerCase().includes(needle));
const filtered = this.chatHistory.all().filter(m =>
m.sender !== '' && m.sender.toLowerCase().includes(needle)
);
return limit > 0 ? filtered.slice(-limit) : filtered;
}
/**
* Wait for the next chat message matching the given filters (messages
* arriving after this call; your own messages are excluded by default).
* The easy way to coordinate two bots: `sdk.say('ready'); const reply =
* await sdk.waitForChat({ from: 'partner', timeout: 60000 });`
*
* @param opts.from Only accept this sender (case-insensitive substring).
* @param opts.matching Only accept messages whose text matches this pattern.
* @param opts.types Chat type codes to accept (default player chat: 1/2/3/6/7).
* @param opts.includeSelf Accept your own messages (default false).
* @param opts.timeout Ms to wait before returning null (default 30000).
* @returns The first matching message, or null on timeout.
*/
async waitForChat(opts: {
from?: string;
matching?: RegExp | string;
types?: readonly number[];
includeSelf?: boolean;
timeout?: number;
} = {}): Promise<GameMessage | null> {
const { from, matching, types = PLAYER_CHAT_TYPES, includeSelf = false, timeout = 30000 } = opts;
const typeSet = new Set(types);
const needle = from?.toLowerCase();
const pattern = typeof matching === 'string' ? new RegExp(matching, 'i') : matching;
const accepts = (m: GameMessage) =>
typeSet.has(m.type) &&
(includeSelf || !m.fromSelf) &&
(!needle || m.sender.toLowerCase().includes(needle)) &&
(!pattern || pattern.test(m.text));
// Private cursor starting "now" — does not consume getNewChat's cursor.
let cursor = this.chatHistory.seen;
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
unsubscribe();
resolve(null);
}, timeout);
const unsubscribe = this.onStateUpdate(() => {
const fresh = this.chatHistory.since(cursor);
cursor = this.chatHistory.seen;
const match = fresh.find(accepts);
if (match) {
clearTimeout(timeoutId);
unsubscribe();
resolve(match);
}
});
});
}
/** Get a skill by name (case-insensitive). */
getSkill(name: string): SkillState | null {
if (!this.state) return null;
@@ -1434,6 +1490,13 @@ export class BotSDK {
);
}
// Accumulate chat into the deep history buffer (post-showChat
// filter, so hidden player chat stays hidden). Must happen before
// state listeners fire so waitForChat sees the new messages.
if (message.state.gameMessages) {
this.chatHistory.record(message.state.gameMessages);
}
// Wrap skills array with a Proxy so both state.skills[0] and state.skills.Woodcutting work
if (message.state.skills) {
const nameMap: Record<string, SkillState> = {};
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bun
/**
* 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
* the SDK surface built on it (getChat, getNewChat cursor, getChatFrom,
* waitForChat), driving the SDK through its real handleMessage path.
*/
import { BotSDK } from '../index';
import { ChatHistory } from '../chat-history';
import type { GameMessage } from '../types';
let failures = 0;
function check(label: string, ok: boolean, detail?: string): void {
if (ok) {
console.log(` ok ${label}`);
} else {
failures++;
console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`);
}
}
let nextId = 0;
function msg(tick: number, text: string, opts: Partial<GameMessage> = {}): GameMessage {
nextId++;
return {
type: opts.type ?? 2,
text,
sender: opts.sender ?? 'partner',
tick,
fromSelf: opts.fromSelf ?? false,
};
}
function texts(messages: GameMessage[]): string {
return messages.map(m => m.text).join(',');
}
function pushState(sdk: BotSDK, gameMessages: GameMessage[]): void {
(sdk as any).handleMessage(JSON.stringify({
type: 'sdk_state',
state: { gameMessages },
}));
}
async function run(): Promise<void> {
console.log('ChatHistory.record():');
// 1. First snapshot is recorded verbatim
{
const h = new ChatHistory();
const fresh = h.record([msg(10, 'a'), msg(11, 'b')]);
check('first snapshot recorded verbatim',
fresh.length === 2 && texts(h.all()) === 'a,b');
}
// 2. Overlapping snapshots are deduplicated
{
const h = new ChatHistory();
h.record([msg(10, 'a'), msg(11, 'b')]);
const fresh = h.record([msg(10, 'a'), msg(11, 'b'), msg(12, 'c')]);
check('overlapping snapshot only appends the new tail',
texts(fresh) === 'c' && texts(h.all()) === 'a,b,c',
`fresh=${texts(fresh)} all=${texts(h.all())}`);
}
// 3. Identical snapshot (idle tick resync) appends nothing
{
const h = new ChatHistory();
h.record([msg(10, 'a'), msg(11, 'b')]);
const fresh = h.record([msg(10, 'a'), msg(11, 'b')]);
check('identical snapshot appends nothing',
fresh.length === 0 && h.all().length === 2);
}
// 4. Same-tick split across snapshots: a chat packet processed after the
// PLAYER_INFO that triggered the previous sync shares its loopCycle.
// The surplus same-tick message must still be picked up.
{
const h = new ChatHistory();
h.record([msg(10, 'a'), msg(11, 'b1')]);
const fresh = h.record([msg(10, 'a'), msg(11, 'b1'), msg(11, 'b2'), msg(12, 'c')]);
check('picks up surplus messages sharing the last-held tick',
texts(fresh) === 'b2,c' && texts(h.all()) === 'a,b1,b2,c',
`fresh=${texts(fresh)}`);
}
// 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.
{
const h = new ChatHistory();
h.record([msg(10, 'a'), msg(11, 'b')]);
const fresh = h.record([msg(20, 'x'), msg(21, 'y')]);
check('disjoint newer snapshot appends everything',
texts(fresh) === 'x,y' && texts(h.all()) === 'a,b,x,y');
}
// 6. Tick reset (page reload → loopCycle restarts, fresh ring): everything
// in the snapshot is treated as new instead of being dropped forever.
{
const h = new ChatHistory();
h.record([msg(30000, 'old1'), msg(30001, 'old2')]);
const fresh = h.record([msg(5, 'new1'), msg(6, 'new2')]);
check('tick reset re-baselines instead of dropping messages',
texts(fresh) === 'new1,new2' && texts(h.all()) === 'old1,old2,new1,new2',
`fresh=${texts(fresh)}`);
}
// 7. Retention cap trims oldest, and since() cursors survive trimming
{
const h = new ChatHistory(3);
h.record([msg(1, 'a'), msg(2, 'b')]);
const cursor = h.seen; // 2
h.record([msg(3, 'c'), msg(4, 'd'), msg(5, 'e')]);
check('cap trims oldest messages',
h.all().length === 3 && texts(h.all()) === 'c,d,e');
check('since() returns post-cursor messages after trimming',
texts(h.since(cursor)) === 'c,d,e');
check('since() clamps when cursor predates retained buffer',
h.since(0).length === 3);
}
// 8. Empty snapshot is a no-op
{
const h = new ChatHistory();
h.record([msg(1, 'a')]);
const fresh = h.record([]);
check('empty snapshot is a no-op', fresh.length === 0 && h.all().length === 1);
}
console.log('');
console.log('BotSDK chat surface (via handleMessage):');
// 9. getChat accumulates beyond a single snapshot window
{
const sdk = new BotSDK({ botUsername: 'test' });
pushState(sdk, [msg(10, 'first')]);
pushState(sdk, [msg(10, 'first'), msg(11, 'second')]);
pushState(sdk, [msg(20, 'third')]); // window slid past 'first'/'second'
const chat = sdk.getChat({ limit: 0 });
check('getChat retains messages evicted from the snapshot window',
texts(chat) === 'first,second,third',
texts(chat));
}
// 10. getChat default filters: player chat only, no self
{
const sdk = new BotSDK({ botUsername: 'test' });
pushState(sdk, [
msg(10, 'sys', { type: 0, sender: '' }),
msg(11, 'hello'),
msg(12, 'me', { fromSelf: true, sender: 'test' }),
]);
check('getChat defaults exclude system and self',
texts(sdk.getChat()) === 'hello');
check('getChat types:[0] returns system messages',
texts(sdk.getChat({ types: [0] })) === 'sys');
check('getChat includeSelf returns own messages',
texts(sdk.getChat({ includeSelf: true })) === 'hello,me');
}
// 11. getNewChat cursor: no re-shows, catches messages evicted between polls
{
const sdk = new BotSDK({ botUsername: 'test' });
pushState(sdk, [msg(10, 'a')]);
check('getNewChat first call returns backlog', texts(sdk.getNewChat()) === 'a');
check('getNewChat repeat call returns nothing', sdk.getNewChat().length === 0);
pushState(sdk, [msg(10, 'a'), msg(11, 'b')]);
pushState(sdk, [msg(20, 'c'), msg(21, 'd')]); // 'b' already out of window
check('getNewChat returns everything since last call, even if evicted from window',
texts(sdk.getNewChat()) === 'b,c,d');
}
// 12. getNewChat with same-tick arrival after cursor read (old tick-cursor bug)
{
const sdk = new BotSDK({ botUsername: 'test' });
pushState(sdk, [msg(10, 'a')]);
sdk.getNewChat();
pushState(sdk, [msg(10, 'a'), msg(10, 'a2')]); // same tick, arrived later
check('getNewChat does not drop later same-tick messages',
texts(sdk.getNewChat()) === 'a2');
}
// 13. getChatFrom matches sender substring, ignores system messages
{
const sdk = new BotSDK({ botUsername: 'test' });
pushState(sdk, [
msg(10, 'sys', { type: 0, sender: '' }),
msg(11, 'hi', { sender: 'HelperBot' }),
msg(12, 'yo', { sender: 'stranger' }),
]);
check('getChatFrom matches case-insensitive substring',
texts(sdk.getChatFrom('helper')) === 'hi');
check('getChatFrom("") never matches system messages',
sdk.getChatFrom('').every(m => m.sender !== ''));
}
// 14. showChat:false keeps player chat out of the history
{
const sdk = new BotSDK({ botUsername: 'test', showChat: false });
pushState(sdk, [msg(10, 'hello'), msg(11, 'sys', { type: 0, sender: '' })]);
check('showChat:false excludes player chat from history',
sdk.getChat({ limit: 0, types: [0, 1, 2, 3, 6, 7] }).every(m => m.type === 0));
}
// 15. waitForChat resolves on a matching message arriving later
{
const sdk = new BotSDK({ botUsername: 'test' });
pushState(sdk, [msg(10, 'before')]);
const promise = sdk.waitForChat({ from: 'partner', matching: /ready/i, timeout: 2000 });
pushState(sdk, [msg(10, 'before'), msg(11, 'noise', { sender: 'stranger' })]);
pushState(sdk, [msg(11, 'noise', { sender: 'stranger' }), msg(12, 'READY to go', { sender: 'Partner' })]);
const got = await promise;
check('waitForChat resolves with the first matching message',
got !== null && got.text === 'READY to go',
JSON.stringify(got));
}
// 16. waitForChat ignores pre-existing messages and returns null on timeout
{
const sdk = new BotSDK({ botUsername: 'test' });
pushState(sdk, [msg(10, 'ready')]); // already there before the wait
const got = await sdk.waitForChat({ matching: /ready/, timeout: 100 });
check('waitForChat ignores messages that arrived before the call (null on timeout)',
got === null, JSON.stringify(got));
}
// 17. waitForChat excludes own messages by default
{
const sdk = new BotSDK({ botUsername: 'test' });
pushState(sdk, []);
const promise = sdk.waitForChat({ matching: /ping/, timeout: 100 });
pushState(sdk, [msg(11, 'ping', { fromSelf: true, sender: 'test' })]);
const got = await promise;
check('waitForChat excludes own messages by default', got === null);
}
console.log('');
if (failures === 0) {
console.log('PASSED — all cases');
process.exit(0);
} else {
console.log(`FAILED — ${failures} case(s) failed`);
process.exit(1);
}
}
run().catch(e => {
console.error('Fatal:', e);
process.exit(1);
});