feat(sdk,webclient,gateway): private message (DM) chat between players

The engine already supported MESSAGE_PRIVATE end-to-end; this adds the
send path and tooling:

- sendPrivateMessage on browser Client + LiteClient (userhash + WordPack
  payload, local type-6 echo), new 'privateMessage' bot action
- sdk.sendPrivateMessage(name, msg) + sdk.dm(name, text) (chunked like say)
- gateway: observe mode whitelists privateMessage alongside say; new
  POST /dm/:username endpoint - a separate path from /say on purpose, so
  old gateways' non-JSON banner makes the CLI fall back to the WS path
  (cleanly rejected) instead of leaking a DM as public chat
- chat CLI: bun sdk/chat.ts <bot> --to <player> "msg"
- engine: FriendServer PM handler logs private_chat best-effort instead of
  letting a missing account row (local easy-startup) kill the relay

PMs deliver to any online player - no friends list needed. One social
packet (say or PM) per tick; dm() paces its chunks. Delivery requires
FRIEND_SERVER=true (prod fly.toml already sets it; local quickstart
defaults off and drops PMs silently).

Verified E2E on the local stack: both directions, 3-chunk long message,
observe-mode WS path, non-chat actions still rejected in observe mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-18 16:37:12 -04:00
1 parent e960b3a8d2
commit 042ad988c0
13 files changed
+321 -30

No files matched your search

