@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/playtest.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Playtest: two agents in the Lantern & Ladle, connected through the REAL
3
+ * agent-framework WebSocket client (the exact transport + handshake a
4
+ * connectome host uses after `mcpl_deploy {url: ...}`).
5
+ *
6
+ * Exercises: token auth, concurrent sessions, room-scoped fan-out,
7
+ * channels/publish speech, offline queueing + replay on reconnect, and
8
+ * full server-restart persistence.
9
+ *
10
+ * Run: bun playtest.ts
11
+ */
12
+
13
+ import { mkdtempSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import {
17
+ McplServerConnection,
18
+ type McplHostCapabilities,
19
+ } from '@animalabs/agent-framework';
20
+
21
+ import { TavernServer } from './src/server.js';
22
+
23
+ const HOST_CAPS: McplHostCapabilities = {
24
+ version: '0.4',
25
+ pushEvents: true,
26
+ contextHooks: { beforeInference: true, afterInference: { blocking: true } },
27
+ featureSets: true,
28
+ };
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Transcript + assertions
32
+ // ---------------------------------------------------------------------------
33
+
34
+ let failures = 0;
35
+
36
+ function narrate(text: string): void {
37
+ console.log(`\x1b[2m${text}\x1b[0m`);
38
+ }
39
+
40
+ function scene(title: string): void {
41
+ console.log(`\n\x1b[1m━━ ${title} ━━\x1b[0m`);
42
+ }
43
+
44
+ function check(label: string, ok: boolean, detail?: string): void {
45
+ if (ok) {
46
+ console.log(` \x1b[32m✓\x1b[0m ${label}`);
47
+ } else {
48
+ failures++;
49
+ console.log(` \x1b[31m✗ ${label}${detail ? ` — ${detail}` : ''}\x1b[0m`);
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Player client — a thin wrapper around the real host-side connection
55
+ // ---------------------------------------------------------------------------
56
+
57
+ class Player {
58
+ feed: string[] = [];
59
+
60
+ private constructor(
61
+ readonly name: string,
62
+ readonly conn: McplServerConnection,
63
+ ) {}
64
+
65
+ static async connect(name: string, url: string, token: string): Promise<Player> {
66
+ const conn = await McplServerConnection.connect({ id: 'tavern', url, token }, HOST_CAPS);
67
+ const player = new Player(name, conn);
68
+
69
+ conn.on('channels-register', (params: { channels: Array<{ id: string }> }, responder: { respond: (r: unknown) => void }) => {
70
+ responder.respond({ registered: params.channels.map(c => c.id) });
71
+ });
72
+ conn.on('channels-incoming', (
73
+ params: { messages: Array<{ messageId: string; channelId: string; content: Array<{ type: string; text?: string }> }> },
74
+ responder: { respond: (r: unknown) => void },
75
+ ) => {
76
+ for (const message of params.messages) {
77
+ const text = message.content.filter(b => b.type === 'text').map(b => b.text).join('');
78
+ player.feed.push(`[${message.channelId}] ${text}`);
79
+ console.log(` \x1b[36m${name} hears\x1b[0m ${text}`);
80
+ }
81
+ responder.respond({ results: params.messages.map(m => ({ messageId: m.messageId, accepted: true })) });
82
+ });
83
+
84
+ conn.ready(); // flush anything buffered during the handshake window
85
+ return player;
86
+ }
87
+
88
+ /** Call a game tool and return the text reply. */
89
+ async act(tool: string, args: Record<string, unknown> = {}): Promise<string> {
90
+ const result = await this.conn.sendToolsCall(tool, args);
91
+ const text = (result.content as Array<{ type: string; text?: string }>)
92
+ .filter(b => b.type === 'text').map(b => b.text).join('');
93
+ const argsNote = Object.keys(args).length ? ` ${JSON.stringify(args)}` : '';
94
+ console.log(` \x1b[33m${this.name} → ${tool}${argsNote}\x1b[0m`);
95
+ console.log(` ${text.split('\n').join('\n ')}`);
96
+ return text;
97
+ }
98
+
99
+ /** The host routing plain agent speech to an open channel. */
100
+ async speak(channelId: string, text: string): Promise<boolean> {
101
+ console.log(` \x1b[33m${this.name} speaks (channels/publish → ${channelId}):\x1b[0m "${text}"`);
102
+ const result = await this.conn.sendChannelsPublish({
103
+ conversationId: 'playtest',
104
+ channelId,
105
+ content: [{ type: 'text', text }],
106
+ });
107
+ return (result as { delivered: boolean } | void)?.delivered ?? false;
108
+ }
109
+
110
+ /** Wait until the feed contains a line matching `fragment` (or time out). */
111
+ async hears(fragment: string, timeoutMs = 2000): Promise<boolean> {
112
+ const deadline = Date.now() + timeoutMs;
113
+ while (Date.now() < deadline) {
114
+ if (this.feed.some(line => line.includes(fragment))) return true;
115
+ await new Promise(resolve => setTimeout(resolve, 20));
116
+ }
117
+ return false;
118
+ }
119
+
120
+ async silentOn(fragment: string, settleMs = 300): Promise<boolean> {
121
+ await new Promise(resolve => setTimeout(resolve, settleMs));
122
+ return !this.feed.some(line => line.includes(fragment));
123
+ }
124
+
125
+ async close(): Promise<void> {
126
+ await this.conn.close();
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // The playtest
132
+ // ---------------------------------------------------------------------------
133
+
134
+ const dir = mkdtempSync(join(tmpdir(), 'tavern-playtest-'));
135
+ const storePath = join(dir, 'tavern.json');
136
+ const tokens = {
137
+ 'tok-alice': { id: 'alice', name: 'Alice' },
138
+ 'tok-bob': { id: 'bob', name: 'Bob' },
139
+ };
140
+
141
+ let server = new TavernServer({ port: 0, tokens, storePath, log: () => {} });
142
+ const port = await server.start();
143
+ const url = `ws://127.0.0.1:${port}/mcpl`;
144
+ narrate(`The Lantern & Ladle opens for business on ${url}`);
145
+
146
+ try {
147
+ // ── Scene 1 ───────────────────────────────────────────────────────────────
148
+ scene('Scene 1 — Arrival');
149
+
150
+ const alice = await Player.connect('Alice', url, 'tok-alice');
151
+ const tools = await alice.conn.sendToolsList();
152
+ check('handshake over real WS transport + tools discovered', tools.tools.length === 7,
153
+ `got ${tools.tools.length} tools`);
154
+
155
+ const firstLook = await alice.act('look');
156
+ check('Alice starts in the Taproom with the ladle', firstLook.includes('Taproom') && firstLook.includes('ladle'));
157
+
158
+ const bob = await Player.connect('Bob', url, 'tok-bob');
159
+ check('Alice sees Bob arrive (room-scoped fan-out)', await alice.hears('Bob pushes through the front door'));
160
+
161
+ const bobLook = await bob.act('look');
162
+ check('Bob sees Alice in the room', bobLook.includes('Alice'));
163
+
164
+ // ── Scene 2 ───────────────────────────────────────────────────────────────
165
+ scene('Scene 2 — Socializing');
166
+
167
+ await bob.act('say', { text: 'Evening, Alice! Cold night out.' });
168
+ check('Alice hears Bob speak', await alice.hears('Bob says: "Evening, Alice!'));
169
+
170
+ await alice.act('take', { item: 'ladle' });
171
+ check('Bob sees Alice take the ladle', await bob.hears('Alice picks up the ladle'));
172
+
173
+ const inv = await alice.act('inventory');
174
+ check('Alice is carrying the ladle', inv.includes('ladle'));
175
+
176
+ // ── Scene 3 ───────────────────────────────────────────────────────────────
177
+ scene('Scene 3 — Exploring (visibility is per-room)');
178
+
179
+ const cellarLook = await alice.act('move', { direction: 'down' });
180
+ check('Bob sees Alice leave', await bob.hears('Alice heads down to The Cellar'));
181
+ check('Alice finds the lantern in the cellar', cellarLook.includes('lantern'));
182
+
183
+ await alice.act('take', { item: 'lantern' });
184
+
185
+ const delivered = await bob.speak('game:taproom', 'Talking to myself again...');
186
+ check('Bob\'s plain speech routes via channels/publish', delivered === true);
187
+ check('Alice (in the cellar) does NOT hear taproom chatter',
188
+ await alice.silentOn('Talking to myself'));
189
+
190
+ await alice.act('move', { direction: 'up' });
191
+ check('Bob sees Alice return', await bob.hears('Alice arrives from The Cellar'));
192
+
193
+ // ── Scene 4 ───────────────────────────────────────────────────────────────
194
+ scene('Scene 4 — Bob\'s host goes down (offline queue)');
195
+
196
+ await bob.close();
197
+ narrate('Bob\'s connection drops (host restart, say). The world keeps moving.');
198
+ check('Alice sees Bob doze off', await alice.hears('Bob dozes off'));
199
+
200
+ await alice.act('say', { text: 'Bob? Huh. Out like a candle.' });
201
+ await alice.act('drop', { item: 'ladle' });
202
+ const who = await alice.act('who');
203
+ check('who shows Bob asleep in the Taproom', who.includes('Bob') && who.includes('asleep'));
204
+
205
+ const bob2 = await Player.connect('Bob', url, 'tok-bob');
206
+ check('Alice sees Bob wake back up', await alice.hears('Bob blinks back into the room'));
207
+ check('Bob is told what he missed', await bob2.hears('While you dozed, 2 things happened'));
208
+ check('...including Alice\'s words', await bob2.hears('Out like a candle'));
209
+ check('...and the ladle hitting the table', await bob2.hears('Alice sets down the ladle'));
210
+
211
+ // ── Scene 5 ───────────────────────────────────────────────────────────────
212
+ scene('Scene 5 — The tavern itself restarts (persistence)');
213
+
214
+ await alice.close();
215
+ await bob2.close();
216
+ await server.stop();
217
+ narrate('The tavern closes for the night (server process exits)...');
218
+
219
+ server = new TavernServer({ port, tokens, storePath, log: () => {} });
220
+ await server.start();
221
+ narrate('...and reopens from the same store file.');
222
+
223
+ const alice2 = await Player.connect('Alice', url, 'tok-alice');
224
+ const inv2 = await alice2.act('inventory');
225
+ check('Alice still carries the lantern across a full server restart', inv2.includes('lantern'));
226
+ const look2 = await alice2.act('look');
227
+ check('The dropped ladle is still on the taproom floor', look2.includes('ladle'));
228
+ const who2 = await alice2.act('who');
229
+ check('Bob is still known (asleep) after the restart', who2.includes('Bob') && who2.includes('asleep'));
230
+
231
+ await alice2.close();
232
+ } finally {
233
+ await server.stop();
234
+ rmSync(dir, { recursive: true, force: true });
235
+ }
236
+
237
+ console.log();
238
+ if (failures === 0) {
239
+ console.log('\x1b[1m\x1b[32m🍺 PLAYTEST PASSED — the Lantern & Ladle is open for agents.\x1b[0m');
240
+ } else {
241
+ console.log(`\x1b[1m\x1b[31mPLAYTEST FAILED — ${failures} check(s) did not hold.\x1b[0m`);
242
+ process.exit(1);
243
+ }
package/src/game.ts ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The Lantern & Ladle — pure game logic. No I/O, no protocol: a world state,
3
+ * a static room graph, and actions that return (reply-to-actor, events-for-
4
+ * others). The server layer owns fan-out, persistence, and sessions.
5
+ */
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // World definition
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export interface RoomDef {
12
+ id: string;
13
+ label: string;
14
+ description: string;
15
+ exits: Record<string, string>; // direction → roomId
16
+ }
17
+
18
+ export const ROOMS: Record<string, RoomDef> = {
19
+ taproom: {
20
+ id: 'taproom',
21
+ label: 'The Taproom',
22
+ description:
23
+ 'The heart of the Lantern & Ladle. A fire crackles in the hearth; ' +
24
+ 'mismatched chairs crowd around sticky tables. Stairs lead up to the ' +
25
+ 'attic, a trapdoor opens down to the cellar, and the garden door stands ajar.',
26
+ exits: { down: 'cellar', up: 'attic', out: 'garden' },
27
+ },
28
+ cellar: {
29
+ id: 'cellar',
30
+ label: 'The Cellar',
31
+ description:
32
+ 'Cool and dark. Barrels line the walls, and something drips in the ' +
33
+ 'far corner. The trapdoor above lets in a square of firelight.',
34
+ exits: { up: 'taproom' },
35
+ },
36
+ attic: {
37
+ id: 'attic',
38
+ label: 'The Attic',
39
+ description:
40
+ 'Dust motes drift through a shaft of light from the round window. ' +
41
+ 'Crates of forgotten things are stacked to the rafters.',
42
+ exits: { down: 'taproom' },
43
+ },
44
+ garden: {
45
+ id: 'garden',
46
+ label: 'The Kitchen Garden',
47
+ description:
48
+ 'Rows of herbs behind the tavern. A crooked scarecrow watches over ' +
49
+ 'the mushroom patch by the wall.',
50
+ exits: { in: 'taproom' },
51
+ },
52
+ };
53
+
54
+ export const START_ROOM = 'taproom';
55
+
56
+ const INITIAL_ITEMS: Record<string, string[]> = {
57
+ taproom: ['ladle'],
58
+ cellar: ['lantern'],
59
+ attic: ['dusty-tome'],
60
+ garden: ['mushroom'],
61
+ };
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // State
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface Player {
68
+ name: string;
69
+ room: string;
70
+ inventory: string[];
71
+ }
72
+
73
+ export interface WorldState {
74
+ players: Record<string, Player>; // keyed by agentId
75
+ roomItems: Record<string, string[]>;
76
+ }
77
+
78
+ export function freshWorld(): WorldState {
79
+ return {
80
+ players: {},
81
+ roomItems: structuredClone(INITIAL_ITEMS),
82
+ };
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Action results
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /** Something other players should see. `room` scopes visibility. */
90
+ export interface GameEvent {
91
+ room: string;
92
+ text: string;
93
+ }
94
+
95
+ export interface ActionResult {
96
+ reply: string;
97
+ events?: GameEvent[];
98
+ isError?: boolean;
99
+ }
100
+
101
+ function err(reply: string): ActionResult {
102
+ return { reply, isError: true };
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Queries
107
+ // ---------------------------------------------------------------------------
108
+
109
+ function playersInRoom(world: WorldState, room: string, excludeId?: string): Player[] {
110
+ return Object.entries(world.players)
111
+ .filter(([id, p]) => p.room === room && id !== excludeId)
112
+ .map(([, p]) => p);
113
+ }
114
+
115
+ export function describeRoom(world: WorldState, agentId: string): string {
116
+ const player = world.players[agentId]!;
117
+ const room = ROOMS[player.room]!;
118
+ const items = world.roomItems[room.id] ?? [];
119
+ const others = playersInRoom(world, room.id, agentId);
120
+ const lines = [
121
+ `**${room.label}**`,
122
+ room.description,
123
+ `Exits: ${Object.entries(room.exits).map(([d, r]) => `${d} (${ROOMS[r]!.label})`).join(', ')}.`,
124
+ ];
125
+ if (items.length > 0) lines.push(`You see: ${items.join(', ')}.`);
126
+ if (others.length > 0) lines.push(`Also here: ${others.map(p => p.name).join(', ')}.`);
127
+ return lines.join('\n');
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Actions (each mutates world and returns reply + events)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /** Ensure the player exists; returns a join/return event when they (re)enter the world. */
135
+ export function ensurePlayer(world: WorldState, agentId: string, name: string): GameEvent | null {
136
+ const existing = world.players[agentId];
137
+ if (existing) {
138
+ existing.name = name; // token config may have renamed them
139
+ return { room: existing.room, text: `${name} blinks back into the room.` };
140
+ }
141
+ world.players[agentId] = { name, room: START_ROOM, inventory: [] };
142
+ return { room: START_ROOM, text: `${name} pushes through the front door and looks around.` };
143
+ }
144
+
145
+ export function look(world: WorldState, agentId: string): ActionResult {
146
+ return { reply: describeRoom(world, agentId) };
147
+ }
148
+
149
+ export function move(world: WorldState, agentId: string, direction: string): ActionResult {
150
+ const player = world.players[agentId]!;
151
+ const from = ROOMS[player.room]!;
152
+ const toId = from.exits[direction];
153
+ if (!toId) {
154
+ return err(`You can't go "${direction}" from ${from.label}. Exits: ${Object.keys(from.exits).join(', ')}.`);
155
+ }
156
+ const to = ROOMS[toId]!;
157
+ player.room = toId;
158
+ return {
159
+ reply: `You head ${direction} to ${to.label}.\n\n${describeRoom(world, agentId)}`,
160
+ events: [
161
+ { room: from.id, text: `${player.name} heads ${direction} to ${to.label}.` },
162
+ { room: to.id, text: `${player.name} arrives from ${from.label}.` },
163
+ ],
164
+ };
165
+ }
166
+
167
+ export function say(world: WorldState, agentId: string, text: string): ActionResult {
168
+ const player = world.players[agentId]!;
169
+ const trimmed = text.trim();
170
+ if (!trimmed) return err('Say what?');
171
+ return {
172
+ reply: `You say: "${trimmed}"`,
173
+ events: [{ room: player.room, text: `${player.name} says: "${trimmed}"` }],
174
+ };
175
+ }
176
+
177
+ export function take(world: WorldState, agentId: string, item: string): ActionResult {
178
+ const player = world.players[agentId]!;
179
+ const items = world.roomItems[player.room] ?? [];
180
+ const idx = items.indexOf(item);
181
+ if (idx < 0) {
182
+ return err(`There's no "${item}" here.${items.length ? ` You see: ${items.join(', ')}.` : ''}`);
183
+ }
184
+ items.splice(idx, 1);
185
+ player.inventory.push(item);
186
+ return {
187
+ reply: `You pick up the ${item}.`,
188
+ events: [{ room: player.room, text: `${player.name} picks up the ${item}.` }],
189
+ };
190
+ }
191
+
192
+ export function drop(world: WorldState, agentId: string, item: string): ActionResult {
193
+ const player = world.players[agentId]!;
194
+ const idx = player.inventory.indexOf(item);
195
+ if (idx < 0) return err(`You're not carrying a "${item}".`);
196
+ player.inventory.splice(idx, 1);
197
+ (world.roomItems[player.room] ??= []).push(item);
198
+ return {
199
+ reply: `You set down the ${item}.`,
200
+ events: [{ room: player.room, text: `${player.name} sets down the ${item}.` }],
201
+ };
202
+ }
203
+
204
+ export function inventory(world: WorldState, agentId: string): ActionResult {
205
+ const player = world.players[agentId]!;
206
+ return {
207
+ reply: player.inventory.length
208
+ ? `You are carrying: ${player.inventory.join(', ')}.`
209
+ : 'You are carrying nothing.',
210
+ };
211
+ }
212
+
213
+ export function who(world: WorldState, agentId: string, online: Set<string>): ActionResult {
214
+ const lines = Object.entries(world.players).map(([id, p]) => {
215
+ const here = id === agentId ? ' (you)' : '';
216
+ const status = online.has(id) ? '' : ' — asleep'; // disconnected players doze off in place
217
+ return `${p.name}${here}: ${ROOMS[p.room]!.label}${status}`;
218
+ });
219
+ return { reply: `Patrons of the Lantern & Ladle:\n${lines.join('\n')}` };
220
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * tavern-mcpl entrypoint.
3
+ *
4
+ * Env:
5
+ * TAVERN_PORT — listen port (default 7450)
6
+ * TAVERN_HOST — bind host (default 127.0.0.1; front with a TLS proxy for wss://)
7
+ * TAVERN_STORE — state file (default ./data/tavern.json)
8
+ * TAVERN_TOKENS — comma-separated token:agentId:DisplayName triples, e.g.
9
+ * "s3cret1:labclaude:LabClaude,s3cret2:mythos:Mythos"
10
+ *
11
+ * Agents join with:
12
+ * mcpl_deploy { id: "tavern", url: "ws://host:7450/mcpl", token: "s3cret1", reconnect: true }
13
+ */
14
+
15
+ import { TavernServer, type AgentAuth } from './server.js';
16
+
17
+ function parseTokens(spec: string | undefined): Record<string, AgentAuth> {
18
+ const tokens: Record<string, AgentAuth> = {};
19
+ for (const triple of (spec ?? '').split(',')) {
20
+ const [token, id, name] = triple.split(':').map((part) => part?.trim());
21
+ if (token && id) tokens[token] = { id, name: name || id };
22
+ }
23
+ return tokens;
24
+ }
25
+
26
+ const tokens = parseTokens(process.env.TAVERN_TOKENS);
27
+ if (Object.keys(tokens).length === 0) {
28
+ console.error('[tavern-mcpl] TAVERN_TOKENS is empty — set token:agentId:Name triples. Refusing to start an unauthenticated server.');
29
+ process.exit(1);
30
+ }
31
+
32
+ const server = new TavernServer({
33
+ port: Number(process.env.TAVERN_PORT ?? 7450),
34
+ host: process.env.TAVERN_HOST ?? '127.0.0.1',
35
+ storePath: process.env.TAVERN_STORE ?? './data/tavern.json',
36
+ tokens,
37
+ });
38
+
39
+ server.start().catch((err) => {
40
+ console.error('[tavern-mcpl] failed to start:', err);
41
+ process.exit(1);
42
+ });
43
+
44
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
45
+ process.on(signal, () => {
46
+ void server.stop().then(() => process.exit(0));
47
+ });
48
+ }