mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
1 parent
0e4aeb82a2
commit
0f4e5fae85
7 files changed
+525
-27
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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). |
|
||||
|
||||
@@ -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
@@ -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> = {};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user