@animalabs/tavern-mcpl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +68 -0
- package/README.md +57 -0
- package/bun.lock +92 -0
- package/dist/src/game.d.ts +43 -0
- package/dist/src/game.js +162 -0
- package/dist/src/game.js.map +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +44 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/server.d.ts +61 -0
- package/dist/src/server.js +382 -0
- package/dist/src/server.js.map +1 -0
- package/package.json +30 -0
- package/playtest.ts +243 -0
- package/src/game.ts +220 -0
- package/src/index.ts +48 -0
- package/src/server.ts +446 -0
- package/tsconfig.json +18 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TavernServer — the network/session layer of the Lantern & Ladle.
|
|
3
|
+
*
|
|
4
|
+
* The multi-agent network-MCPL pattern:
|
|
5
|
+
* - One WebSocketServer; each connection is authenticated by `?token=` and
|
|
6
|
+
* mapped to a stable agent id (sessions are ephemeral, identity is not).
|
|
7
|
+
* - One Session per live connection: initialize handshake, channels/register,
|
|
8
|
+
* then a message loop dispatching tools/call + channels/publish.
|
|
9
|
+
* - SharedCore = the game world. All mutations happen in tool handlers
|
|
10
|
+
* (atomic per message on Node's single thread) and persist immediately.
|
|
11
|
+
* - Fan-out: game events go to every ONLINE player in the room as
|
|
12
|
+
* channels/incoming on that room's channel; OFFLINE players in the room
|
|
13
|
+
* get the event queued (capped) and replayed on their next connect.
|
|
14
|
+
* - Persistence: world + offline queues in one JSON file (tmp+rename), so
|
|
15
|
+
* a server restart loses nothing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createServer, type Server as HttpServer } from 'node:http';
|
|
19
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { dirname } from 'node:path';
|
|
21
|
+
import { WebSocketServer, type WebSocket } from 'ws';
|
|
22
|
+
import {
|
|
23
|
+
McplConnection,
|
|
24
|
+
method,
|
|
25
|
+
type McplInitializeParams,
|
|
26
|
+
type McplInitializeResult,
|
|
27
|
+
type McplCapabilities,
|
|
28
|
+
type InitializeCapabilities,
|
|
29
|
+
type ChannelDescriptor,
|
|
30
|
+
type ChannelsRegisterParams,
|
|
31
|
+
type ChannelsIncomingParams,
|
|
32
|
+
type ChannelsPublishParams,
|
|
33
|
+
type ChannelsPublishResult,
|
|
34
|
+
} from '@animalabs/mcpl-core';
|
|
35
|
+
|
|
36
|
+
import * as game from './game.js';
|
|
37
|
+
import { ROOMS, type WorldState, type GameEvent } from './game.js';
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Config & persistence
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export interface AgentAuth {
|
|
44
|
+
/** Stable agent identity — state is keyed by this, never by connection. */
|
|
45
|
+
id: string;
|
|
46
|
+
/** In-game display name. */
|
|
47
|
+
name: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TavernServerOptions {
|
|
51
|
+
port: number;
|
|
52
|
+
host?: string;
|
|
53
|
+
/** token → agent identity. The ONLY identity channel: the host dials ?token=. */
|
|
54
|
+
tokens: Record<string, AgentAuth>;
|
|
55
|
+
storePath: string;
|
|
56
|
+
/** Max events queued per offline player (oldest dropped). Default 100. */
|
|
57
|
+
offlineQueueCap?: number;
|
|
58
|
+
log?: (line: string) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface QueuedEvent {
|
|
62
|
+
channelId: string;
|
|
63
|
+
text: string;
|
|
64
|
+
timestamp: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface StoreShape {
|
|
68
|
+
world: WorldState;
|
|
69
|
+
offlineQueues: Record<string, QueuedEvent[]>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadStore(path: string): StoreShape {
|
|
73
|
+
if (existsSync(path)) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as StoreShape;
|
|
76
|
+
if (parsed.world?.players && parsed.world?.roomItems) {
|
|
77
|
+
return { world: parsed.world, offlineQueues: parsed.offlineQueues ?? {} };
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Corrupt store: fall through to a fresh world rather than crash-looping.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { world: game.freshWorld(), offlineQueues: {} };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Tool definitions (MCP shape; host namespaces them as mcpl--<id>--<name>)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
const str = (description: string) => ({ type: 'string' as const, description });
|
|
91
|
+
|
|
92
|
+
const TOOLS = [
|
|
93
|
+
{ name: 'look', description: 'Look around your current room: description, exits, items, and who else is here.', inputSchema: { type: 'object', properties: {} } },
|
|
94
|
+
{ name: 'move', description: 'Move through an exit. Directions are room-specific (see look).', inputSchema: { type: 'object', properties: { direction: str('Exit direction, e.g. "down", "up", "out", "in".') }, required: ['direction'] } },
|
|
95
|
+
{ name: 'say', description: 'Say something aloud. Everyone in your room hears it.', inputSchema: { type: 'object', properties: { text: str('What to say.') }, required: ['text'] } },
|
|
96
|
+
{ name: 'take', description: 'Pick up an item from the room.', inputSchema: { type: 'object', properties: { item: str('Item name as shown by look.') }, required: ['item'] } },
|
|
97
|
+
{ name: 'drop', description: 'Set down an item you are carrying.', inputSchema: { type: 'object', properties: { item: str('Item name from your inventory.') }, required: ['item'] } },
|
|
98
|
+
{ name: 'inventory', description: 'List what you are carrying.', inputSchema: { type: 'object', properties: {} } },
|
|
99
|
+
{ name: 'who', description: 'List all patrons of the tavern, where they are, and who is awake.', inputSchema: { type: 'object', properties: {} } },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Server
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export class TavernServer {
|
|
107
|
+
private readonly opts: TavernServerOptions;
|
|
108
|
+
private readonly store: StoreShape;
|
|
109
|
+
private readonly sessions = new Map<string, Session>(); // agentId → live session
|
|
110
|
+
private httpServer: HttpServer | null = null;
|
|
111
|
+
private wss: WebSocketServer | null = null;
|
|
112
|
+
private readonly log: (line: string) => void;
|
|
113
|
+
|
|
114
|
+
constructor(opts: TavernServerOptions) {
|
|
115
|
+
this.opts = opts;
|
|
116
|
+
this.store = loadStore(opts.storePath);
|
|
117
|
+
this.log = opts.log ?? ((line) => console.error(`[tavern-mcpl] ${line}`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Start listening. Returns the bound port (useful with port 0). */
|
|
121
|
+
async start(): Promise<number> {
|
|
122
|
+
const httpServer = createServer();
|
|
123
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
124
|
+
this.httpServer = httpServer;
|
|
125
|
+
this.wss = wss;
|
|
126
|
+
|
|
127
|
+
wss.on('connection', (ws, req) => {
|
|
128
|
+
const token = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token');
|
|
129
|
+
const auth = token ? this.opts.tokens[token] : undefined;
|
|
130
|
+
if (!auth) {
|
|
131
|
+
this.log(`rejected connection (bad token) from ${req.socket.remoteAddress}`);
|
|
132
|
+
ws.close(4001, 'unauthorized');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// One live session per agent: a newer connection supersedes the old
|
|
137
|
+
// (covers host restarts where the dead socket hasn't timed out yet).
|
|
138
|
+
this.sessions.get(auth.id)?.close('superseded by a newer connection');
|
|
139
|
+
|
|
140
|
+
const session = new Session(this, auth, ws);
|
|
141
|
+
this.sessions.set(auth.id, session);
|
|
142
|
+
this.log(`${auth.name} (${auth.id}) connected`);
|
|
143
|
+
|
|
144
|
+
session.serve()
|
|
145
|
+
.catch((err) => this.log(`${auth.id} session error: ${(err as Error).message}`))
|
|
146
|
+
.finally(() => {
|
|
147
|
+
if (this.sessions.get(auth.id) === session) {
|
|
148
|
+
this.sessions.delete(auth.id);
|
|
149
|
+
this.fanOut({ room: this.world.players[auth.id]?.room ?? game.START_ROOM, text: `${auth.name} dozes off.` }, auth.id);
|
|
150
|
+
}
|
|
151
|
+
this.log(`${auth.name} (${auth.id}) disconnected`);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await new Promise<void>((resolve) => httpServer.listen(this.opts.port, this.opts.host ?? '127.0.0.1', resolve));
|
|
156
|
+
const address = httpServer.address();
|
|
157
|
+
const port = typeof address === 'object' && address ? address.port : this.opts.port;
|
|
158
|
+
this.log(`listening on ws://${this.opts.host ?? '127.0.0.1'}:${port} (${Object.keys(this.opts.tokens).length} tokens)`);
|
|
159
|
+
return port;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async stop(): Promise<void> {
|
|
163
|
+
for (const session of this.sessions.values()) session.close('server shutting down');
|
|
164
|
+
this.sessions.clear();
|
|
165
|
+
await new Promise<void>((resolve) => {
|
|
166
|
+
if (!this.wss) return resolve();
|
|
167
|
+
this.wss.close(() => resolve());
|
|
168
|
+
// ws + external HTTP server: the close callback waits for every client
|
|
169
|
+
// to finish its close handshake — terminate stragglers so shutdown
|
|
170
|
+
// can't wedge on a half-dead socket.
|
|
171
|
+
for (const client of this.wss.clients) client.terminate();
|
|
172
|
+
});
|
|
173
|
+
await new Promise<void>((resolve) => {
|
|
174
|
+
if (!this.httpServer) return resolve();
|
|
175
|
+
// Bounded: bun's node:http shim can fail to fire the close callback (and
|
|
176
|
+
// lacks closeAllConnections), which would wedge shutdown forever. The
|
|
177
|
+
// listener itself is released by close() either way; the grace period
|
|
178
|
+
// only covers callback delivery.
|
|
179
|
+
const grace = setTimeout(resolve, 1000);
|
|
180
|
+
this.httpServer.close(() => {
|
|
181
|
+
clearTimeout(grace);
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
this.httpServer.closeAllConnections?.();
|
|
185
|
+
});
|
|
186
|
+
this.persist();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// -- shared core access (used by Session) ---------------------------------
|
|
190
|
+
|
|
191
|
+
get world(): WorldState {
|
|
192
|
+
return this.store.world;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onlineIds(): Set<string> {
|
|
196
|
+
return new Set(this.sessions.keys());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
persist(): void {
|
|
200
|
+
const path = this.opts.storePath;
|
|
201
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
202
|
+
const tmp = `${path}.tmp`;
|
|
203
|
+
writeFileSync(tmp, JSON.stringify(this.store, null, 2));
|
|
204
|
+
renameSync(tmp, path);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Deliver a game event to every player in its room except the actor:
|
|
209
|
+
* live sessions get channels/incoming now; offline players get it queued.
|
|
210
|
+
*/
|
|
211
|
+
fanOut(event: GameEvent, actorId: string | null): void {
|
|
212
|
+
const timestamp = new Date().toISOString();
|
|
213
|
+
const channelId = `game:${event.room}`;
|
|
214
|
+
for (const [agentId, player] of Object.entries(this.world.players)) {
|
|
215
|
+
if (agentId === actorId || player.room !== event.room) continue;
|
|
216
|
+
const session = this.sessions.get(agentId);
|
|
217
|
+
if (session?.ready) {
|
|
218
|
+
session.deliver(channelId, event.text, timestamp);
|
|
219
|
+
} else {
|
|
220
|
+
const queue = (this.store.offlineQueues[agentId] ??= []);
|
|
221
|
+
queue.push({ channelId, text: event.text, timestamp });
|
|
222
|
+
const cap = this.opts.offlineQueueCap ?? 100;
|
|
223
|
+
if (queue.length > cap) queue.splice(0, queue.length - cap);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
this.persist();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Pull (and clear) an agent's offline queue. */
|
|
230
|
+
drainQueue(agentId: string): QueuedEvent[] {
|
|
231
|
+
const queue = this.store.offlineQueues[agentId] ?? [];
|
|
232
|
+
delete this.store.offlineQueues[agentId];
|
|
233
|
+
if (queue.length > 0) this.persist();
|
|
234
|
+
return queue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Session — one connected agent
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
class Session {
|
|
243
|
+
ready = false;
|
|
244
|
+
|
|
245
|
+
private readonly conn: McplConnection;
|
|
246
|
+
|
|
247
|
+
constructor(
|
|
248
|
+
private readonly server: TavernServer,
|
|
249
|
+
private readonly auth: AgentAuth,
|
|
250
|
+
ws: WebSocket,
|
|
251
|
+
) {
|
|
252
|
+
this.conn = McplConnection.fromWebSocket(ws as never);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
close(reason: string): void {
|
|
256
|
+
void reason;
|
|
257
|
+
this.conn.close();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Send one game event line as a channels/incoming message. Fire-and-forget. */
|
|
261
|
+
deliver(channelId: string, text: string, timestamp: string, tags?: string[]): void {
|
|
262
|
+
const params: ChannelsIncomingParams = {
|
|
263
|
+
messages: [{
|
|
264
|
+
channelId,
|
|
265
|
+
messageId: `ev-${timestamp}-${Math.random().toString(36).slice(2, 8)}`,
|
|
266
|
+
author: { id: 'world', name: 'The Tavern' },
|
|
267
|
+
timestamp,
|
|
268
|
+
content: [{ type: 'text', text }],
|
|
269
|
+
...(tags ? { tags } : {}),
|
|
270
|
+
}],
|
|
271
|
+
};
|
|
272
|
+
this.conn.sendRequest(method.CHANNELS_INCOMING, params).catch(() => {
|
|
273
|
+
// Delivery failure = connection is going down; the serve loop handles it.
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async serve(): Promise<void> {
|
|
278
|
+
await this.handshake();
|
|
279
|
+
|
|
280
|
+
// Join the world (or wake back up in place) — visible to the room.
|
|
281
|
+
const joinEvent = game.ensurePlayer(this.server.world, this.auth.id, this.auth.name);
|
|
282
|
+
this.server.persist();
|
|
283
|
+
|
|
284
|
+
// Announce the rooms as channels, then open for business.
|
|
285
|
+
await this.registerChannels();
|
|
286
|
+
this.ready = true;
|
|
287
|
+
if (joinEvent) this.server.fanOut(joinEvent, this.auth.id);
|
|
288
|
+
|
|
289
|
+
// Replay anything that happened while this agent was asleep.
|
|
290
|
+
const missed = this.server.drainQueue(this.auth.id);
|
|
291
|
+
if (missed.length > 0) {
|
|
292
|
+
this.deliver(
|
|
293
|
+
`game:${this.server.world.players[this.auth.id]!.room}`,
|
|
294
|
+
`While you dozed, ${missed.length} thing${missed.length === 1 ? '' : 's'} happened:`,
|
|
295
|
+
new Date().toISOString(),
|
|
296
|
+
);
|
|
297
|
+
for (const ev of missed) this.deliver(ev.channelId, ev.text, ev.timestamp);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
while (!this.conn.isClosed) {
|
|
302
|
+
const msg = await this.conn.nextMessage();
|
|
303
|
+
if (msg.type !== 'request') continue;
|
|
304
|
+
const req = msg.request;
|
|
305
|
+
const params = (req.params ?? {}) as Record<string, unknown>;
|
|
306
|
+
try {
|
|
307
|
+
switch (req.method) {
|
|
308
|
+
case 'tools/list':
|
|
309
|
+
this.conn.sendResponse(req.id, { tools: TOOLS });
|
|
310
|
+
break;
|
|
311
|
+
case 'tools/call':
|
|
312
|
+
this.conn.sendResponse(
|
|
313
|
+
req.id,
|
|
314
|
+
this.handleToolCall(String(params.name), (params.arguments ?? {}) as Record<string, unknown>),
|
|
315
|
+
);
|
|
316
|
+
break;
|
|
317
|
+
case method.CHANNELS_LIST:
|
|
318
|
+
this.conn.sendResponse(req.id, { channels: this.channelDescriptors() });
|
|
319
|
+
break;
|
|
320
|
+
case method.CHANNELS_PUBLISH:
|
|
321
|
+
this.conn.sendResponse(req.id, this.handlePublish(params as unknown as ChannelsPublishParams));
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
this.conn.sendError(req.id, -32601, `Method not found: ${req.method}`);
|
|
325
|
+
}
|
|
326
|
+
} catch (e) {
|
|
327
|
+
this.conn.sendError(req.id, -32000, (e as Error).message);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// A dropped connection ends the loop cleanly; anything else is real.
|
|
332
|
+
if ((e as Error).name !== 'ConnectionClosedError') throw e;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// -- protocol steps --------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
private async handshake(): Promise<void> {
|
|
339
|
+
const msg = await this.conn.nextMessage();
|
|
340
|
+
if (msg.type !== 'request' || msg.request.method !== 'initialize') {
|
|
341
|
+
this.conn.close();
|
|
342
|
+
throw new Error('expected initialize as the first message');
|
|
343
|
+
}
|
|
344
|
+
const initParams = msg.request.params as unknown as McplInitializeParams | undefined;
|
|
345
|
+
const mcplRequested = initParams?.capabilities?.experimental?.mcpl !== undefined;
|
|
346
|
+
|
|
347
|
+
const serverCaps: McplCapabilities = {
|
|
348
|
+
version: '0.4',
|
|
349
|
+
pushEvents: false,
|
|
350
|
+
channels: true,
|
|
351
|
+
rollback: false,
|
|
352
|
+
};
|
|
353
|
+
const capabilities: InitializeCapabilities = {
|
|
354
|
+
tools: {},
|
|
355
|
+
...(mcplRequested ? { experimental: { mcpl: serverCaps } } : {}),
|
|
356
|
+
};
|
|
357
|
+
const result: McplInitializeResult = {
|
|
358
|
+
protocolVersion: '2024-11-05',
|
|
359
|
+
capabilities,
|
|
360
|
+
serverInfo: { name: 'tavern-mcpl', version: '0.1.0' },
|
|
361
|
+
};
|
|
362
|
+
this.conn.sendResponse(msg.request.id, result);
|
|
363
|
+
|
|
364
|
+
const inited = await this.conn.nextMessage();
|
|
365
|
+
if (!(inited.type === 'notification' && inited.notification.method === 'notifications/initialized')) {
|
|
366
|
+
this.conn.close();
|
|
367
|
+
throw new Error('expected notifications/initialized after initialize');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private channelDescriptors(): ChannelDescriptor[] {
|
|
372
|
+
return Object.values(ROOMS).map((room) => ({
|
|
373
|
+
id: `game:${room.id}`,
|
|
374
|
+
type: 'game',
|
|
375
|
+
label: `Lantern & Ladle — ${room.label}`,
|
|
376
|
+
direction: 'bidirectional' as const,
|
|
377
|
+
address: { room: room.id },
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async registerChannels(): Promise<void> {
|
|
382
|
+
const params: ChannelsRegisterParams = { channels: this.channelDescriptors() };
|
|
383
|
+
try {
|
|
384
|
+
await this.conn.sendRequest(method.CHANNELS_REGISTER, params);
|
|
385
|
+
} catch (e) {
|
|
386
|
+
// Non-MCPL hosts may reject channels/register; tools still work.
|
|
387
|
+
void e;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// -- game dispatch ---------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
private handleToolCall(name: string, args: Record<string, unknown>) {
|
|
394
|
+
const world = this.server.world;
|
|
395
|
+
const id = this.auth.id;
|
|
396
|
+
|
|
397
|
+
let result: game.ActionResult;
|
|
398
|
+
switch (name) {
|
|
399
|
+
case 'look': result = game.look(world, id); break;
|
|
400
|
+
case 'move': result = game.move(world, id, String(args.direction ?? '')); break;
|
|
401
|
+
case 'say': result = game.say(world, id, String(args.text ?? '')); break;
|
|
402
|
+
case 'take': result = game.take(world, id, String(args.item ?? '')); break;
|
|
403
|
+
case 'drop': result = game.drop(world, id, String(args.item ?? '')); break;
|
|
404
|
+
case 'inventory': result = game.inventory(world, id); break;
|
|
405
|
+
case 'who': result = game.who(world, id, this.server.onlineIds()); break;
|
|
406
|
+
default:
|
|
407
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
for (const event of result.events ?? []) this.server.fanOut(event, id);
|
|
411
|
+
this.server.persist();
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
content: [{ type: 'text', text: result.reply }],
|
|
415
|
+
...(result.isError ? { isError: true } : {}),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* channels/publish — the host routes the agent's plain speech to an open
|
|
421
|
+
* channel. Publishing to the room you're standing in = saying it aloud.
|
|
422
|
+
*/
|
|
423
|
+
private handlePublish(params: ChannelsPublishParams): ChannelsPublishResult {
|
|
424
|
+
const world = this.server.world;
|
|
425
|
+
const player = world.players[this.auth.id]!;
|
|
426
|
+
const targetRoom = params.channelId?.startsWith('game:') ? params.channelId.slice(5) : null;
|
|
427
|
+
if (!targetRoom || !ROOMS[targetRoom]) {
|
|
428
|
+
return { delivered: false };
|
|
429
|
+
}
|
|
430
|
+
if (targetRoom !== player.room) {
|
|
431
|
+
// You can only speak in the room you occupy — no shouting across the map.
|
|
432
|
+
return { delivered: false };
|
|
433
|
+
}
|
|
434
|
+
const text = (params.content ?? [])
|
|
435
|
+
.filter((block): block is { type: 'text'; text: string } => block.type === 'text')
|
|
436
|
+
.map((block) => block.text)
|
|
437
|
+
.join('\n')
|
|
438
|
+
.trim();
|
|
439
|
+
if (!text) return { delivered: false };
|
|
440
|
+
|
|
441
|
+
const result = game.say(world, this.auth.id, text);
|
|
442
|
+
for (const event of result.events ?? []) this.server.fanOut(event, this.auth.id);
|
|
443
|
+
this.server.persist();
|
|
444
|
+
return { delivered: true, messageId: `say-${Date.now()}` };
|
|
445
|
+
}
|
|
446
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": ".",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"sourceMap": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["node_modules", "dist", "playtest.ts"]
|
|
18
|
+
}
|