+8 -1
View File
@@ -128,7 +128,8 @@ chat (and nothing else) and never pre-empts (or gets pre-empted by) a
controller:
```bash
bun sdk/chat.ts {username} "meet me at the bank" # send
bun sdk/chat.ts {username} "meet me at the bank" # send public chat
bun sdk/chat.ts {username} --to {player} "on my way" # send a private message
bun sdk/chat.ts {username} # recent chat
bun sdk/chat.ts {username} --watch # tail live
```
@@ -136,6 +137,11 @@ bun sdk/chat.ts {username} --watch # tail live
Public chat is capped at 400 chars per message; `sdk.say()` auto-splits longer
text into wire-safe chunks.
Private messages (`sdk.dm(name, text)` / `sdk.sendPrivateMessage(name, msg)`)
reach the target anywhere in the world - no friends-list setup needed - and
show up in `getChat()` as type 3 (received) / 6 (sent). The server accepts one
social packet (say or PM) per tick; `dm()` paces its chunks automatically.
## Script Duration Guidelines
**Start short, extend as you gain confidence:**
@@ -215,6 +221,7 @@ For the complete method reference, see **[sdk/API.md](sdk/API.md)** (auto-genera
| `sendUseItem(slot)` | Use inventory item (bury, etc.) |
| `sendUseItemOnItem(src, dst)` | Combine two items |
| `say(text)` | Send chat, auto-chunked past the length cap |
| `dm(name, text)` | Send a private message, auto-chunked |
| `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 |
+2
View File
@@ -201,6 +201,7 @@
| `async launchBrowser(): Promise<void>` | Launch native browser to client URL. Uses the `open` package for cross-platform support (macOS, Windows, Linux, WSL). Falls back to printing the URL if no browser can be opened. |
| `countInventoryItems(pattern: string \| RegExp): number` | Count total item quantity matching a name pattern. This sums stack sizes across every matching slot. Use `getInventory().filter(...)` when the number of occupied slots is needed. |
| `async clickInterfaceOption(selector: InterfaceOptionSelector): Promise<ActionResult>` | Click exactly one interface option, selected by its state object or by visible text (substring for strings, match for regexes). This dispatches the option's `componentId` and never interprets `InterfaceOption.index` as an array position. |
| `async dm(targetName: string, text: string, opts: { maxLen?: number; delayTicks?: number } = {}): Promise<ActionResult[]>` | Send a private message of any length, auto-split into chunks like {@link say}. Returns one ActionResult per chunk. |
| `blockDoorTemporarily(level: number, x: number, z: number, ttlMs: number = 30_000): boolean` | Temporarily exclude a known door from this SDK instance's path queries. The shared collision map is never mutated beyond the synchronous query. |
### Condition Waiting
@@ -269,6 +270,7 @@
| `async sendSetTab(tabIndex: number): Promise<ActionResult>` | Switch to a UI tab by index. |
| `async sendSay(message: string): Promise<ActionResult>` | Send a single chat message. The server caps public chat at {@link maxMessageLength} chars (400 on rs-sdk servers) and runs a word filter; `result.data` reports `{ sent, truncated, filtered, finalText }` so you know if your message was clipped or censored. For longer text that shouldn't be silently truncated, use {@link say}. |
| `async say(text: string, opts: { maxLen?: number; delayTicks?: number } = {}): Promise<ActionResult[]>` | Send a message of any length, auto-split into chunks on word boundaries and sent in order (so a multi-sentence plan isn't lost to the chat-length cap). Waits a tick between chunks so they don't collide. Returns one ActionResult per chunk. |
| `async sendPrivateMessage(targetName: string, message: string): Promise<ActionResult>` | Send a single private message to another player by name. Delivered if the target is online anywhere in the world - no friends-list setup needed. It arrives in their chat as type 3 ("From <you>"), and echoes locally as type 6 ("To <name>"). Same length cap and word filter as {@link sendSay}; the server accepts at most one social packet (say or PM) per tick, so don't fire this back-to-back with public chat in the same tick. For longer text, use {@link dm}. |
| `async sendWait(ticks: number = 1): Promise<ActionResult>` | Wait for specified number of game ticks. |
| `async sendBankDeposit(slot: number, amount: number = 1): Promise<ActionResult>` | Deposit item to bank by slot. |
| `async sendBankWithdraw(slot: number, amount: number = 1): Promise<ActionResult>` | Withdraw item from bank by slot. |
+38 -10
View File
@@ -30,9 +30,11 @@ SDK Chat CLI - send and read chat without stealing bot control
Usage:
bun sdk/chat.ts <botname> # Show recent chat
bun sdk/chat.ts <botname> <message...> # Send a message
bun sdk/chat.ts <botname> --to <player> <message...> # Send a private message
bun sdk/chat.ts <botname> --watch # Tail chat live (Ctrl+C to stop)
Options:
--to <player> Send as a private message to this player instead of public chat
--limit <n> Messages of history to show (default: 20)
--system Include system/game messages, not just player chat
--server <host> Server hostname (default: from bot.env or rs-sdk-demo.fly.dev)
@@ -41,9 +43,13 @@ Options:
Examples:
bun sdk/chat.ts mybot "meet me at the bank"
bun sdk/chat.ts mybot --to otherbot "got the ruby, banking now"
bun sdk/chat.ts mybot --watch
bun sdk/chat.ts mybot --limit 50 --system
Private messages reach the target anywhere in the world (no friends-list setup
needed) and show up in chat reads as "[PM from ...]" / "[PM to ...]".
Sending requires a game client (browser or lite) to be logged in as the bot;
this tool only relays chat through it. Reading works off the same state feed
observers get.
@@ -126,9 +132,17 @@ interface CliContext {
// ============ HTTP paths (primary) ============
/** Returns exit code, or null if the gateway lacks the HTTP endpoints. */
async function sendViaHttp(ctx: CliContext, text: string): Promise<number | null> {
const url = `${gatewayHttpBase(ctx.gatewayUrl)}/say/${encodeURIComponent(ctx.username)}`;
/**
* Returns exit code, or null if the gateway lacks the HTTP endpoints.
* With `to` set, posts to /dm (a distinct path, so a pre-DM gateway answers
* with its non-JSON banner and we fall back to WebSocket - a DM must never
* silently degrade into public chat).
*/
async function sendViaHttp(ctx: CliContext, text: string, to?: string): Promise<number | null> {
const base = gatewayHttpBase(ctx.gatewayUrl);
const url = to
? `${base}/dm/${encodeURIComponent(ctx.username)}`
: `${base}/say/${encodeURIComponent(ctx.username)}`;
let res: Response;
try {
// The gateway bounds each chunk at 10s and aborts on first failure;
@@ -136,7 +150,7 @@ async function sendViaHttp(ctx: CliContext, text: string): Promise<number | null
res = await boundedFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, password: ctx.password, timeoutMs: 10_000 })
body: JSON.stringify({ text, to, password: ctx.password, timeoutMs: 10_000 })
}, 60_000);
} catch (err: any) {
console.error(`Error: Failed to reach gateway at ${url}`);
@@ -160,7 +174,7 @@ async function sendViaHttp(ctx: CliContext, text: string): Promise<number | null
let ok = true;
for (const result of body.results ?? []) {
if (result.success) {
console.log(`Sent: ${result.data?.finalText ?? result.chunk}`);
console.log(`Sent${to ? ` [PM to ${to}]` : ''}: ${result.data?.finalText ?? result.chunk}`);
if (result.data?.truncated) console.log(' (truncated by server cap)');
if (result.data?.filtered) console.log(' (altered by word filter)');
} else {
@@ -208,7 +222,7 @@ async function readViaHttp(ctx: CliContext): Promise<number | null> {
// ============ Observer-WebSocket path (fallback + --watch) ============
async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: boolean): Promise<number> {
async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: boolean, dmTarget?: string): Promise<number> {
const sdk = new BotSDK({
botUsername: ctx.username,
password: ctx.password,
@@ -231,12 +245,12 @@ async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: b
}
if (messageToSend) {
const results = await sdk.say(messageToSend);
const results = dmTarget ? await sdk.dm(dmTarget, messageToSend) : await sdk.say(messageToSend);
let ok = true;
for (const result of results) {
if (result.success) {
const data = result.data as { finalText?: string; truncated?: boolean; filtered?: boolean } | undefined;
console.log(`Sent: ${data?.finalText ?? messageToSend}`);
console.log(`Sent${dmTarget ? ` [PM to ${dmTarget}]` : ''}: ${data?.finalText ?? messageToSend}`);
if (data?.truncated) console.log(' (truncated by server cap)');
if (data?.filtered) console.log(' (altered by word filter)');
} else {
@@ -246,6 +260,9 @@ async function runOverWebSocket(ctx: CliContext, messageToSend: string, watch: b
console.error(` No game client is logged in as '${ctx.username}' - chat is relayed through it.`);
console.error(` Log the bot in (browser or lite client), then retry.`);
}
if (dmTarget && /observe mode can only send 'say'/i.test(result.message)) {
console.error(` This gateway predates private-message support - redeploy the gateway to enable DMs.`);
}
}
}
sdk.disconnect();
@@ -304,6 +321,7 @@ async function main() {
let limit = 20;
let watch = false;
let system = false;
let dmTarget = '';
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
@@ -315,6 +333,8 @@ async function main() {
watch = true;
} else if (arg === '--system') {
system = true;
} else if (arg === '--to') {
dmTarget = args[++i] || '';
} else if (arg === '--server') {
server = args[++i] || '';
} else if (arg === '--timeout') {
@@ -332,6 +352,14 @@ async function main() {
process.exit(1);
}
const messageToSend = positional.slice(1).join(' ');
if (dmTarget && !messageToSend) {
console.error('Error: --to requires a message to send');
process.exit(1);
}
if (dmTarget.length > 12) {
console.error(`Error: '--to ${dmTarget}' - player names are at most 12 characters`);
process.exit(1);
}
let username = botName;
let password = process.env.PASSWORD || '';
@@ -367,10 +395,10 @@ async function main() {
}, HARD_DEADLINE_MS);
if (typeof (deadline as any).unref === 'function') (deadline as any).unref();
const httpExit = messageToSend ? await sendViaHttp(ctx, messageToSend) : await readViaHttp(ctx);
const httpExit = messageToSend ? await sendViaHttp(ctx, messageToSend, dmTarget || undefined) : await readViaHttp(ctx);
if (httpExit !== null) process.exit(httpExit);
// Gateway predates the HTTP chat endpoints - use the observer socket.
process.exit(await runOverWebSocket(ctx, messageToSend, false));
process.exit(await runOverWebSocket(ctx, messageToSend, false, dmTarget || undefined));
}
await runOverWebSocket(ctx, '', true);
+42
View File
@@ -1511,6 +1511,48 @@ export class BotSDK {
return results;
}
/**
* Send a single private message to another player by name. Delivered if the
* target is online anywhere in the world - no friends-list setup needed. It
* arrives in their chat as type 3 ("From <you>"), and echoes locally as
* type 6 ("To <name>"). Same length cap and word filter as {@link sendSay};
* the server accepts at most one social packet (say or PM) per tick, so
* don't fire this back-to-back with public chat in the same tick.
* For longer text, use {@link dm}.
*/
async sendPrivateMessage(targetName: string, message: string): Promise<ActionResult> {
return this.sendAction({ type: 'privateMessage', targetName, message, reason: 'SDK' });
}
/**
* Send a private message of any length, auto-split into chunks like
* {@link say}. Returns one ActionResult per chunk.
*
* @param targetName The recipient's username (max 12 chars).
* @param text The full message to send.
* @param opts.maxLen Max chars per chunk, capped at {@link maxMessageLength}.
* @param opts.delayTicks Ticks to wait between chunks (default 1).
*/
async dm(targetName: string, text: string, opts: { maxLen?: number; delayTicks?: number } = {}): Promise<ActionResult[]> {
const maxLen = Math.min(opts.maxLen ?? this.serverMaxMessageLength, this.serverMaxMessageLength);
const delayTicks = opts.delayTicks ?? 1;
const chunks = chunkMessage(text, maxLen);
const results: ActionResult[] = [];
for (let i = 0; i < chunks.length; i++) {
results.push(await this.sendPrivateMessage(targetName, chunks[i]!));
if (i < chunks.length - 1 && delayTicks > 0) {
if (this.config.connectionMode === 'observe') {
// Observe mode may only dispatch chat actions - a 'wait' would
// occupy the executor of whatever controller owns the bot.
await new Promise(resolve => setTimeout(resolve, delayTicks * 600));
} else {
await this.sendWait(delayTicks);
}
}
}
return results;
}
/** Wait for specified number of game ticks. */
async sendWait(ticks: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'wait', ticks, reason: 'SDK' });
+1
View File
@@ -600,6 +600,7 @@ export type BotAction =
| { type: 'useItemOnLoc'; itemSlot: number; x: number; z: number; locId: number; reason: string }
| { type: 'useItemOnNpc'; itemSlot: number; npcIndex: number; reason: string }
| { type: 'say'; message: string; reason: string }
| { type: 'privateMessage'; targetName: string; message: string; reason: string }
| { type: 'spellOnNpc'; npcIndex: number; spellComponent: number; reason: string }
| { type: 'spellOnPlayer'; playerIndex: number; spellComponent: number; reason: string }
| { type: 'spellOnItem'; slot: number; spellComponent: number; reason: string }
+20 -13
View File
@@ -267,20 +267,27 @@ export class FriendServer {
const targetUsername37 = BigInt(message.targetUsername37);
const { staffLvl, pmId, chat } = message;
const from = await db.selectFrom('account').selectAll().where('username', '=', fromBase37(username37)).executeTakeFirstOrThrow();
const to = await db.selectFrom('account').selectAll().where('username', '=', fromBase37(targetUsername37)).executeTakeFirstOrThrow();
// Chat logging is best-effort: without account rows (local
// easy-startup logins never create them) the lookup throws,
// and that must not eat the message before the relay below.
try {
const from = await db.selectFrom('account').selectAll().where('username', '=', fromBase37(username37)).executeTakeFirstOrThrow();
const to = await db.selectFrom('account').selectAll().where('username', '=', fromBase37(targetUsername37)).executeTakeFirstOrThrow();
await db
.insertInto('private_chat')
.values({
account_id: from.id,
profile: message.profile,
to_account_id: to.id,
timestamp: toDbDate(Date.now()),
coord: message.coord,
message: chat
})
.execute();
await db
.insertInto('private_chat')
.values({
account_id: from.id,
profile: message.profile,
to_account_id: to.id,
timestamp: toDbDate(Date.now()),
coord: message.coord,
message: chat
})
.execute();
} catch (err) {
console.error(`[Friends]: failed to log private chat: ${err instanceof Error ? err.message : err}`);
}
await this.sendPrivateMessage(username37, staffLvl, pmId, targetUsername37, chat);
} else if (type === FriendsClientOpcodes.PUBLIC_CHAT_LOG) {
+67 -6
View File
@@ -515,13 +515,13 @@ const SyncModule = {
const sdkSession = this.getSessionForSocket(ws);
if (!sdkSession) return;
// Gate actions based on mode: observers may only send 'say', so chat
// tools can talk without being able to drive the bot.
if (sdkSession.mode === 'observe' && message.action?.type !== 'say') {
// Gate actions based on mode: observers may only send chat ('say' /
// 'privateMessage'), so chat tools can talk without driving the bot.
if (sdkSession.mode === 'observe' && message.action?.type !== 'say' && message.action?.type !== 'privateMessage') {
this.sendToSDK(sdkSession, {
type: 'sdk_error',
actionId: message.actionId,
error: `Observe mode can only send 'say' actions (got '${message.action?.type}')`
error: `Observe mode can only send 'say' or 'privateMessage' actions (got '${message.action?.type}')`
});
console.log(`[Gateway] [${sdkSession.targetUsername}] Rejected action from observe-mode SDK: ${message.action?.type}`);
return;
@@ -664,7 +664,7 @@ async function authenticateHttpRequest(req: Request, url: URL, username: string,
* actionResult. Never rejects: a client that doesn't answer inside the budget
* yields a failed result, so the HTTP handler always responds.
*/
function dispatchHttpSay(botSession: BotSession, text: string, timeoutMs: number): Promise<{ success: boolean; message: string; data?: any; reason?: string }> {
function dispatchHttpSay(botSession: BotSession, text: string, timeoutMs: number, to?: string): Promise<{ success: boolean; message: string; data?: any; reason?: string }> {
const actionId = `http-${++httpActionSeq}-${Math.random().toString(36).slice(2, 8)}`;
return new Promise(resolve => {
const timeout = setTimeout(() => {
@@ -676,7 +676,9 @@ function dispatchHttpSay(botSession: BotSession, text: string, timeoutMs: number
botSession.currentActionId = actionId;
SyncModule.sendToBot(botSession, {
type: 'action',
action: { type: 'say', message: text, reason: 'HTTP chat' },
action: to
? { type: 'privateMessage', targetName: to, message: text, reason: 'HTTP chat' }
: { type: 'say', message: text, reason: 'HTTP chat' },
actionId,
actionTimeoutMs: timeoutMs
});
@@ -883,6 +885,64 @@ const server = Bun.serve({
// Relays through the bot's game client like observer 'say' does, but as
// a single request/response with a hard per-chunk deadline - a caller
// can trust that this either answers or fails within its budget.
// Send a private message: POST /dm/:username body: {"to": "...", "text": "...", "password"?: "...", "timeoutMs"?: n}
//
// A distinct path (not a `to` field on /say) on purpose: gateways that
// predate DMs answer unknown paths with the text/plain banner, which
// callers detect and handle - a DM must never silently degrade into
// public chat.
const dmMatch = url.pathname.match(/^\/dm\/(.+)$/);
if (dmMatch && dmMatch[1] && req.method === 'POST') {
const username = decodeURIComponent(dmMatch[1]);
let body: any;
try {
body = await req.json();
} catch {
return jsonResponse({ error: 'Body must be JSON: {"to": "...", "text": "..."}' }, 400);
}
const text = typeof body?.text === 'string' ? body.text.trim() : '';
const to = typeof body?.to === 'string' ? body.to.trim() : '';
if (!text) {
return jsonResponse({ error: 'Missing "text"' }, 400);
}
if (!to || to.length > 12) {
return jsonResponse({ error: 'Missing or invalid "to" (target player name, max 12 chars)' }, 400);
}
const auth = await authenticateHttpRequest(req, url, username, body?.password);
if (!auth.success) {
return jsonResponse({ error: `Authentication failed: ${auth.error}` }, 401);
}
const botSession = botSessions.get(username);
if (!botSession || !botSession.ws) {
return jsonResponse({
error: `Bot not connected - no game client is logged in as '${username}', so there is nothing to relay chat through`
}, 409);
}
const timeoutRaw = Number(body?.timeoutMs);
const perChunkTimeout = Math.min(Math.max(Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 10000, 2000), 30000);
const chunks = chunkMessage(text, botSession.maxMessageLength ?? 80);
const results: any[] = [];
let ok = true;
for (let i = 0; i < chunks.length; i++) {
const result = await dispatchHttpSay(botSession, chunks[i]!, perChunkTimeout, to);
results.push({ chunk: chunks[i], ...result });
if (!result.success) {
ok = false;
for (let j = i + 1; j < chunks.length; j++) {
results.push({ chunk: chunks[j], success: false, message: 'Skipped: previous chunk failed' });
}
break;
}
if (i < chunks.length - 1) {
await new Promise(resolve => setTimeout(resolve, 600)); // ~1 tick so chunks don't collide
}
}
return jsonResponse({ ok, to, results }, ok ? 200 : 502);
}
const sayMatch = url.pathname.match(/^\/say\/(.+)$/);
if (sayMatch && sayMatch[1] && req.method === 'POST') {
const username = decodeURIComponent(sayMatch[1]);
@@ -972,6 +1032,7 @@ Endpoints:
- GET /status/:username Per-bot status (controllers, observers)
- GET /chat/:username Recent chat (?limit=20&system=1)
- POST /say/:username Send chat ({"text": "..."}); relayed through the bot's game client
- POST /dm/:username Send a private message ({"to": "...", "text": "..."})
WebSocket:
- ws://localhost:${GATEWAY_PORT} Bot/SDK connections
@@ -335,6 +335,26 @@ export class ActionExecutor {
};
}
case 'privateMessage': {
const pm = this.client.sendPrivateMessage(action.targetName, action.message);
if (!pm.ok) {
return { success: false, message: `Failed to send PM to ${action.targetName}` };
}
let message = `PM to ${action.targetName}: ${pm.finalText}`;
if (pm.truncated) message += ` (truncated to ${this.client.getMaxMessageLength()} chars)`;
if (pm.filtered) message += ' (word-filtered)';
return {
success: true,
message,
data: {
sent: true,
truncated: pm.truncated,
filtered: pm.filtered,
finalText: pm.finalText
}
};
}
case 'scanNearbyLocs':
if (!this.scanProvider) {
return { success: false, message: 'No scan provider available' };
@@ -499,6 +519,7 @@ export function formatAction(action: BotAction): string {
case 'acceptCharacterDesign': return 'Accept character design';
case 'randomizeCharacterDesign': return 'Randomize character design';
case 'say': return `Say: ${action.message}`;
case 'privateMessage': return `PM ${action.targetName}: ${action.message}`;
case 'togglePrayer': return `Toggle prayer ${action.prayerIndex}`;
default: return action.type;
}
+1
View File
@@ -506,6 +506,7 @@ export type BotAction =
| { type: 'spellOnGroundItem'; x: number; z: number; itemId: number; spellComponent: number; reason: string }
| { type: 'setTab'; tabIndex: number; reason: string }
| { type: 'say'; message: string; reason: string }
| { type: 'privateMessage'; targetName: string; message: string; reason: string }
| { type: 'bankDeposit'; slot: number; amount: number; reason: string }
| { type: 'bankWithdraw'; slot: number; amount: number; reason: string }
| { type: 'submitCountDialog'; value: number; reason: string }
+36
View File
@@ -2015,6 +2015,42 @@ export class Client extends GameShell {
return { ok: true, truncated, filtered, finalText: text };
}
/**
* Send a private message to another player (MESSAGE_PRIVATE). Mirrors the
* socialInput path the friends-tab UI uses, without needing the target on
* the friends list - the server relays PMs to any online player.
*/
sendPrivateMessage(targetName: string, message: string): SayOutcome {
if (!this.ingame || !this.out) {
return { ok: false, truncated: false, filtered: false, finalText: '' };
}
const userhash: bigint = JString.toUserhash(targetName);
if (userhash === 0n) {
return { ok: false, truncated: false, filtered: false, finalText: '' };
}
const truncated: boolean = message.length > Client.maxMessageLength;
let text = message.substring(0, Client.maxMessageLength);
this.out.p1Enc(ClientProt.MESSAGE_PRIVATE);
this.out.p1(0);
const start: number = this.out.pos;
this.out.p8(userhash);
WordPack.pack(this.out, text);
this.out.psize1(this.out.pos - start);
const cased = JString.toSentenceCase(text);
text = WordFilter.filter(cased);
const filtered: boolean = text !== cased;
// Local echo as a sent-PM (type 6) - the server never echoes your own PM back.
this.addChat(6, text, JString.toScreenName(JString.toRawUsername(userhash)));
return { ok: true, truncated, filtered, finalText: text };
}
/**
* Use one inventory item on another inventory item (OPHELDU)
*/
+3
View File
@@ -860,6 +860,9 @@ export class LiteClient {
say(message: string): A.SayOutcome {
return A.say(this, message);
}
sendPrivateMessage(targetName: string, message: string): A.SayOutcome {
return A.sendPrivateMessage(this, targetName, message);
}
setCombatStyle(style: number): boolean {
return A.setCombatStyle(this, style);
}
@@ -20,6 +20,7 @@ import { beforeAll, describe, expect, test } from 'bun:test';
import './dom-shim.js';
import CollisionMap from '#/dash3d/CollisionMap.js';
import JString from '#/datastruct/JString.js';
import { ClientProt } from '#/io/ClientProt.js';
import Packet from '#/io/Packet.js';
import { ServerProt } from '#/io/ServerProt.js';
@@ -99,6 +100,13 @@ class Capture {
this.pos += 2;
return value;
}
g8(): bigint {
let value = 0n;
for (let i = 0; i < 8; i++) {
value = (value << 8n) | BigInt(this.buf.data[this.pos++]);
}
return value;
}
rest(until: number): number[] {
return Array.from(this.buf.data.subarray(this.pos, until));
}
@@ -225,6 +233,46 @@ describe('outgoing action encoding', () => {
expect(c.localPlayer!.chatTimer).toBe(150);
});
test('MESSAGE_PRIVATE carries the target userhash inside the size byte, then packed text', () => {
if (!available) {
return;
}
const c = makeClient('bota');
const start = c.out.pos;
const outcome = c.sendPrivateMessage('botb', 'hello world');
expect(outcome.ok).toBe(true);
// The oracle: pack the same text the way the browser client does.
const scratch = Packet.alloc(0);
WordPack.pack(scratch, 'hello world');
const packed = Array.from(scratch.data.subarray(0, scratch.pos));
const r = new Capture(c.out, start);
expect(r.g1()).toBe(ClientProt.MESSAGE_PRIVATE);
expect(r.g1()).toBe(8 + packed.length); // size covers userhash + text
expect(r.g8()).toBe(JString.toUserhash('botb'));
expect(r.rest(c.out.pos)).toEqual(packed);
});
test('sendPrivateMessage() self-echoes as a sent-PM (type 6) and rejects bad names', () => {
if (!available) {
return;
}
const c = makeClient('bota');
c.sendPrivateMessage('botb', 'hello world');
expect(c.chatType[0]).toBe(6); // same type the browser uses for "To <name>:"
expect(c.chatUsername[0]).toBe('Botb');
expect(c.chatText[0]).toBe('Hello world');
const before = c.out.pos;
expect(c.sendPrivateMessage('', 'hi').ok).toBe(false);
expect(c.out.pos).toBe(before); // nothing written for an invalid target
});
test('INV_BUTTON carries the real obj id from linkObjType, not 0', () => {
if (!available) {
return;
+34
View File
@@ -790,6 +790,40 @@ export function say(c: LiteClient, message: string): SayOutcome {
return { ok: true, truncated, filtered, finalText: echoed };
}
export function sendPrivateMessage(c: LiteClient, targetName: string, message: string): SayOutcome {
if (!c.isInGame() || !c.localPlayer) {
return { ok: false, truncated: false, filtered: false, finalText: '' };
}
const userhash = JString.toUserhash(targetName);
if (userhash === 0n) {
return { ok: false, truncated: false, filtered: false, finalText: '' };
}
const cap = c.getMaxMessageLength();
const truncated = message.length > cap;
const text = truncated ? message.substring(0, cap) : message;
// MessagePrivateDecoder reads: 8-byte target userhash, then packed text -
// the size byte covers both.
c.writeOpcode(ClientProt.MESSAGE_PRIVATE);
const lengthPos = c.out.pos;
c.out.p1(0);
const start = c.out.pos;
c.out.p8(userhash);
WordPack.pack(c.out, text);
c.out.data[lengthPos] = c.out.pos - start;
// Local echo as a sent-PM (type 6), mirroring Client's socialInput path:
// the server never echoes your own PM back.
const cased = JString.toSentenceCase(text);
const echoed = WordFilter.filter(cased);
const filtered = echoed !== cased;
c.addChat(6, echoed, JString.toScreenName(JString.toRawUsername(userhash)));
return { ok: true, truncated, filtered, finalText: echoed };
}
// ================================================================ combat
export function setCombatStyle(c: LiteClient, style: number): boolean {