@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/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
+ }