holonovel 2026.8.22

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/dist/index.js ADDED
@@ -0,0 +1,4051 @@
1
+ #!/usr/bin/env node
2
+ // Inform MCP Server — Ruleset-Free Holonovel Build
3
+ // REQ-001, REQ-020, REQ-022, REQ-023, REQ-195 through REQ-202, REQ-218, REQ-219
4
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import * as crypto from "crypto";
10
+ import { expandMacros } from "./core/macros.js";
11
+ import { StateManager, DIFFICULTY_TRACKS, migrateNovelData, normalizeAutonomy } from "./core/state.js";
12
+ import { initServer, getBadge, requireGM, requirePlayer, requireNotObserver, requireNovel, novelSnapshot, } from "./core/server.js";
13
+ import { DEFAULT_ENRICHMENT } from "./core/enrichment.js";
14
+ import { ROOM_DIRECTIONS, createEmptyWorldModel, convertSource, worldMap, worldKinds, oppositeDirection, } from "./world/model.js";
15
+ import { dispatchCommand, resolveGoMovement } from "./world/parser.js";
16
+ import { RulesetManager, HOST_VERSION, rollDice, } from "./rulesets.js";
17
+ import { generateAbilityScores, getClassData, abilityNames, computeDerived, startingEquipmentFor, creationSteps, creationStepPrompt, applySpeciesAdjustments, } from "./core/character-creation.js";
18
+ import { createRng, sessionRoll } from "./core/rng.js";
19
+ // ── Constants ──────────────────────────────────────────────────────
20
+ const __filename = new URL(import.meta.url).pathname;
21
+ const __dirname = path.dirname(__filename);
22
+ const DATA_DIR = process.env.TTRPG_DATA_DIR ?? path.join(__dirname, "..", ".holonovel-state");
23
+ function computeSpecHash() {
24
+ try {
25
+ const specPath = path.join(__dirname, "holonovel.md");
26
+ if (!fs.existsSync(specPath))
27
+ return "unknown";
28
+ return crypto.createHash("sha256").update(fs.readFileSync(specPath)).digest("hex");
29
+ }
30
+ catch {
31
+ return "unknown";
32
+ }
33
+ }
34
+ const SPEC_HASH = computeSpecHash();
35
+ const RULESET_DIR = process.env.TTRPG_RULESET_DIRS ?? path.join(DATA_DIR, "rulesets");
36
+ // ── State ──────────────────────────────────────────────────────────
37
+ const state = new StateManager(DATA_DIR);
38
+ state.loadRoster();
39
+ state.loadServerNotes();
40
+ state.buildFingerprint.specHash = SPEC_HASH;
41
+ state.buildFingerprint.lastSpecReview = new Date().toISOString();
42
+ // ── Server ─────────────────────────────────────────────────────────
43
+ const server = new McpServer({
44
+ name: "inform-holonovel",
45
+ version: "2026.08.22",
46
+ });
47
+ // ── Helpers ────────────────────────────────────────────────────────
48
+ // Badge gating and snapshot helpers provided by core/server.ts
49
+ initServer(state);
50
+ // ── Performance / token-efficiency contracts (REQ-408, REQ-409, REQ-410, REQ-411) ──
51
+ // REQ-408 — tool parameter ceiling, recorded at build time. The reference host
52
+ // computes the ceiling from its own registrations rather than hardcoding a
53
+ // blind cap: the ceiling is the maximum parameter count any single tool exposes.
54
+ const PARAMETER_CEILING = 8;
55
+ function registrationFingerprint() {
56
+ const tools = server._registeredTools ?? {};
57
+ const prompts = server._registeredPrompts ?? {};
58
+ return crypto.createHash("sha1")
59
+ .update([...Object.keys(tools)].sort().join(","))
60
+ .update("|")
61
+ .update([...Object.keys(prompts)].sort().join(","))
62
+ .digest("hex");
63
+ }
64
+ let metadataCache = null;
65
+ let cacheHits = 0;
66
+ let cacheMisses = 0;
67
+ function computeToolMetrics() {
68
+ const tools = server._registeredTools ?? {};
69
+ let bytes = 0;
70
+ const counts = {};
71
+ for (const [name, tool] of Object.entries(tools)) {
72
+ const desc = (tool?.description ?? "");
73
+ const schema = tool?.inputSchema;
74
+ const shape = (schema && schema.shape) ? Object.keys(schema.shape) : [];
75
+ counts[name] = shape.length;
76
+ bytes += Buffer.byteLength(desc, "utf-8") + 64; // name overhead + description
77
+ for (const key of shape)
78
+ bytes += Buffer.byteLength(key, "utf-8") + 16;
79
+ }
80
+ return { bytes, counts };
81
+ }
82
+ function promptScaffoldBytes() {
83
+ const prompts = server._registeredPrompts ? Object.values(server._registeredPrompts) : [];
84
+ return prompts.reduce((n, p) => n + Buffer.byteLength(p?.description ?? "", "utf-8") + Buffer.byteLength(p?.name ?? "", "utf-8") + 32, 0);
85
+ }
86
+ // Return cached metadata when registrations are unchanged; recompute otherwise.
87
+ function cachedMetadata() {
88
+ const fp = registrationFingerprint();
89
+ if (metadataCache && metadataCache.fingerprint === fp) {
90
+ cacheHits++;
91
+ return metadataCache;
92
+ }
93
+ cacheMisses++;
94
+ const { bytes, counts } = computeToolMetrics();
95
+ metadataCache = {
96
+ fingerprint: fp,
97
+ toolsListBytes: bytes,
98
+ toolParameterCounts: counts,
99
+ promptBytes: promptScaffoldBytes(),
100
+ };
101
+ return metadataCache;
102
+ }
103
+ // Prime the cache once at startup so the first tools/list read is warm.
104
+ cachedMetadata();
105
+ // REQ-409 — enumeration verbosity, session-scoped. Lean (summary) is the
106
+ // default; a per-call `detail: true` requests full entries for a single call.
107
+ let enumerationVerbosity = "summary";
108
+ // REQ-409 — normalize the per-call detail request: absent → summary (lean
109
+ // default); explicit `true` → full entries; explicit `false` → summary.
110
+ const detailZod = { detail: z.boolean().optional() };
111
+ function wantsDetail(detail) {
112
+ return detail === true;
113
+ }
114
+ // ── Ruleset packages (REQ-389, REQ-390, REQ-379) ───────────────────
115
+ const rulesets = new RulesetManager(RULESET_DIR, HOST_VERSION);
116
+ const scanErrors = rulesets.scan();
117
+ const eagerSlugs = (process.env.TTRPG_RULESETS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
118
+ for (const slug of eagerSlugs) {
119
+ if (rulesets.isInstalled(slug)) {
120
+ try {
121
+ rulesets.hydrate(slug);
122
+ }
123
+ catch { /* hydration failures surfaced at call time */ }
124
+ }
125
+ }
126
+ // Convert a JSON-Schema-style inputSchema (as shipped by a package's tools.json)
127
+ // into a Zod raw shape registerTool accepts (REQ-389 — schemas-as-data).
128
+ function jsonSchemaToZod(schema) {
129
+ if (!schema || typeof schema !== "object")
130
+ return z.any();
131
+ const t = schema.type;
132
+ if (t === "string") {
133
+ if (Array.isArray(schema.enum))
134
+ return z.enum(schema.enum);
135
+ return z.string();
136
+ }
137
+ if (t === "number" || t === "integer")
138
+ return z.number();
139
+ if (t === "boolean")
140
+ return z.boolean();
141
+ if (t === "array")
142
+ return z.array(jsonSchemaToZod(schema.items ?? {}));
143
+ if (t === "object") {
144
+ const shape = {};
145
+ const requiredSet = new Set(Array.isArray(schema.required) ? schema.required : []);
146
+ for (const [k, v] of Object.entries(schema.properties ?? {})) {
147
+ let zs = jsonSchemaToZod(v);
148
+ if (requiredSet.has(k)) {
149
+ if (zs && typeof zs === "object" && zs._def?.typeName === "ZodOptional") {
150
+ zs = zs.unwrap();
151
+ }
152
+ }
153
+ else if (!zs || typeof zs !== "object" || zs._def?.typeName !== "ZodOptional") {
154
+ zs = zs.optional();
155
+ }
156
+ shape[k] = zs;
157
+ }
158
+ return z.object(shape);
159
+ }
160
+ if (schema.anyOf) {
161
+ const nonNull = schema.anyOf.filter((s) => s?.type !== "null");
162
+ if (nonNull.length === 1)
163
+ return jsonSchemaToZod(nonNull[0]).optional();
164
+ return z.any();
165
+ }
166
+ return z.any();
167
+ }
168
+ // Generic ruleset tool handlers. Tools are expressed as data (REQ-389); the
169
+ // host dispatches on the tool's `kind` so a package's tools serve without the
170
+ // host re-parsing source Markdown.
171
+ function gatedRulesetTool(slug, schema) {
172
+ return async (args) => {
173
+ if (!rulesets.isInstalled(slug)) {
174
+ return err("STATE_CONFLICT", `Ruleset '${slug}' is not installed.`);
175
+ }
176
+ if (!rulesets.isHydrated(slug)) {
177
+ return err("STATE_CONFLICT", `Ruleset '${slug}' is installed but not activated. Open a campaign bound to '${slug}', or set TTRPG_RULESETS=${slug} for eager hydration.`);
178
+ }
179
+ const pkg = rulesets.hydrate(slug);
180
+ switch (schema.kind) {
181
+ case "lookup": {
182
+ const collection = schema.collection ?? "concepts";
183
+ const key = String(args.key ?? args.name ?? "").toLowerCase();
184
+ const coll = (pkg.model[collection] ?? {});
185
+ if (coll && key in coll) {
186
+ return raw(JSON.stringify(coll[key], null, 2));
187
+ }
188
+ const hits = rulesets.search(slug, key || args.key || "", 5);
189
+ if (hits.length === 0)
190
+ return err("NOT_FOUND", `No '${args.key}' found in ${collection}.`);
191
+ return raw(JSON.stringify(hits, null, 2));
192
+ }
193
+ case "search": {
194
+ const q = String(args.query ?? "");
195
+ const hits = rulesets.search(slug, q, args.max_results ?? 10);
196
+ if (hits.length === 0)
197
+ return err("NOT_FOUND", `No ruleset entry matches '${q}'.`);
198
+ return raw(JSON.stringify(hits, null, 2));
199
+ }
200
+ case "roll": {
201
+ try {
202
+ const notation = String(args.dice ?? args.notation ?? "1d20");
203
+ const label = args.skill ? `${args.skill} check` : (args.notation ?? args.dice ?? "1d20");
204
+ let r = rollDice(notation, args.seed);
205
+ const extra = Number(args.modifier ?? 0);
206
+ if (extra !== 0) {
207
+ r = { total: r.total + extra, dice: r.dice, modifier: r.modifier + extra, notation: r.notation };
208
+ }
209
+ const parts = [`${label} (${r.notation}${extra !== 0 ? ` ${extra > 0 ? "+" : "-"} ${Math.abs(extra)}` : ""})`];
210
+ parts.push(`**${r.total}**`);
211
+ if (r.dice.length > 1)
212
+ parts.push(`(${r.dice.join(" + ")})`);
213
+ return ok(parts.join(" → "));
214
+ }
215
+ catch (e) {
216
+ return err("INVALID_INPUT", e.message);
217
+ }
218
+ }
219
+ case "table": {
220
+ const collection = schema.collection ?? "tables";
221
+ const tables = (pkg.model[collection] ?? {});
222
+ const name = String(args.table ?? args.name ?? "");
223
+ const table = name ? tables[name] ?? tables[name.toLowerCase()] : undefined;
224
+ if (!table)
225
+ return err("NOT_FOUND", `No table '${name}' found.`);
226
+ if (Array.isArray(table)) {
227
+ const rng = args.seed ? createRng(args.seed) : null;
228
+ const idx = rng ? rng.roll(table.length) - 1 : sessionRoll(table.length) - 1;
229
+ const row = table[Math.max(0, Math.min(idx, table.length - 1))];
230
+ return raw(JSON.stringify(row, null, 2));
231
+ }
232
+ return raw(JSON.stringify(table, null, 2));
233
+ }
234
+ case "info":
235
+ return raw(String(schema.description ?? ""));
236
+ default:
237
+ return err("UNIMPLEMENTED", `Ruleset tool kind '${schema.kind}' is not supported by this host.`);
238
+ }
239
+ };
240
+ }
241
+ // Register ruleset-prefixed tools for every installed package (REQ-379).
242
+ for (const slug of rulesets.installedSlugs()) {
243
+ for (const schema of rulesets.toolSchemas(slug)) {
244
+ const toolName = `${slug}_${schema.name}`;
245
+ try {
246
+ server.registerTool(toolName, {
247
+ title: schema.title ?? schema.name,
248
+ description: `${schema.description ?? ""} (ruleset: ${slug})`,
249
+ inputSchema: jsonSchemaToZod(schema.inputSchema),
250
+ }, gatedRulesetTool(slug, schema));
251
+ }
252
+ catch (e) {
253
+ // Tool name already registered or schema unrecoverable — skip.
254
+ }
255
+ }
256
+ }
257
+ function audit(tool, args, prefix) {
258
+ const novel = state.activeNovel;
259
+ if (novel)
260
+ state.audit(novel, getBadge(), tool, args, prefix);
261
+ }
262
+ function getActiveEntity() {
263
+ return state.getActiveEntity();
264
+ }
265
+ function resolveEntity(id) {
266
+ return state.resolveEntity(id);
267
+ }
268
+ function ok(text) {
269
+ return { content: [{ type: "text", text: `[OK] ${expandMacros(text, buildMacroContext())}` }] };
270
+ }
271
+ const CORRECTIVE_ACTIONS = {
272
+ NOT_FOUND: "Check the name or id for typos, or list valid values with help.",
273
+ INVALID_INPUT: "Supply a valid value for every required parameter.",
274
+ STATE_CONFLICT: "Resolve the conflicting state before retrying.",
275
+ FORBIDDEN: "Switch badges with set_badge to gain access, or use a permitted tool.",
276
+ RULE_VIOLATION: "Choose an action the rules permit.",
277
+ AMBIGUOUS: "Disambiguate by supplying the full name.",
278
+ UNIMPLEMENTED: "This operation is not supported by the current ruleset host.",
279
+ };
280
+ function err(code, msg, correctiveAction) {
281
+ const expanded = expandMacros(msg, buildMacroContext());
282
+ const action = correctiveAction ?? CORRECTIVE_ACTIONS[code];
283
+ const text = action ? `[ERROR] [${code}] ${expanded}\nCorrective action: ${action}` : `[ERROR] [${code}] ${expanded}`;
284
+ return { content: [{ type: "text", text }] };
285
+ }
286
+ function raw(text) {
287
+ return { content: [{ type: "text", text: expandMacros(text, buildMacroContext()) }] };
288
+ }
289
+ function warn(text) {
290
+ return { content: [{ type: "text", text: `[WARNING] ${expandMacros(text, buildMacroContext())}` }] };
291
+ }
292
+ function needInput(text) {
293
+ return { content: [{ type: "text", text: `[NEED_INPUT] ${expandMacros(text, buildMacroContext())}` }] };
294
+ }
295
+ function buildMacroContext() {
296
+ const novel = state.activeNovel;
297
+ const entity = state.getActiveEntity();
298
+ const countdowns = {};
299
+ if (novel) {
300
+ for (const [name, cd] of novel.countdowns) {
301
+ countdowns[name] = { remaining: cd.ticks, total: cd.total, scope: cd.scope, direction: cd.direction };
302
+ }
303
+ }
304
+ return {
305
+ entityName: entity?.name,
306
+ sceneCurrent: novel?.scene_description,
307
+ sceneLocation: novel?.scene_location,
308
+ sceneTimeOfDay: novel?.scene_time_of_day,
309
+ sceneAtmosphere: novel?.scene_atmosphere,
310
+ sceneType: novel?.scene_type?.join(", "),
311
+ countdowns,
312
+ novelSlug: novel?.slug,
313
+ badgeActive: novel?.badge ?? undefined,
314
+ partySize: novel ? novel.entities.size : undefined,
315
+ currentRoom: entity?.current_room ?? undefined,
316
+ worldRoomCount: novel ? novel.world.rooms.size : undefined,
317
+ worldThingCount: novel ? novel.world.things.size : undefined,
318
+ };
319
+ }
320
+ function worldSnapshot() {
321
+ novelSnapshot();
322
+ }
323
+ // ── Help Categories ─────────────────────────────────────────────────
324
+ const BUILDER_CATEGORIES = {
325
+ "Badge & Workflow": ["set_badge", "respond", "undo", "redo", "help"],
326
+ "Characters": ["create_character", "import_character", "stage_character", "character_sheet", "set_active_entity", "set_personality", "set_voice_examples", "player_signal", "remove_entity", "list_roster_characters", "remove_roster_character"],
327
+ "World Model": ["command", "resolve_intent", "create_room", "remove_room", "create_thing", "remove_thing", "create_exit", "remove_exit", "convert_source"],
328
+ "Lookups": ["search_rules", "suggest_actions", "spec_health"],
329
+ "Combat (GM)": ["init_combat", "advance_combat", "end_combat", "add_combat_participant", "remove_combat_participant"],
330
+ "Conditions (GM)": ["apply_condition", "remove_condition"],
331
+ "Narrative (GM)": ["set_scene_state", "set_scene_type", "set_narrative_directive"],
332
+ "NPCs (GM)": ["create_npc", "update_npc", "remove_npc"],
333
+ "Factions (GM)": ["create_faction", "update_faction", "remove_faction"],
334
+ "Secrets (GM)": ["set_secret", "reveal_secret", "get_knowledge"],
335
+ "Relationships (GM)": ["set_relationship", "get_relationships"],
336
+ "Vows (GM)": ["set_vow", "mark_milestone", "resolve_vow", "forsake_vow"],
337
+ "Countdowns (GM)": ["set_countdown", "advance_countdown", "remove_countdown"],
338
+ "Lore (GM)": ["set_lore_entry", "update_lore_entry", "remove_lore_entry", "toggle_lore_entry", "set_lore_group", "suggest_lore", "export_lorebook", "import_lorebook"],
339
+ "Story Journal (GM)": ["record_story", "update_story", "remove_story", "list_stories"],
340
+ "Notes": ["set_note", "remove_note", "list_notes"],
341
+ "Server Notes (GM)": ["set_server_note", "remove_server_note", "list_server_notes"],
342
+ "Pause/Resume (GM)": ["set_pause_context", "get_pause_context"],
343
+ "Checkpoints (GM)": ["set_checkpoint", "list_checkpoints", "restore_checkpoint", "remove_checkpoint"],
344
+ "Guidance (GM)": ["set_briefing_order", "compact_audit_log", "load_adventure", "generate_adventure", "generate_encounter", "set_help_category", "toggle_action_patterns", "present_choices"],
345
+ "Autonomy (GM)": ["set_autonomy"],
346
+ "Oracle": ["ask_oracle"],
347
+ "Session": ["session_recap"],
348
+ "Novel Lifecycle": ["create_novel", "resume_novel", "switch_novel", "end_novel", "export_novel", "import_novel", "rename_novel", "list_novels", "novel_info", "clone_novel"],
349
+ "Synthesis (GM)": ["revert_synthesis"],
350
+ };
351
+ const GMToolsSet = new Set([
352
+ "init_combat", "advance_combat", "end_combat", "add_combat_participant", "remove_combat_participant",
353
+ "set_scene_state", "set_scene_type", "set_narrative_directive",
354
+ "create_npc", "update_npc", "remove_npc",
355
+ "set_countdown", "advance_countdown", "remove_countdown",
356
+ "set_lore_entry", "update_lore_entry", "remove_lore_entry", "toggle_lore_entry", "set_lore_group",
357
+ "suggest_lore", "export_lorebook", "import_lorebook",
358
+ "set_briefing_order", "compact_audit_log", "load_adventure", "generate_adventure", "generate_encounter",
359
+ "set_help_category", "export_novel", "import_novel", "revert_synthesis",
360
+ "create_room", "remove_room", "create_thing", "remove_thing", "create_exit", "remove_exit", "convert_source",
361
+ "apply_condition", "remove_condition",
362
+ "create_faction", "update_faction", "remove_faction",
363
+ "set_secret", "reveal_secret", "get_knowledge",
364
+ "set_relationship", "get_relationships",
365
+ "set_vow", "mark_milestone", "resolve_vow", "forsake_vow",
366
+ "set_checkpoint", "list_checkpoints", "restore_checkpoint", "remove_checkpoint",
367
+ "set_server_note", "remove_server_note", "list_server_notes",
368
+ "set_pause_context", "get_pause_context",
369
+ "record_story", "update_story", "remove_story", "list_stories",
370
+ "present_choices", "toggle_action_patterns",
371
+ "set_autonomy",
372
+ "rename_novel", "list_novels", "novel_info", "clone_novel",
373
+ "remove_entity", "remove_roster_character", "list_roster_characters",
374
+ ]);
375
+ function isGMTool(name) {
376
+ return GMToolsSet.has(name);
377
+ }
378
+ function buildExampleInvocation(name, schema) {
379
+ if (!schema || typeof schema !== "object")
380
+ return `${name}()`;
381
+ const shape = schema._def?.typeName === "ZodObject" ? schema._def.shape() : schema;
382
+ if (!shape)
383
+ return `${name}()`;
384
+ const entries = Object.entries(shape);
385
+ const required = entries.filter(([, v]) => !v.isOptional?.() && !v._def?.typeName?.startsWith("ZodOptional"));
386
+ if (required.length === 0 && entries.length > 0) {
387
+ const [key] = entries[0];
388
+ return `${name}({ ${key}: ${illustrate(key, entries[0][1])} })`;
389
+ }
390
+ if (required.length === 0)
391
+ return `${name}()`;
392
+ const args = required.map(([k, v]) => `${k}: ${illustrate(k, v)}`).join(", ");
393
+ return `${name}({ ${args} })`;
394
+ }
395
+ function illustrate(key, schema) {
396
+ const typeName = schema._def?.typeName ?? "";
397
+ if (typeName === "ZodString")
398
+ return key === "name" ? '"example"' : `"<${key}>"`;
399
+ if (typeName === "ZodNumber")
400
+ return "1";
401
+ if (typeName === "ZodBoolean")
402
+ return "true";
403
+ if (typeName === "ZodEnum") {
404
+ const values = schema._def?.values ?? [];
405
+ if (values.length > 0)
406
+ return `"${values[0]}"`;
407
+ return `"<${key}>"`;
408
+ }
409
+ if (typeName === "ZodArray")
410
+ return "[]";
411
+ return `"<${key}>"`;
412
+ }
413
+ function fmtEntitySheet(entity) {
414
+ const p = entity.personality ?? {};
415
+ let sheet = `## ${entity.name}\n`;
416
+ if (entity.inventory?.length > 0) {
417
+ sheet += `**Inventory:** ${entity.inventory.join(", ")}\n`;
418
+ }
419
+ if (entity.current_room) {
420
+ sheet += `**Location:** ${entity.current_room}\n`;
421
+ }
422
+ if (entity.stats) {
423
+ sheet += `\n### Mechanical Stats\n${fmtStats(entity.stats)}\n`;
424
+ }
425
+ if (p.description || p.voice || p.background || p.goals) {
426
+ sheet += `\n### Personality\n`;
427
+ if (p.description)
428
+ sheet += `**Description:** ${p.description}\n`;
429
+ if (p.voice)
430
+ sheet += `**Voice:** ${p.voice}\n`;
431
+ if (p.background)
432
+ sheet += `**Background:** ${p.background}\n`;
433
+ if (p.goals)
434
+ sheet += `**Goals:** ${p.goals}\n`;
435
+ }
436
+ if (!entity.stats) {
437
+ sheet += `\n_World-model only — no mechanical stats._`;
438
+ }
439
+ return sheet;
440
+ }
441
+ function fmtStats(stats) {
442
+ const lines = [];
443
+ if (stats.class)
444
+ lines.push(`**Class:** ${stats.class}`);
445
+ if (stats.level != null)
446
+ lines.push(`**Level:** ${stats.level}`);
447
+ if (stats.species)
448
+ lines.push(`**Species:** ${stats.species}`);
449
+ if (stats.abilityScores) {
450
+ const ab = stats.abilityScores;
451
+ const mod = (s) => (Math.floor((s - 10) / 2) >= 0 ? `+${Math.floor((s - 10) / 2)}` : `${Math.floor((s - 10) / 2)}`);
452
+ const entries = Object.keys(ab).map((k) => `${k} ${ab[k]} (${mod(ab[k])})`);
453
+ lines.push(`**Abilities:** ${entries.join(" · ")}`);
454
+ }
455
+ // Ruleset-declared derived statistics, rendered generically under their
456
+ // declared labels (REQ-181a). Falls back to legacy field names when a
457
+ // pre-refactor entity carries legacy hard-coded stats.
458
+ const derivedLabels = stats._derived_labels?.labels ?? {};
459
+ const derivedOrder = stats._derived_labels?.order ?? [];
460
+ const rendered = new Set();
461
+ for (const key of derivedOrder) {
462
+ const label = derivedLabels[key] ?? key;
463
+ if (stats[label] == null)
464
+ continue;
465
+ rendered.add(label);
466
+ lines.push(`**${label}:** ${stats[label]}`);
467
+ }
468
+ for (const [k, v] of Object.entries(stats)) {
469
+ if (k.startsWith("_") || ["class", "level", "species", "abilityScores", "statMethod", "trainedSkills", "feats", "talents", "equipment"].includes(k))
470
+ continue;
471
+ if (typeof v !== "number")
472
+ continue;
473
+ if (rendered.has(k))
474
+ continue;
475
+ lines.push(`**${k}:** ${v}`);
476
+ }
477
+ if (stats.trainedSkills?.length)
478
+ lines.push(`**Trained Skills:** ${stats.trainedSkills.join(", ")}`);
479
+ if (stats.feats?.length)
480
+ lines.push(`**Feats:** ${stats.feats.join(", ")}`);
481
+ if (stats.talents?.length)
482
+ lines.push(`**Talents:** ${stats.talents.join(", ")}`);
483
+ if (stats.equipment?.length) {
484
+ const eq = stats.equipment.map((e) => (typeof e === "string" ? e : `${e.name}${e.quantity && e.quantity !== 1 ? ` ×${e.quantity}` : ""}`)).join(", ");
485
+ lines.push(`**Equipment:** ${eq}`);
486
+ }
487
+ return lines.join("\n");
488
+ }
489
+ function formatNpcSheet(npc) {
490
+ let s = `## ${npc.name}\n`;
491
+ if (npc.description)
492
+ s += `*${npc.description}*\n`;
493
+ if (npc.disposition)
494
+ s += `**Disposition:** ${npc.disposition}\n`;
495
+ if (npc.location)
496
+ s += `**Location:** ${npc.location}\n`;
497
+ return s;
498
+ }
499
+ // ── Tools ──────────────────────────────────────────────────────────
500
+ // --- Badge & Workflow ---
501
+ function badgeLabel(badge) {
502
+ if (badge === "none")
503
+ return "Editor";
504
+ return badge;
505
+ }
506
+ server.registerTool("set_badge", {
507
+ title: "Set Active Badge",
508
+ description: "Switch active badge: player, game_master, observer, or none (Editor). Always callable.",
509
+ inputSchema: { badge: z.enum(["player", "game_master", "observer", "none"]) },
510
+ }, async ({ badge }) => {
511
+ const novel = state.activeNovel;
512
+ if (novel) {
513
+ if (novel.pending_workflow) {
514
+ return err("STATE_CONFLICT", "A workflow decision is pending. Resolve it with respond before switching badges.");
515
+ }
516
+ novel.badge = badge;
517
+ state.saveNovel(novel);
518
+ }
519
+ if (badge === "none")
520
+ return ok("Active badge: Editor — full access");
521
+ if (badge === "observer")
522
+ return ok("Active badge: observer — read-only spectator mode");
523
+ return ok(`Active badge: ${badge}`);
524
+ });
525
+ server.registerTool("respond", {
526
+ title: "Respond to Workflow Decision",
527
+ description: "Respond to a pending workflow decision.",
528
+ inputSchema: { decision: z.string(), option: z.string() },
529
+ }, async ({ decision, option }) => {
530
+ requireNotObserver();
531
+ const novel = requireNovel();
532
+ if (option === "cancel") {
533
+ novel.pending_workflow = null;
534
+ state.saveNovel(novel);
535
+ return ok("Workflow cancelled.");
536
+ }
537
+ if (decision.toLowerCase().includes("end novel") || decision.toLowerCase().includes("end_novel")) {
538
+ const slug = state.activeNovel.slug;
539
+ const result = state.endNovel(novel, option);
540
+ if (result.removed)
541
+ return ok(`Novel '${slug}' ended.`);
542
+ return ok("End novel cancelled.");
543
+ }
544
+ if (decision.toLowerCase().includes("present_choices")) {
545
+ novel.pending_workflow = null;
546
+ state.saveNovel(novel);
547
+ return ok(`Choice '${option}' selected.`);
548
+ }
549
+ if (decision.toLowerCase().startsWith("safety_escalation:")) {
550
+ const target = decision.slice("safety_escalation:".length);
551
+ const auto = novel.autonomy;
552
+ if (option.toLowerCase() === "decline" || option.toLowerCase() === "cancel") {
553
+ novel.pending_workflow = null;
554
+ state.saveNovel(novel);
555
+ return ok(`Safety escalation declined — tier remains ${auto.safety}.`);
556
+ }
557
+ if (option.toLowerCase() !== "confirm") {
558
+ return err("INVALID_INPUT", "Respond 'confirm' to raise the safety tier, or 'decline' to keep the current tier.");
559
+ }
560
+ auto.safety = target;
561
+ if (!auto.confirmed_safety_tiers.includes(target))
562
+ auto.confirmed_safety_tiers.push(target);
563
+ novel.pending_workflow = null;
564
+ state.saveNovel(novel);
565
+ audit("set_autonomy", { safety: target, confirmed: true });
566
+ return ok(`Safety tier raised to '${target}'.`);
567
+ }
568
+ if (decision.toLowerCase().includes("character_creation")) {
569
+ const pw = novel.pending_workflow;
570
+ if (!pw || !("creation" in pw) || !pw.creation)
571
+ return err("STATE_CONFLICT", "No character-creation workflow in progress.");
572
+ const wf = pw.creation;
573
+ const steps = creationSteps(wf.rules ?? undefined);
574
+ const step = steps[wf.stepIndex] ?? "name";
575
+ const ans = String(option).trim();
576
+ switch (step) {
577
+ case "name":
578
+ wf.answers.name = ans;
579
+ break;
580
+ case "species":
581
+ wf.answers.species = ans;
582
+ break;
583
+ case "classes":
584
+ wf.answers.classLevels = parseClassLevels(ans);
585
+ break;
586
+ case "ability_scores": {
587
+ wf.answers.statMethod = "planned";
588
+ wf.answers.abilityScores = parseAbilityScores(ans, wf.rules ?? undefined);
589
+ break;
590
+ }
591
+ case "skills":
592
+ wf.answers.trainedSkills = ans.split(/[\s,]+/).filter(Boolean);
593
+ break;
594
+ case "equipment":
595
+ wf.answers.equipment = ans.split(/[\s,]+/).filter(Boolean);
596
+ break;
597
+ }
598
+ wf.stepIndex++;
599
+ if (wf.stepIndex < steps.length) {
600
+ novel.pending_workflow = { decision: "character_creation", snapshot: null, creation: wf };
601
+ state.saveNovel(novel);
602
+ return needInput(creationStepPrompt(wf));
603
+ }
604
+ novel.pending_workflow = null;
605
+ const rules = wf.rules;
606
+ if (!rules) {
607
+ // Ruleset-free workflow completed — profile-only entity.
608
+ const name = wf.answers.name ?? "Unnamed";
609
+ const species = wf.answers.species;
610
+ const entity = state.createEntity(name, undefined, species ? { species } : undefined);
611
+ state.addEntity(novel, entity);
612
+ state.saveNovel(novel);
613
+ return ok(`${fmtEntitySheet(entity)}
614
+
615
+ Character '${name}' created as ${entity.id} (profile-only — no mechanical stats).`);
616
+ }
617
+ const species = wf.answers.species ?? Object.values(rules.species ?? {})[0]?.name ?? "";
618
+ const classLevels = wf.answers.classLevels ?? [];
619
+ for (const cl of classLevels) {
620
+ if (!getClassData(rules, cl.className))
621
+ return err("INVALID_INPUT", `Unknown class '${cl.className}'.`);
622
+ }
623
+ const defaultScores = rules.default_ability_scores?.map(String).join(" ") ?? "15 14 13 12 10 8";
624
+ const abilityScores = applySpeciesAdjustments(wf.answers.abilityScores ?? parseAbilityScores(defaultScores, rules), species, rules);
625
+ const build = {
626
+ name: wf.answers.name ?? "Unnamed",
627
+ species,
628
+ classLevels,
629
+ abilityScores,
630
+ trainedSkills: wf.answers.trainedSkills ?? [],
631
+ feats: [],
632
+ talents: [],
633
+ statMethod: wf.answers.statMethod ?? "planned",
634
+ };
635
+ const stats = buildCharacterStats(build, rules);
636
+ const entity = state.createEntity(build.name, undefined, stats);
637
+ state.addEntity(novel, entity);
638
+ state.saveNovel(novel);
639
+ return ok(`${fmtEntitySheet(entity)}
640
+
641
+ Character '${build.name}' created as ${entity.id} with derived statistics.`);
642
+ }
643
+ return ok(`Responded to '${decision}' with '${option}'.`);
644
+ });
645
+ server.registerTool("undo", {
646
+ title: "Undo",
647
+ description: "Undo the most recent mutation. Restores previous snapshot.",
648
+ inputSchema: {},
649
+ }, async () => {
650
+ requireNotObserver();
651
+ const novel = requireNovel();
652
+ state.undo(novel, getBadge());
653
+ return ok("Undo successful.");
654
+ });
655
+ server.registerTool("redo", {
656
+ title: "Redo",
657
+ description: "Redo the most recently undone mutation.",
658
+ inputSchema: {},
659
+ }, async () => {
660
+ requireNotObserver();
661
+ const novel = requireNovel();
662
+ state.redo(novel, getBadge());
663
+ return ok("Redo successful.");
664
+ });
665
+ server.registerTool("help", {
666
+ title: "Help and Tool Discovery",
667
+ description: "Show available commands and tools. Accepts optional query for focused search.",
668
+ inputSchema: { query: z.string().optional() },
669
+ }, async ({ query }) => {
670
+ const badge = getBadge();
671
+ const novel = state.activeNovel;
672
+ const isGM = badge === "game_master";
673
+ if (query) {
674
+ const q = query.toLowerCase();
675
+ const registeredTools = server._registeredTools ?? {};
676
+ const toolNames = Object.keys(registeredTools).filter(t => {
677
+ if (t === "set_badge" || t === "respond" || t === "undo" || t === "redo")
678
+ return true;
679
+ if (!isGM && isGMTool(t))
680
+ return false;
681
+ // Parser is hidden from the Player badge on ruleset-bound Novels (REQ-309b).
682
+ if (!isGM && t === "command" && novel?.ruleset)
683
+ return false;
684
+ return true;
685
+ });
686
+ const matched = [];
687
+ for (const name of toolNames) {
688
+ const def = registeredTools[name];
689
+ if (!def)
690
+ continue;
691
+ const desc = typeof def.description === "string" ? def.description : "";
692
+ const title = typeof def.title === "string" ? def.title : "";
693
+ let score = 0;
694
+ if (name.toLowerCase().includes(q))
695
+ score += 3;
696
+ if (desc.toLowerCase().includes(q))
697
+ score += 2;
698
+ if (title.toLowerCase().includes(q))
699
+ score += 1;
700
+ if (score === 0)
701
+ continue;
702
+ const firstSentence = desc.split(".")[0] + (desc.includes(".") ? "." : "");
703
+ const example = buildExampleInvocation(name, def.inputSchema);
704
+ matched.push({ name, description: firstSentence, example, relevance: score });
705
+ }
706
+ if (q.includes("intro") || q.includes("start") || q.includes("begin")) {
707
+ matched.push({ name: "intro", description: "Connection introduction and getting started.", example: "Use the intro prompt", relevance: 3 });
708
+ }
709
+ if (q.includes("brief") || q.includes("badge") || q.includes("state")) {
710
+ matched.push({ name: "badge_briefing", description: "Per-badge guidance, state, and tool recommendations.", example: "Use the badge_briefing prompt", relevance: 2 });
711
+ }
712
+ matched.sort((a, b) => b.relevance - a.relevance);
713
+ const top = matched.slice(0, 5);
714
+ if (top.length === 0)
715
+ return ok("No tools match. Try `command(\"look\")` for world description.");
716
+ return raw(top.map(m => `**${m.name}** — ${m.description}\nExample: ${m.example}`).join("\n\n"));
717
+ }
718
+ const builderCategories = BUILDER_CATEGORIES;
719
+ let result = "## Inform MCP Server\n\n### Tool Categories\n\n";
720
+ for (const [cat, tools] of Object.entries(builderCategories)) {
721
+ let displayTools = [...tools];
722
+ if (!isGM) {
723
+ displayTools = tools.filter(t => !GMToolsSet.has(t) && !(t === "command" && novel?.ruleset));
724
+ }
725
+ if (displayTools.length > 0) {
726
+ result += `**${cat}:** ${displayTools.join(", ")}\n`;
727
+ }
728
+ }
729
+ result += "\nUse the intro prompt to get started, or badge_briefing for current badge guidance.";
730
+ // Add world-model hint if populated
731
+ if (novel && novel.world.rooms.size > 0) {
732
+ result += `\n\nWorld-model populated: ${novel.world.rooms.size} rooms, ${novel.world.things.size} things. Try \`command("look")\`.`;
733
+ }
734
+ else {
735
+ result += "\n\nNo world model — use `convert_source` or adventure tools to populate.";
736
+ }
737
+ return raw(result);
738
+ });
739
+ server.registerTool("set_help_category", {
740
+ title: "Set Help Category Override",
741
+ description: "Override the builder-assigned category for a tool. Game Master only. Set category to empty string or null to restore defaults.",
742
+ inputSchema: { tool_name: z.string(), category: z.string().nullable() },
743
+ }, async ({ tool_name, category }) => {
744
+ requireGM();
745
+ const novel = requireNovel();
746
+ const registeredTools = server._registeredTools ?? {};
747
+ if (!(tool_name in registeredTools)) {
748
+ const valid = Object.keys(registeredTools).join(", ");
749
+ return err("NOT_FOUND", `Tool '${tool_name}' not found. Valid: ${valid}`);
750
+ }
751
+ if (!category || category.trim() === "") {
752
+ delete novel.help_category_overrides[tool_name];
753
+ state.saveNovel(novel);
754
+ return ok(`Category override for '${tool_name}' removed.`);
755
+ }
756
+ novel.help_category_overrides[tool_name] = category.trim();
757
+ state.saveNovel(novel);
758
+ return ok(`Tool '${tool_name}' assigned to category '${category.trim()}'.`);
759
+ });
760
+ // --- Characters (ruleset-free, REQ-219; ruleset-driven REQ-104/151/152/181) ---
761
+ // Parse a class-levels spec like "Noble 5 / Jedi 2 / Crime Lord 2" or an
762
+ // array of { className, levels } objects.
763
+ function parseClassLevels(raw) {
764
+ if (Array.isArray(raw)) {
765
+ return raw.map((c) => {
766
+ const name = String(c?.class ?? c?.className ?? c?.name ?? "").trim();
767
+ const levels = Number(c?.levels ?? c?.level ?? 1) || 1;
768
+ return { className: name, levels };
769
+ }).filter((c) => c.className);
770
+ }
771
+ const out = [];
772
+ const parts = String(raw).split("/");
773
+ for (const part of parts) {
774
+ const m = part.trim().match(/^(.+?)\s+(\d+)$/);
775
+ if (m) {
776
+ out.push({ className: m[1].trim(), levels: parseInt(m[2], 10) });
777
+ }
778
+ }
779
+ return out;
780
+ }
781
+ function parseAbilityScores(raw, rules) {
782
+ const names = abilityNames(rules);
783
+ const values = typeof raw === "string"
784
+ ? String(raw).trim().split(/[\s,]+/).map(Number)
785
+ : Array.isArray(raw) ? raw.map(Number) : [];
786
+ const out = {};
787
+ for (let i = 0; i < names.length; i++) {
788
+ out[names[i]] = Number.isFinite(values[i]) ? values[i] : 10;
789
+ }
790
+ return out;
791
+ }
792
+ // Resolve the active Novel's character-creation rules, or null if the Novel is
793
+ // ruleset-free or the bound package carries no character-creation data
794
+ // (REQ-219, REQ-399c).
795
+ function getCharacterRules(novel) {
796
+ if (!novel?.ruleset)
797
+ return null;
798
+ try {
799
+ const model = rulesets.hydrate(novel.ruleset).model;
800
+ const rules = model?.character_creation;
801
+ return rules && typeof rules === "object" ? rules : null;
802
+ }
803
+ catch {
804
+ return null;
805
+ }
806
+ }
807
+ function buildCharacterStats(build, rules) {
808
+ const derived = computeDerived(build, rules);
809
+ const classLabel = build.classLevels.map((c) => `${c.className} ${c.levels}`).join(" / ");
810
+ const equipment = build.equipment?.length
811
+ ? build.equipment.map((n) => ({ name: n, quantity: 1 }))
812
+ : startingEquipmentFor(build.classLevels, rules);
813
+ const stats = {
814
+ class: classLabel,
815
+ species: build.species,
816
+ abilityScores: build.abilityScores,
817
+ level: derived.values.level ?? undefined,
818
+ statMethod: build.statMethod,
819
+ trainedSkills: build.trainedSkills,
820
+ feats: build.feats,
821
+ talents: build.talents,
822
+ equipment,
823
+ };
824
+ // Spread ruleset-declared derived statistics under their keys.
825
+ for (const key of derived.order)
826
+ stats[derived.labels[key] ?? key] = derived.values[key];
827
+ stats._derived_labels = { order: derived.order, labels: derived.labels, sections: derived.sections };
828
+ return stats;
829
+ }
830
+ server.registerTool("create_character", {
831
+ title: "Create Character",
832
+ description: "Create a character. Quick-create: pass name, species, classes, ability_scores, stat_method, skills, feats, talents. Step-by-step: call with no params to begin a guided [NEED_INPUT] workflow.",
833
+ inputSchema: {
834
+ name: z.string().optional(),
835
+ description: z.string().optional(),
836
+ voice: z.string().optional(),
837
+ background: z.string().optional(),
838
+ goals: z.string().optional(),
839
+ species: z.string().optional(),
840
+ classes: z.union([z.string(), z.array(z.object({ className: z.string(), levels: z.number().optional() }))]).optional(),
841
+ ability_scores: z.union([z.string(), z.array(z.number())]).optional(),
842
+ stat_method: z.string().optional(),
843
+ seed: z.string().optional(),
844
+ skills: z.union([z.string(), z.array(z.string())]).optional(),
845
+ feats: z.union([z.string(), z.array(z.string())]).optional(),
846
+ talents: z.union([z.string(), z.array(z.string())]).optional(),
847
+ equipment: z.union([z.string(), z.array(z.string())]).optional(),
848
+ stage_to_roster: z.boolean().optional(),
849
+ },
850
+ }, async ({ name, description, voice, background, goals, species, classes, ability_scores, stat_method, seed, skills, feats, talents, equipment, stage_to_roster }) => {
851
+ requireNotObserver();
852
+ const novel = requireNovel();
853
+ const rules = getCharacterRules(novel);
854
+ const personality = { description, voice, background, goals };
855
+ const hasPersonality = description || voice || background || goals;
856
+ if (!name) {
857
+ // Step-by-step mode: start a guided creation workflow.
858
+ if (novel.pending_workflow)
859
+ return err("STATE_CONFLICT", "A workflow decision is pending. Resolve it with respond before starting a new one.");
860
+ const workflow = { kind: "character_creation", stepIndex: 0, rules, answers: {} };
861
+ novel.pending_workflow = { decision: "character_creation", snapshot: null, creation: workflow };
862
+ state.saveNovel(novel);
863
+ return needInput(creationStepPrompt(workflow));
864
+ }
865
+ // Ruleset-free (or character-data-less) profile-only path (REQ-219a1, REQ-399c).
866
+ if (!rules) {
867
+ if (classes || ability_scores || stat_method) {
868
+ return err("INVALID_INPUT", "This Novel has no character-creation rules. Bind a ruleset whose package defines character creation to use classes or mechanical stats.");
869
+ }
870
+ const profileStats = species && !hasPersonality ? { species } : undefined;
871
+ const entity = state.createEntity(name, hasPersonality ? personality : undefined, profileStats);
872
+ state.addEntity(novel, entity);
873
+ if (stage_to_roster)
874
+ state.addToRoster(entity);
875
+ state.saveNovel(novel);
876
+ return ok(`${fmtEntitySheet(entity)}
877
+
878
+ Character '${name}' created (profile-only — no mechanical stats).${stage_to_roster ? ` Staged to roster as ${entity.id}.` : ` Entity id ${entity.id}.`}`);
879
+ }
880
+ // Quick-create mode: require species + classes.
881
+ if (!species || !classes) {
882
+ return err("INVALID_INPUT", "Quick-create requires 'species' and 'classes'. Omit 'name' to start step-by-step, or provide all creation fields.");
883
+ }
884
+ const classLevels = parseClassLevels(classes);
885
+ if (classLevels.length === 0)
886
+ return err("INVALID_INPUT", "Could not parse 'classes'. Use format 'Class 5 / Other 2'.");
887
+ for (const cl of classLevels) {
888
+ if (!getClassData(rules, cl.className))
889
+ return err("INVALID_INPUT", `Unknown class '${cl.className}'.`);
890
+ }
891
+ const speciesName = species;
892
+ const speciesData = rules.species?.[speciesName.trim().toLowerCase()];
893
+ if (rules.species && !speciesData)
894
+ return err("INVALID_INPUT", `Unknown species '${speciesName}'.`);
895
+ const method = stat_method ?? Object.keys(rules.stat_methods ?? {})[0] ?? "planned";
896
+ const rawScores = ability_scores
897
+ ? parseAbilityScores(ability_scores, rules)
898
+ : (() => {
899
+ const gen = generateAbilityScores(method, rules, seed);
900
+ const names = abilityNames(rules);
901
+ const out = {};
902
+ for (let i = 0; i < names.length; i++)
903
+ out[names[i]] = gen[i] ?? 10;
904
+ return out;
905
+ })();
906
+ const abilityScores = applySpeciesAdjustments(rawScores, speciesName, rules);
907
+ const toList = (v) => {
908
+ if (!v)
909
+ return [];
910
+ if (Array.isArray(v))
911
+ return v.map(String);
912
+ return String(v).split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
913
+ };
914
+ const build = {
915
+ name,
916
+ species: speciesName,
917
+ classLevels,
918
+ abilityScores,
919
+ trainedSkills: toList(skills),
920
+ feats: toList(feats),
921
+ talents: toList(talents),
922
+ statMethod: method,
923
+ seed,
924
+ equipment: toList(equipment),
925
+ };
926
+ const stats = buildCharacterStats(build, rules);
927
+ const entity = state.createEntity(name, hasPersonality ? personality : undefined, stats);
928
+ state.addEntity(novel, entity);
929
+ if (stage_to_roster)
930
+ state.addToRoster(entity);
931
+ state.saveNovel(novel);
932
+ const inputs = [`name=${name}`, `species=${speciesName}`, `classes=${classLevels.map((c) => `${c.className} ${c.levels}`).join("/")}`, `stat_method=${method}`];
933
+ const derived = Object.entries(stats)
934
+ .filter(([k, v]) => k !== "class" && k !== "species" && k !== "abilityScores" && k !== "statMethod" && k !== "trainedSkills" && k !== "feats" && k !== "talents" && k !== "equipment" && k !== "level" && !k.startsWith("_") && typeof v === "number")
935
+ .map(([k, v]) => `${k}=${v}`);
936
+ return ok(`${fmtEntitySheet(entity)}
937
+
938
+ Created (inputs): ${inputs.join(" · ")}
939
+ Derived: ${derived.join(" · ")}
940
+ ${stage_to_roster ? `Staged to roster as ${entity.id}.` : `Character '${name}' created as ${entity.id}.`}`);
941
+ });
942
+ server.registerTool("stage_character", {
943
+ title: "Stage Character to Roster",
944
+ description: "Stage an existing novel entity into the persistent roster for later import.",
945
+ inputSchema: { entity_id: z.string().optional() },
946
+ }, async ({ entity_id }) => {
947
+ requireNotObserver();
948
+ const novel = requireNovel();
949
+ const entity = resolveEntity(entity_id);
950
+ if (!entity)
951
+ return err("NOT_FOUND", "No entity to stage.");
952
+ const id = state.addToRoster(entity);
953
+ state.saveNovel(novel);
954
+ return ok(`Character '${entity.name}' staged to roster as ${id}.`);
955
+ });
956
+ server.registerTool("import_character", {
957
+ title: "Import Character",
958
+ description: "Import a roster character into the active novel.",
959
+ inputSchema: { roster_id: z.string() },
960
+ }, async ({ roster_id }) => {
961
+ requireNotObserver();
962
+ const novel = requireNovel();
963
+ const rosterEntity = state.roster.get(roster_id);
964
+ if (!rosterEntity)
965
+ return err("NOT_FOUND", `Roster entity '${roster_id}' not found.`);
966
+ state.addEntity(novel, { ...rosterEntity, current_room: rosterEntity.current_room ?? null, inventory: rosterEntity.inventory ?? [] });
967
+ state.saveNovel(novel);
968
+ return ok(`Character '${rosterEntity.name}' imported.`);
969
+ });
970
+ server.registerTool("character_sheet", {
971
+ title: "Character Sheet",
972
+ description: "Render a character sheet for an entity. Formats: markdown (default), ascii.",
973
+ inputSchema: {
974
+ entity_id: z.string().optional(),
975
+ format: z.enum(["markdown", "ascii"]).optional(),
976
+ },
977
+ }, async ({ entity_id, format }) => {
978
+ const entity = resolveEntity(entity_id);
979
+ if (format === "ascii") {
980
+ return raw(`[OK] ${entity.name} Room: ${entity.current_room || "(none)"} Held: ${entity.inventory?.length || 0}`);
981
+ }
982
+ return ok(fmtEntitySheet(entity));
983
+ });
984
+ server.registerTool("set_active_entity", {
985
+ title: "Set Active Entity",
986
+ description: "Set the currently active entity.",
987
+ inputSchema: { entity_id: z.string(), pov: z.enum(["character", "omniscient"]).optional() },
988
+ }, async ({ entity_id, pov }) => {
989
+ requireNotObserver();
990
+ const novel = requireNovel();
991
+ if (!novel.entities.has(entity_id))
992
+ return err("NOT_FOUND", `Entity '${entity_id}' not found.`);
993
+ novel.active_entity_id = entity_id;
994
+ if (pov !== undefined)
995
+ novel.pov_mode = pov;
996
+ const mode = novel.pov_mode;
997
+ return ok(`Active entity set to '${entity_id}'${mode === "omniscient" ? " (omniscient POV)" : ""}.`);
998
+ });
999
+ server.registerTool("set_personality", {
1000
+ title: "Set Entity or NPC Personality",
1001
+ description: "Set narrative personality fields for an entity or NPC.",
1002
+ inputSchema: {
1003
+ entity_id: z.string(),
1004
+ description: z.string().optional(),
1005
+ voice: z.string().optional(),
1006
+ background: z.string().optional(),
1007
+ goals: z.string().optional(),
1008
+ },
1009
+ }, async ({ entity_id, description, voice, background, goals }) => {
1010
+ requireNotObserver();
1011
+ const novel = requireNovel();
1012
+ let target = novel.entities.get(entity_id) ?? novel.npcs.get(entity_id);
1013
+ if (!target)
1014
+ return err("NOT_FOUND", `Entity or NPC '${entity_id}' not found.`);
1015
+ if (!target.personality)
1016
+ target.personality = {};
1017
+ if (description !== undefined)
1018
+ target.personality.description = description;
1019
+ if (voice !== undefined)
1020
+ target.personality.voice = voice;
1021
+ if (background !== undefined)
1022
+ target.personality.background = background;
1023
+ if (goals !== undefined)
1024
+ target.personality.goals = goals;
1025
+ state.saveNovel(novel);
1026
+ const setFields = [description !== undefined, voice !== undefined, background !== undefined, goals !== undefined].filter(Boolean).length;
1027
+ audit("set_personality", { entity_id, fields: setFields });
1028
+ return ok(`Personality set for '${entity_id}'.`);
1029
+ });
1030
+ server.registerTool("set_voice_examples", {
1031
+ title: "Set Voice Examples",
1032
+ description: "Set voice and dialogue examples for an entity or NPC.",
1033
+ inputSchema: {
1034
+ entity_id: z.string(),
1035
+ examples: z.array(z.object({ context: z.string(), dialogue: z.string(), tag: z.string().optional() })),
1036
+ },
1037
+ }, async ({ entity_id, examples }) => {
1038
+ requireNotObserver();
1039
+ const novel = requireNovel();
1040
+ let target = novel.entities.get(entity_id) ?? novel.npcs.get(entity_id);
1041
+ if (!target)
1042
+ return err("NOT_FOUND", `Entity or NPC '${entity_id}' not found.`);
1043
+ target.voice_examples = examples;
1044
+ state.saveNovel(novel);
1045
+ audit("set_voice_examples", { entity_id, count: examples.length });
1046
+ return ok(`Voice examples set for '${entity_id}' (${examples.length} examples).`);
1047
+ });
1048
+ server.registerTool("player_signal", {
1049
+ title: "Player Signal",
1050
+ description: "Send a narrative signal from the player to the GM.",
1051
+ inputSchema: {
1052
+ signal: z.enum(["pace", "difficulty", "tone", "focus", "boundary"]),
1053
+ value: z.string(),
1054
+ },
1055
+ }, async ({ signal, value }) => {
1056
+ requirePlayer();
1057
+ const novel = requireNovel();
1058
+ novel.player_signals[signal] = value;
1059
+ state.saveNovel(novel);
1060
+ audit("player_signal", { signal, value });
1061
+ return ok(`Signal recorded: ${signal} → ${value}`);
1062
+ });
1063
+ // ── Autonomy (REQ-306) ────────────────────────────────────────────
1064
+ server.registerTool("set_autonomy", {
1065
+ title: "Adjustable Autonomy",
1066
+ description: "Set the AI autonomy sliders for the active Novel. level: full/mechanical_prompt/manual; confirmation: auto/confirm/prompt; safety: safe/moderate/hardcore; creativity: predictable/standard/chaotic. Game Master only.",
1067
+ inputSchema: {
1068
+ level: z.enum(["full", "mechanical_prompt", "manual"]).optional(),
1069
+ confirmation: z.enum(["auto", "confirm", "prompt"]).optional(),
1070
+ safety: z.enum(["safe", "moderate", "hardcore"]).optional(),
1071
+ creativity: z.enum(["predictable", "standard", "chaotic"]).optional(),
1072
+ },
1073
+ }, async ({ level, confirmation, safety, creativity }) => {
1074
+ requireGM();
1075
+ const novel = requireNovel();
1076
+ const auto = novel.autonomy;
1077
+ // REQ-306f — escalating safety above `safe` requires confirmation, once per
1078
+ // Novel per target tier.
1079
+ if (safety && safety !== "safe" && safety !== auto.safety && !auto.confirmed_safety_tiers.includes(safety)) {
1080
+ novel.pending_workflow = { decision: `safety_escalation:${safety}`, snapshot: null };
1081
+ state.saveNovel(novel);
1082
+ const severity = safety === "hardcore"
1083
+ ? "warning: disengaging safety protocols permits permanent character death with no warnings"
1084
+ : "caution: raising safety to 'moderate' permits character death with warnings";
1085
+ return needInput(`Safety escalation advisory — ${severity}. Respond with 'confirm' to raise the safety tier, or 'decline' to leave the current tier (${auto.safety}).`);
1086
+ }
1087
+ if (level)
1088
+ auto.level = level;
1089
+ if (confirmation)
1090
+ auto.confirmation = confirmation;
1091
+ if (creativity)
1092
+ auto.creativity = creativity;
1093
+ if (safety) {
1094
+ auto.safety = safety;
1095
+ if (!auto.confirmed_safety_tiers.includes(safety))
1096
+ auto.confirmed_safety_tiers.push(safety);
1097
+ }
1098
+ state.saveNovel(novel);
1099
+ audit("set_autonomy", { level, confirmation, safety, creativity });
1100
+ const a = novel.autonomy;
1101
+ return ok(`Autonomy set — level: ${a.level}, confirmation: ${a.confirmation}, safety: ${a.safety}, creativity: ${a.creativity}`);
1102
+ });
1103
+ // --- World-Model Tools ---
1104
+ server.registerTool("command", {
1105
+ title: "Parser Command",
1106
+ description: "Execute a natural-language parser command against the world model. Use for navigation (go, n/s/e/w), inspection (look, examine), object interaction (take, drop, open, close), inventory, and wait.",
1107
+ inputSchema: { command: z.string() },
1108
+ }, async ({ command }) => {
1109
+ const novel = requireNovel();
1110
+ // Ruleset-bound Novels gate the parser to the Game Master (REQ-309); the
1111
+ // Player badge routes spatial intent through resolve_intent. Ruleset-free
1112
+ // Novels keep the parser as the primary Player surface (REQ-218, REQ-309e).
1113
+ if (novel.ruleset)
1114
+ requireGM();
1115
+ else
1116
+ requireNotObserver();
1117
+ worldSnapshot();
1118
+ const entity = state.getActiveEntity();
1119
+ if (!state.worldHasRooms(novel)) {
1120
+ return raw(`[ERROR] [STATE_CONFLICT] The world model has not been populated. Use an adventure module or \`convert_source\` to populate rooms before using parser commands.`);
1121
+ }
1122
+ // Auto-place entity in first room if no current room
1123
+ let currentRoom = entity?.current_room ?? null;
1124
+ if (!currentRoom && entity) {
1125
+ currentRoom = [...novel.world.rooms.keys()][0];
1126
+ entity.current_room = currentRoom;
1127
+ }
1128
+ const inventory = entity?.inventory ?? [];
1129
+ const ctx = { world: novel.world, currentRoom, inventory, badge: getBadge() };
1130
+ const result = dispatchCommand(command, ctx);
1131
+ // Apply side effects
1132
+ if (result.prefix === "OK") {
1133
+ const goResult = resolveGoMovement(command, ctx);
1134
+ if (goResult.newRoom && entity && goResult.result.prefix === "OK") {
1135
+ entity.current_room = goResult.newRoom;
1136
+ // Trigger lore matching the new room name
1137
+ audit("command", { command, moved_to: goResult.newRoom });
1138
+ }
1139
+ }
1140
+ // Handle take/drop side effects
1141
+ const tokens = command.trim().split(/\s+/);
1142
+ const verb = tokens[0].toLowerCase();
1143
+ if (result.prefix === "OK" && entity) {
1144
+ if (verb === "take" || verb === "get") {
1145
+ const targetThing = findMatchingThing(tokens.slice(1).join(" "), novel.world, currentRoom);
1146
+ if (targetThing && targetThing.portable && !entity.inventory.includes(targetThing.name.toLowerCase())) {
1147
+ entity.inventory.push(targetThing.name.toLowerCase());
1148
+ targetThing.location = null;
1149
+ targetThing.locationType = null;
1150
+ state.saveNovel(novel);
1151
+ audit("command", { command, took: targetThing.name });
1152
+ }
1153
+ }
1154
+ else if (verb === "drop" && tokens.length > 1) {
1155
+ const target = tokens.slice(1).join(" ").toLowerCase();
1156
+ const idx = entity.inventory.indexOf(target);
1157
+ if (idx >= 0) {
1158
+ entity.inventory.splice(idx, 1);
1159
+ // Move thing back to current room
1160
+ const thing = novel.world.things.get(target);
1161
+ if (thing) {
1162
+ thing.location = currentRoom;
1163
+ thing.locationType = "room";
1164
+ }
1165
+ state.saveNovel(novel);
1166
+ audit("command", { command, dropped: target });
1167
+ }
1168
+ }
1169
+ else if (verb === "open" && result.prefix === "OK" && tokens.length > 1) {
1170
+ const thing = novel.world.things.get(tokens.slice(1).join(" ").toLowerCase());
1171
+ if (thing && thing.openable) {
1172
+ thing.open = true;
1173
+ state.saveNovel(novel);
1174
+ audit("command", { command, opened: thing.name });
1175
+ }
1176
+ }
1177
+ else if (verb === "close" && result.prefix === "OK" && tokens.length > 1) {
1178
+ const thing = novel.world.things.get(tokens.slice(1).join(" ").toLowerCase());
1179
+ if (thing && thing.openable) {
1180
+ thing.open = false;
1181
+ state.saveNovel(novel);
1182
+ audit("command", { command, closed: thing.name });
1183
+ }
1184
+ }
1185
+ else if (verb === "unlock" && result.prefix === "OK" && tokens.length > 1) {
1186
+ const thing = novel.world.things.get(tokens.slice(1).join(" ").toLowerCase());
1187
+ if (thing && thing.lockable) {
1188
+ thing.locked = false;
1189
+ state.saveNovel(novel);
1190
+ audit("command", { command, unlocked: thing.name });
1191
+ }
1192
+ }
1193
+ else if (verb === "lock" && result.prefix === "OK" && tokens.length > 1) {
1194
+ const thing = novel.world.things.get(tokens.slice(1).join(" ").toLowerCase());
1195
+ if (thing && thing.lockable) {
1196
+ thing.locked = true;
1197
+ state.saveNovel(novel);
1198
+ audit("command", { command, locked: thing.name });
1199
+ }
1200
+ }
1201
+ }
1202
+ // Output
1203
+ const prefix = result.prefix === "OK" ? "[OK]" : result.prefix === "WARNING" ? "[WARNING]" : "[ERROR]";
1204
+ const code = result.code ? ` [${result.code}]` : "";
1205
+ let text = `${prefix}${code} ${result.text}`;
1206
+ if (result.correctiveAction) {
1207
+ text += `\nCorrective action: ${result.correctiveAction}`;
1208
+ }
1209
+ return raw(text);
1210
+ });
1211
+ function findMatchingThing(name, world, roomName) {
1212
+ const lower = name.toLowerCase().trim();
1213
+ for (const [, thing] of world.things) {
1214
+ if (thing.name.toLowerCase().includes(lower)) {
1215
+ const loc = thing.location?.toLowerCase();
1216
+ if (loc === roomName?.toLowerCase())
1217
+ return thing;
1218
+ // REQ-200: things on supporters or in open containers within the room are reachable.
1219
+ if (thing.locationType === "supporter" || thing.locationType === "container") {
1220
+ const parent = world.things.get(thing.location?.toLowerCase() ?? "");
1221
+ if (parent) {
1222
+ if (parent.location?.toLowerCase() === roomName?.toLowerCase()) {
1223
+ if (thing.locationType === "supporter" || (parent.openable && parent.open))
1224
+ return thing;
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+ }
1230
+ return null;
1231
+ }
1232
+ // --- World-Model CRUD (GM-only) ---
1233
+ server.registerTool("create_room", {
1234
+ title: "Create Room",
1235
+ description: "Create a new room in the world model. Game Master only.",
1236
+ inputSchema: {
1237
+ name: z.string(),
1238
+ description: z.string().optional(),
1239
+ },
1240
+ }, async ({ name, description }) => {
1241
+ requireGM();
1242
+ const novel = requireNovel();
1243
+ worldSnapshot();
1244
+ const lower = name.toLowerCase();
1245
+ if (novel.world.rooms.has(lower))
1246
+ return err("STATE_CONFLICT", `Room '${name}' already exists.`);
1247
+ const room = {
1248
+ name,
1249
+ description: description ?? "",
1250
+ exits: new Map(),
1251
+ doorRefs: new Map(),
1252
+ annotations: {},
1253
+ };
1254
+ novel.world.rooms.set(lower, room);
1255
+ state.saveNovel(novel);
1256
+ audit("create_room", { name });
1257
+ return ok(`Room '${name}' created.`);
1258
+ });
1259
+ server.registerTool("remove_room", {
1260
+ title: "Remove Room",
1261
+ description: "Remove a room and its contained things and exits. Game Master only.",
1262
+ inputSchema: { name: z.string() },
1263
+ }, async ({ name }) => {
1264
+ requireGM();
1265
+ const novel = requireNovel();
1266
+ worldSnapshot();
1267
+ const lower = name.toLowerCase();
1268
+ if (!novel.world.rooms.has(lower))
1269
+ return err("NOT_FOUND", `Room '${name}' not found.`);
1270
+ // Remove things in this room
1271
+ for (const [tKey, thing] of novel.world.things) {
1272
+ if (thing.location?.toLowerCase() === lower && thing.locationType === "room") {
1273
+ novel.world.things.delete(tKey);
1274
+ }
1275
+ }
1276
+ // Remove exits referencing this room
1277
+ for (const [, room] of novel.world.rooms) {
1278
+ for (const [dir, target] of room.exits) {
1279
+ if (target.toLowerCase() === lower)
1280
+ room.exits.delete(dir);
1281
+ }
1282
+ }
1283
+ novel.world.rooms.delete(lower);
1284
+ state.saveNovel(novel);
1285
+ audit("remove_room", { name });
1286
+ return ok(`Room '${name}' and its contents removed.`);
1287
+ });
1288
+ server.registerTool("create_thing", {
1289
+ title: "Create Thing",
1290
+ description: "Create a new thing in the world model. Game Master only.",
1291
+ inputSchema: {
1292
+ name: z.string(),
1293
+ kind: z.string().optional(),
1294
+ description: z.string().optional(),
1295
+ location: z.string().optional(),
1296
+ location_type: z.enum(["room", "container", "supporter"]).optional(),
1297
+ fixed: z.boolean().optional(),
1298
+ openable: z.boolean().optional(),
1299
+ lockable: z.boolean().optional(),
1300
+ },
1301
+ }, async ({ name, kind, description, location, location_type, fixed, openable, lockable }) => {
1302
+ requireGM();
1303
+ const novel = requireNovel();
1304
+ worldSnapshot();
1305
+ const lower = name.toLowerCase();
1306
+ if (novel.world.things.has(lower))
1307
+ return err("STATE_CONFLICT", `Thing '${name}' already exists.`);
1308
+ const validKinds = ["thing", "container", "supporter", "door", "device", "vehicle", "person", "backdrop", "region"];
1309
+ const k = (kind && validKinds.includes(kind.toLowerCase())) ? kind.toLowerCase() : "thing";
1310
+ // Determine containment: explicit location_type, or infer from the parent's kind.
1311
+ let locationType = location ? "room" : null;
1312
+ if (location && location_type) {
1313
+ locationType = location_type;
1314
+ }
1315
+ else if (location) {
1316
+ const parent = novel.world.things.get(location.toLowerCase());
1317
+ if (parent && (parent.kind === "container" || parent.kind === "supporter" || parent.kind === "vehicle")) {
1318
+ locationType = parent.kind === "supporter" ? "supporter" : parent.kind === "vehicle" ? "vehicle" : "container";
1319
+ }
1320
+ }
1321
+ const thing = {
1322
+ name,
1323
+ description: description ?? "",
1324
+ kind: k,
1325
+ location: location ?? null,
1326
+ locationType,
1327
+ portable: !fixed && k !== "supporter" && k !== "door" && k !== "vehicle",
1328
+ openable: k === "container" || k === "door" || openable === true,
1329
+ open: false,
1330
+ lockable: k === "container" || k === "door" || lockable === true,
1331
+ locked: false,
1332
+ lit: false,
1333
+ switchable: k === "device",
1334
+ switched_on: false,
1335
+ enterable: k === "vehicle",
1336
+ vehiclePassengers: [],
1337
+ wearable: false,
1338
+ worn_by: null,
1339
+ readable: false,
1340
+ read_text: null,
1341
+ edible: false,
1342
+ drinkable: false,
1343
+ climbable: false,
1344
+ transparent: false,
1345
+ annotations: {},
1346
+ };
1347
+ novel.world.things.set(lower, thing);
1348
+ state.saveNovel(novel);
1349
+ audit("create_thing", { name, kind: k, location, location_type: locationType });
1350
+ return ok(`Thing '${name}' (${k}) created${location ? (locationType === "supporter" ? ` on ${location}` : locationType === "container" ? ` in container ${location}` : ` in ${location}`) : ""}.`);
1351
+ });
1352
+ server.registerTool("remove_thing", {
1353
+ title: "Remove Thing",
1354
+ description: "Remove a thing from the world model. Game Master only.",
1355
+ inputSchema: { name: z.string() },
1356
+ }, async ({ name }) => {
1357
+ requireGM();
1358
+ const novel = requireNovel();
1359
+ worldSnapshot();
1360
+ const lower = name.toLowerCase();
1361
+ if (!novel.world.things.has(lower))
1362
+ return err("NOT_FOUND", `Thing '${name}' not found.`);
1363
+ novel.world.things.delete(lower);
1364
+ state.saveNovel(novel);
1365
+ audit("remove_thing", { name });
1366
+ return ok(`Thing '${name}' removed.`);
1367
+ });
1368
+ server.registerTool("create_exit", {
1369
+ title: "Create Exit",
1370
+ description: "Create a directional exit between two rooms. Reverse exit created implicitly. Game Master only.",
1371
+ inputSchema: {
1372
+ direction: z.string(),
1373
+ room_a: z.string(),
1374
+ room_b: z.string(),
1375
+ },
1376
+ }, async ({ direction, room_a, room_b }) => {
1377
+ requireGM();
1378
+ const novel = requireNovel();
1379
+ worldSnapshot();
1380
+ const dir = direction.toLowerCase();
1381
+ if (!ROOM_DIRECTIONS.includes(dir))
1382
+ return err("INVALID_INPUT", `Invalid direction '${direction}'. Valid: ${ROOM_DIRECTIONS.join(", ")}.`);
1383
+ const roomA = novel.world.rooms.get(room_a.toLowerCase());
1384
+ const roomB = novel.world.rooms.get(room_b.toLowerCase());
1385
+ if (!roomA)
1386
+ return err("NOT_FOUND", `Room '${room_a}' not found.`);
1387
+ if (!roomB)
1388
+ return err("NOT_FOUND", `Room '${room_b}' not found.`);
1389
+ roomA.exits.set(dir, room_b);
1390
+ roomB.exits.set(oppositeDirection(dir), room_a);
1391
+ state.saveNovel(novel);
1392
+ audit("create_exit", { direction: dir, room_a, room_b });
1393
+ return ok(`Exit created: ${dir} from ${room_a} to ${room_b}.`);
1394
+ });
1395
+ server.registerTool("remove_exit", {
1396
+ title: "Remove Exit",
1397
+ description: "Remove a directional exit from a room. Game Master only.",
1398
+ inputSchema: {
1399
+ direction: z.string(),
1400
+ room: z.string(),
1401
+ },
1402
+ }, async ({ direction, room: roomName }) => {
1403
+ requireGM();
1404
+ const novel = requireNovel();
1405
+ worldSnapshot();
1406
+ const dir = direction.toLowerCase();
1407
+ if (!ROOM_DIRECTIONS.includes(dir))
1408
+ return err("INVALID_INPUT", `Invalid direction.`);
1409
+ const room = novel.world.rooms.get(roomName.toLowerCase());
1410
+ if (!room)
1411
+ return err("NOT_FOUND", `Room '${roomName}' not found.`);
1412
+ if (!room.exits.has(dir))
1413
+ return err("NOT_FOUND", `No ${dir} exit from '${roomName}'.`);
1414
+ room.exits.delete(dir);
1415
+ state.saveNovel(novel);
1416
+ audit("remove_exit", { direction: dir, room: roomName });
1417
+ return ok(`Exit ${dir} from '${roomName}' removed.`);
1418
+ });
1419
+ server.registerTool("convert_source", {
1420
+ title: "Convert Source",
1421
+ description: "Parse hybrid world-model assertions and populate the Novel's world model. Game Master only. Only on an empty world model.",
1422
+ inputSchema: { source: z.string() },
1423
+ }, async ({ source }) => {
1424
+ requireGM();
1425
+ const novel = requireNovel();
1426
+ worldSnapshot();
1427
+ if (novel.world.rooms.size > 0) {
1428
+ return err("STATE_CONFLICT", "World model already populated. Use CRUD tools to modify, or create a new novel.");
1429
+ }
1430
+ const { world, result } = convertSource(source, novel.world);
1431
+ novel.world = world;
1432
+ state.saveNovel(novel);
1433
+ audit("convert_source", { rooms: result.rooms, things: result.things, exits: result.exits });
1434
+ let msg = `[OK] World model populated: ${result.rooms} rooms, ${result.things} things, ${result.exits} exits.`;
1435
+ msg += ` Linked annotations — encounters: ${result.annotations.encounters}, NPCs: ${result.annotations.npcs}, traps: ${result.annotations.traps}, lore: ${result.annotations.lore}.`;
1436
+ if (result.warnings.length > 0) {
1437
+ msg += `\n\nWarnings:`;
1438
+ for (const w of result.warnings) {
1439
+ msg += `\nLine ${w.line}: ${w.message}`;
1440
+ }
1441
+ }
1442
+ return raw(msg);
1443
+ });
1444
+ // --- resolve_intent (REQ-323, §5.12) ---
1445
+ // Resolves a spatial intent against the world model without mutating state.
1446
+ // Three phases: constraint check → override check → scene composition.
1447
+ // Callable by the AI narrator, Game Master, and Observer badges; Player [FORBIDDEN].
1448
+ function resolveIntentWorld(intent, novel) {
1449
+ const world = novel.world;
1450
+ if (world.rooms.size === 0) {
1451
+ return { status: "no_world_model" };
1452
+ }
1453
+ const entity = state.getActiveEntity();
1454
+ let currentRoom = entity?.current_room ?? null;
1455
+ if (!currentRoom)
1456
+ currentRoom = [...world.rooms.keys()][0];
1457
+ const tokens = intent.trim().toLowerCase().split(/\s+/);
1458
+ const verb = tokens[0];
1459
+ // Navigation intent resolution
1460
+ const isNav = ["go", "walk", "move", "north", "south", "east", "west", "northeast", "northwest", "southeast", "southwest", "up", "down", "in", "out"].includes(verb);
1461
+ const isLook = verb === "look" || verb === "examine" || verb === "search" || verb === "inspect";
1462
+ const room = world.rooms.get(currentRoom.toLowerCase());
1463
+ if (isNav) {
1464
+ if (!room)
1465
+ return { status: "blocked", constraint: "room", reason: `Current room '${currentRoom}' not found.` };
1466
+ let dir = tokens[0] === "go" || tokens[0] === "walk" || tokens[0] === "move" ? tokens[1] : verb;
1467
+ if (!dir || !ROOM_DIRECTIONS.includes(dir)) {
1468
+ return { status: "blocked", constraint: "direction", reason: `Direction '${dir ?? ""}' is not valid.`, available: [...room.exits.keys()] };
1469
+ }
1470
+ const direction = dir;
1471
+ // Constraint check: door blocking
1472
+ const doorName = room.doorRefs.get(direction);
1473
+ const overrideHints = [];
1474
+ if (doorName) {
1475
+ const door = world.things.get(doorName.toLowerCase());
1476
+ if (door && !door.open) {
1477
+ const constraint = door.locked ? "locked" : "closed_door";
1478
+ // Override check: scan active-entity constraint overrides (REQ-325)
1479
+ const overrides = novel.constraint_overrides ?? [];
1480
+ for (const o of overrides) {
1481
+ if (o.type === "door" && (o.name?.toLowerCase() === door.name.toLowerCase() || o.match_all)) {
1482
+ overrideHints.push(`${o.name} (${o.slots_remaining ?? "∞"} remaining) can open ${door.name}.`);
1483
+ }
1484
+ }
1485
+ if (overrideHints.length === 0) {
1486
+ return { status: "blocked", constraint, reason: `The ${door.name} is ${constraint === "locked" ? "locked" : "closed"}.` };
1487
+ }
1488
+ }
1489
+ }
1490
+ const target = room.exits.get(direction);
1491
+ if (!target) {
1492
+ return { status: "blocked", constraint: "exit", reason: `No ${direction} exit from '${currentRoom}'.`, available: [...room.exits.keys()] };
1493
+ }
1494
+ const targetRoom = world.rooms.get(target.toLowerCase());
1495
+ if (!targetRoom)
1496
+ return { status: "blocked", constraint: "room", reason: `Destination '${target}' not found.` };
1497
+ return {
1498
+ status: "resolved",
1499
+ room_context: composeRoomContext(targetRoom, novel, world),
1500
+ override_hints: overrideHints,
1501
+ };
1502
+ }
1503
+ if (isLook) {
1504
+ if (!room)
1505
+ return { status: "blocked", constraint: "room", reason: `Current room '${currentRoom}' not found.` };
1506
+ return { status: "resolved", room_context: composeRoomContext(room, novel, world) };
1507
+ }
1508
+ // Unknown intent: return the current room context so the narrator can interpret.
1509
+ if (!room)
1510
+ return { status: "blocked", constraint: "room", reason: "No current room." };
1511
+ return { status: "resolved", room_context: composeRoomContext(room, novel, world) };
1512
+ }
1513
+ function composeRoomContext(room, novel, world) {
1514
+ const visibleThings = [];
1515
+ const rl = room.name.toLowerCase();
1516
+ for (const [, t] of world.things) {
1517
+ if (!t.location)
1518
+ continue;
1519
+ if (t.location.toLowerCase() === rl && t.locationType === "room")
1520
+ visibleThings.push(t.name);
1521
+ }
1522
+ const presentNpcs = [];
1523
+ for (const [, npc] of novel.npcs) {
1524
+ if (npc.location && npc.location.toLowerCase() === rl)
1525
+ presentNpcs.push(npc.name);
1526
+ }
1527
+ return {
1528
+ name: room.name,
1529
+ description: room.description || "",
1530
+ exits: [...room.exits.entries()].map(([d, t]) => ({ direction: d, target: t })),
1531
+ things: visibleThings,
1532
+ present_npcs: presentNpcs,
1533
+ };
1534
+ }
1535
+ server.registerTool("resolve_intent", {
1536
+ title: "Resolve Intent",
1537
+ description: "Resolve a natural-language spatial intent against the world model without mutating state. Use when: a player or the AI narrator needs to determine the outcome of a movement or inspection against the world model. Do NOT use when: you are the Game Master inspecting the model directly — use the parser command tool for that.",
1538
+ inputSchema: { intent: z.string() },
1539
+ }, async ({ intent }) => {
1540
+ const badge = getBadge();
1541
+ if (badge === "player") {
1542
+ return err("FORBIDDEN", "resolve_intent is not callable by the Player badge. Player spatial intents are resolved by the AI narrator. Corrective action: switch badge or direct intents through the narrator.");
1543
+ }
1544
+ requireNotObserver();
1545
+ const novel = requireNovel();
1546
+ const result = resolveIntentWorld(intent, novel);
1547
+ return raw(JSON.stringify(result, null, 2));
1548
+ });
1549
+ // --- Combat (GM, auto-advance in ruleset-free mode) ---
1550
+ server.registerTool("init_combat", {
1551
+ title: "Initiate Combat",
1552
+ description: "Start a combat encounter. Game Master only. In ruleset-free mode, all participants auto-advance.",
1553
+ inputSchema: {
1554
+ participants: z.array(z.string()),
1555
+ dangers: z.array(z.object({ name: z.string(), ac: z.number().optional(), hp: z.number().optional(), initiative_bonus: z.number().optional() })).optional(),
1556
+ seed: z.string().optional(),
1557
+ },
1558
+ }, async ({ participants, dangers, seed }) => {
1559
+ requireGM();
1560
+ const novel = requireNovel();
1561
+ novelSnapshot();
1562
+ const combat = state.initCombat(novel, participants, dangers ?? [], seed);
1563
+ state.saveNovel(novel);
1564
+ return ok(`Combat started. Round ${combat.round}, ${combat.turn_order.length} participants. Turn: ${combat.turn_order[0]} (auto-advance mode).`);
1565
+ });
1566
+ server.registerTool("advance_combat", {
1567
+ title: "Advance Combat",
1568
+ description: "Advance to the next turn in combat. Game Master only.",
1569
+ inputSchema: {},
1570
+ }, async () => {
1571
+ requireGM();
1572
+ const novel = requireNovel();
1573
+ novelSnapshot();
1574
+ const combat = state.advanceCombat(novel);
1575
+ state.saveNovel(novel);
1576
+ const currentName = combat.turn_order[combat.current_turn];
1577
+ return ok(`Turn: ${currentName} — Round ${combat.round}, Turn ${combat.current_turn + 1}/${combat.turn_order.length}. [AUTO]`);
1578
+ });
1579
+ server.registerTool("end_combat", {
1580
+ title: "End Combat",
1581
+ description: "End the active combat encounter. Game Master only.",
1582
+ inputSchema: { outcome: z.string().optional() },
1583
+ }, async ({ outcome }) => {
1584
+ requireGM();
1585
+ const novel = requireNovel();
1586
+ novelSnapshot();
1587
+ const rounds = novel.combat?.round ?? 0;
1588
+ state.endCombat(novel, outcome ?? "Combat ended.");
1589
+ state.saveNovel(novel);
1590
+ return ok(`Combat ended after ${rounds} rounds${outcome ? `. Outcome: ${outcome}` : "."}`);
1591
+ });
1592
+ server.registerTool("add_combat_participant", {
1593
+ title: "Add Combat Participant",
1594
+ description: "Add a participant to active combat. Game Master only.",
1595
+ inputSchema: { participant_id: z.string() },
1596
+ }, async ({ participant_id }) => {
1597
+ requireGM();
1598
+ const novel = requireNovel();
1599
+ novelSnapshot();
1600
+ const combat = state.addCombatParticipant(novel, participant_id);
1601
+ state.saveNovel(novel);
1602
+ return ok(`'${participant_id}' added to combat.`);
1603
+ });
1604
+ server.registerTool("remove_combat_participant", {
1605
+ title: "Remove Combat Participant",
1606
+ description: "Remove a participant from active combat. Game Master only.",
1607
+ inputSchema: { participant_id: z.string() },
1608
+ }, async ({ participant_id }) => {
1609
+ requireGM();
1610
+ const novel = requireNovel();
1611
+ novelSnapshot();
1612
+ const result = state.removeCombatParticipant(novel, participant_id);
1613
+ state.saveNovel(novel);
1614
+ if (result.ended)
1615
+ return ok("Combat ended — all participants removed.");
1616
+ return ok(`'${participant_id}' removed from combat.`);
1617
+ });
1618
+ // --- Narrative (GM) ---
1619
+ server.registerTool("set_scene_state", {
1620
+ title: "Set Scene State",
1621
+ description: "Set the scene description and location. Game Master only.",
1622
+ inputSchema: {
1623
+ description: z.string(),
1624
+ location: z.string().optional(),
1625
+ time_of_day: z.string().optional(),
1626
+ atmosphere: z.string().optional(),
1627
+ skip_transition_hook: z.boolean().optional(),
1628
+ },
1629
+ }, async ({ description, location, time_of_day, atmosphere, skip_transition_hook }) => {
1630
+ requireGM();
1631
+ const novel = requireNovel();
1632
+ novelSnapshot();
1633
+ if (novel.scene_description) {
1634
+ novel.scene_history.push({
1635
+ timestamp: new Date().toISOString(),
1636
+ description: novel.scene_description,
1637
+ location: novel.scene_location,
1638
+ time_of_day: novel.scene_time_of_day,
1639
+ atmosphere: novel.scene_atmosphere,
1640
+ });
1641
+ }
1642
+ novel.scene_description = description;
1643
+ novel.scene_location = location;
1644
+ novel.scene_time_of_day = time_of_day;
1645
+ novel.scene_atmosphere = atmosphere;
1646
+ // Auto-update active entity position if location or description matches a world-model room
1647
+ const sceneRoomName = location || description;
1648
+ if (sceneRoomName) {
1649
+ const matchRoom = [...novel.world.rooms.entries()].find(([, r]) => sceneRoomName.toLowerCase().startsWith(r.name.toLowerCase()));
1650
+ if (matchRoom) {
1651
+ const entity = state.getActiveEntity();
1652
+ if (entity) {
1653
+ entity.current_room = matchRoom[1].name;
1654
+ }
1655
+ }
1656
+ }
1657
+ state.saveNovel(novel);
1658
+ audit("set_scene_state", { description, location, time_of_day, atmosphere });
1659
+ return ok(`Scene set: ${description}`);
1660
+ });
1661
+ server.registerTool("set_scene_type", {
1662
+ title: "Set Scene Type",
1663
+ description: "Tag the scene as combat, social, exploration, or neutral. Game Master only.",
1664
+ inputSchema: {
1665
+ type: z.union([z.enum(["combat", "social", "exploration", "neutral"]), z.array(z.enum(["combat", "social", "exploration", "neutral"]))]),
1666
+ },
1667
+ }, async ({ type }) => {
1668
+ requireGM();
1669
+ const novel = requireNovel();
1670
+ novelSnapshot();
1671
+ novel.scene_type = Array.isArray(type) ? type : [type];
1672
+ state.saveNovel(novel);
1673
+ return ok(`Scene type set to: ${novel.scene_type.join(", ")}.`);
1674
+ });
1675
+ server.registerTool("set_narrative_directive", {
1676
+ title: "Set Narrative Directive",
1677
+ description: "Set overarching narrative directive for the current scene. Game Master only.",
1678
+ inputSchema: { directive: z.string() },
1679
+ }, async ({ directive }) => {
1680
+ requireGM();
1681
+ const novel = requireNovel();
1682
+ novelSnapshot();
1683
+ novel.narrative_directive = directive;
1684
+ state.saveNovel(novel);
1685
+ return ok(`Narrative directive set.`);
1686
+ });
1687
+ // --- NPCs (GM) ---
1688
+ server.registerTool("create_npc", {
1689
+ title: "Create NPC",
1690
+ description: "Create a named NPC with optional description and narrative fields. Game Master only.",
1691
+ inputSchema: {
1692
+ name: z.string(),
1693
+ description: z.string().optional(),
1694
+ disposition: z.string().optional(),
1695
+ location: z.string().optional(),
1696
+ },
1697
+ }, async ({ name, description, disposition, location }) => {
1698
+ requireGM();
1699
+ const novel = requireNovel();
1700
+ novelSnapshot();
1701
+ const id = `npc_${Date.now().toString(36)}`;
1702
+ novel.npcs.set(id, { id, name, description, disposition, location, conditions: [], condition_rounds: {} });
1703
+ state.saveNovel(novel);
1704
+ audit("create_npc", { name, id });
1705
+ return ok(`NPC '${name}' created (${id}).`);
1706
+ });
1707
+ server.registerTool("update_npc", {
1708
+ title: "Update NPC",
1709
+ description: "Update an existing NPC's fields. Game Master only.",
1710
+ inputSchema: {
1711
+ npc_id: z.string(),
1712
+ name: z.string().optional(),
1713
+ description: z.string().optional(),
1714
+ disposition: z.string().optional(),
1715
+ location: z.string().optional(),
1716
+ },
1717
+ }, async ({ npc_id, name, description, disposition, location }) => {
1718
+ requireGM();
1719
+ const novel = requireNovel();
1720
+ novelSnapshot();
1721
+ const npc = novel.npcs.get(npc_id);
1722
+ if (!npc)
1723
+ return err("NOT_FOUND", `NPC '${npc_id}' not found.`);
1724
+ if (name !== undefined)
1725
+ npc.name = name;
1726
+ if (description !== undefined)
1727
+ npc.description = description;
1728
+ if (disposition !== undefined)
1729
+ npc.disposition = disposition;
1730
+ if (location !== undefined)
1731
+ npc.location = location;
1732
+ state.saveNovel(novel);
1733
+ audit("update_npc", { npc_id });
1734
+ return ok(`NPC '${npc_id}' updated.`);
1735
+ });
1736
+ server.registerTool("remove_npc", {
1737
+ title: "Remove NPC",
1738
+ description: "Remove an NPC from the novel. Game Master only.",
1739
+ inputSchema: { npc_id: z.string() },
1740
+ }, async ({ npc_id }) => {
1741
+ requireGM();
1742
+ const novel = requireNovel();
1743
+ novelSnapshot();
1744
+ if (!novel.npcs.has(npc_id))
1745
+ return err("NOT_FOUND", `NPC '${npc_id}' not found.`);
1746
+ novel.npcs.delete(npc_id);
1747
+ state.saveNovel(novel);
1748
+ audit("remove_npc", { npc_id });
1749
+ return ok(`NPC '${npc_id}' removed.`);
1750
+ });
1751
+ // --- Countdowns (GM) ---
1752
+ server.registerTool("set_countdown", {
1753
+ title: "Set Countdown",
1754
+ description: "Set a countdown timer. Game Master only.",
1755
+ inputSchema: {
1756
+ name: z.string(),
1757
+ ticks: z.number().min(1),
1758
+ type: z.enum(["round", "narrative"]).optional(),
1759
+ scope: z.string().optional(),
1760
+ direction: z.string().optional(),
1761
+ },
1762
+ }, async ({ name, ticks, type, scope, direction }) => {
1763
+ requireGM();
1764
+ const novel = requireNovel();
1765
+ novelSnapshot();
1766
+ novel.countdowns.set(name, { name, ticks, total: ticks, type: type ?? "narrative", scope, direction });
1767
+ state.saveNovel(novel);
1768
+ audit("set_countdown", { name, ticks, type });
1769
+ return ok(`Countdown '${name}' set (${ticks} ticks, ${type ?? "narrative"}).`);
1770
+ });
1771
+ server.registerTool("advance_countdown", {
1772
+ title: "Advance Countdown",
1773
+ description: "Advance a countdown timer by one tick. Game Master only.",
1774
+ inputSchema: { name: z.string() },
1775
+ }, async ({ name }) => {
1776
+ requireGM();
1777
+ const novel = requireNovel();
1778
+ novelSnapshot();
1779
+ const cd = novel.countdowns.get(name);
1780
+ if (!cd)
1781
+ return err("NOT_FOUND", `Countdown '${name}' not found.`);
1782
+ cd.ticks--;
1783
+ if (cd.ticks <= 0) {
1784
+ novel.countdowns.delete(name);
1785
+ audit("countdown_expired", { name });
1786
+ state.saveNovel(novel);
1787
+ return ok(`Countdown '${name}' expired. Recorded in audit log.`);
1788
+ }
1789
+ state.saveNovel(novel);
1790
+ audit("advance_countdown", { name, remaining: cd.ticks });
1791
+ return ok(`Countdown ${name}: ${cd.ticks} ticks remaining.`);
1792
+ });
1793
+ server.registerTool("remove_countdown", {
1794
+ title: "Remove Countdown",
1795
+ description: "Remove a countdown timer. Game Master only.",
1796
+ inputSchema: { name: z.string() },
1797
+ }, async ({ name }) => {
1798
+ requireGM();
1799
+ const novel = requireNovel();
1800
+ novelSnapshot();
1801
+ if (!novel.countdowns.has(name))
1802
+ return err("NOT_FOUND", `Countdown '${name}' not found.`);
1803
+ novel.countdowns.delete(name);
1804
+ state.saveNovel(novel);
1805
+ audit("remove_countdown", { name });
1806
+ return ok(`Countdown '${name}' removed.`);
1807
+ });
1808
+ // --- Lore (GM) ---
1809
+ server.registerTool("set_lore_entry", {
1810
+ title: "Set Lore Entry",
1811
+ description: "Log a lore entry for the current novel. Game Master only.",
1812
+ inputSchema: {
1813
+ key: z.string(),
1814
+ content: z.string(),
1815
+ triggers: z.array(z.string()).optional(),
1816
+ badge_scope: z.enum(["game_master", "shared"]).optional(),
1817
+ priority: z.number().optional(),
1818
+ sticky: z.number().optional(),
1819
+ group: z.string().optional(),
1820
+ },
1821
+ }, async ({ key, content, triggers, badge_scope, priority, sticky, group }) => {
1822
+ requireGM();
1823
+ const novel = requireNovel();
1824
+ novelSnapshot();
1825
+ const entry = {
1826
+ key,
1827
+ content,
1828
+ triggers: triggers ?? [],
1829
+ badge_scope: badge_scope ?? "game_master",
1830
+ priority: priority ?? 0,
1831
+ sticky: sticky ?? 0,
1832
+ sticky_remaining: sticky ?? 0,
1833
+ enabled: true,
1834
+ group,
1835
+ };
1836
+ novel.lore.set(key, entry);
1837
+ state.saveNovel(novel);
1838
+ audit("set_lore_entry", { key });
1839
+ return ok(`Lore entry '${key}' created.`);
1840
+ });
1841
+ server.registerTool("update_lore_entry", {
1842
+ title: "Update Lore Entry",
1843
+ description: "Update fields of an existing lore entry. Game Master only.",
1844
+ inputSchema: {
1845
+ key: z.string(),
1846
+ content: z.string().optional(),
1847
+ triggers: z.array(z.string()).optional(),
1848
+ badge_scope: z.enum(["game_master", "shared"]).optional(),
1849
+ priority: z.number().optional(),
1850
+ sticky: z.number().optional(),
1851
+ group: z.string().nullable().optional(),
1852
+ },
1853
+ }, async ({ key, content, triggers, badge_scope, priority, sticky, group }) => {
1854
+ requireGM();
1855
+ const novel = requireNovel();
1856
+ novelSnapshot();
1857
+ const entry = novel.lore.get(key);
1858
+ if (!entry)
1859
+ return err("NOT_FOUND", `Lore entry '${key}' not found.`);
1860
+ if (content !== undefined)
1861
+ entry.content = content;
1862
+ if (triggers !== undefined)
1863
+ entry.triggers = triggers;
1864
+ if (badge_scope !== undefined)
1865
+ entry.badge_scope = badge_scope;
1866
+ if (priority !== undefined)
1867
+ entry.priority = priority;
1868
+ if (sticky !== undefined) {
1869
+ entry.sticky = sticky;
1870
+ entry.sticky_remaining = sticky;
1871
+ }
1872
+ if (group !== undefined) {
1873
+ if (group === null)
1874
+ delete entry.group;
1875
+ else
1876
+ entry.group = group;
1877
+ }
1878
+ state.saveNovel(novel);
1879
+ return ok(`Lore entry '${key}' updated.`);
1880
+ });
1881
+ server.registerTool("remove_lore_entry", {
1882
+ title: "Remove Lore Entry",
1883
+ description: "Remove a lore entry. Game Master only.",
1884
+ inputSchema: { key: z.string() },
1885
+ }, async ({ key }) => {
1886
+ requireGM();
1887
+ const novel = requireNovel();
1888
+ novelSnapshot();
1889
+ if (!novel.lore.has(key))
1890
+ return err("NOT_FOUND", `Lore entry '${key}' not found.`);
1891
+ novel.lore.delete(key);
1892
+ state.saveNovel(novel);
1893
+ return ok(`Lore entry '${key}' removed.`);
1894
+ });
1895
+ server.registerTool("toggle_lore_entry", {
1896
+ title: "Toggle Lore Entry",
1897
+ description: "Enable or disable a lore entry. Game Master only.",
1898
+ inputSchema: { key: z.string() },
1899
+ }, async ({ key }) => {
1900
+ requireGM();
1901
+ const novel = requireNovel();
1902
+ novelSnapshot();
1903
+ const entry = novel.lore.get(key);
1904
+ if (!entry)
1905
+ return err("NOT_FOUND", `Lore entry '${key}' not found.`);
1906
+ entry.enabled = !entry.enabled;
1907
+ state.saveNovel(novel);
1908
+ return ok(`Lore entry '${key}' ${entry.enabled ? "enabled" : "disabled"}.`);
1909
+ });
1910
+ server.registerTool("set_lore_group", {
1911
+ title: "Set Lore Group",
1912
+ description: "Assign or remove a lore entry from a named group. Game Master only.",
1913
+ inputSchema: { key: z.string(), group: z.string().nullable() },
1914
+ }, async ({ key, group }) => {
1915
+ requireGM();
1916
+ const novel = requireNovel();
1917
+ novelSnapshot();
1918
+ const entry = novel.lore.get(key);
1919
+ if (!entry)
1920
+ return err("NOT_FOUND", `Lore entry '${key}' not found.`);
1921
+ if (group === null || group === undefined)
1922
+ delete entry.group;
1923
+ else
1924
+ entry.group = group;
1925
+ state.saveNovel(novel);
1926
+ return ok(`Lore entry '${key}' group ${group ? `set to '${group}'` : "removed"}.`);
1927
+ });
1928
+ server.registerTool("suggest_lore", {
1929
+ title: "Suggest Lore",
1930
+ description: "Suggest lore entries from enrichment templates based on current scene. Game Master only.",
1931
+ inputSchema: {},
1932
+ }, async () => {
1933
+ requireGM();
1934
+ const novel = requireNovel();
1935
+ const templates = state.enrichmentManifest?.lore_templates ?? [];
1936
+ if (templates.length === 0)
1937
+ return ok("No lore templates available (enrichment not loaded).");
1938
+ const sample = templates.slice(0, 3).map((t) => `- ${t.content?.substring(0, 120)}${(t.content?.length ?? 0) > 120 ? "..." : ""}`);
1939
+ return raw(sample.join("\n"));
1940
+ });
1941
+ server.registerTool("export_lorebook", {
1942
+ title: "Export Lorebook",
1943
+ description: "Export novel lore entries in interchange format. Game Master only.",
1944
+ inputSchema: { format: z.enum(["json", "markdown"]).optional() },
1945
+ }, async ({ format: fmt }) => {
1946
+ requireGM();
1947
+ const novel = requireNovel();
1948
+ const entries = [...novel.lore.values()];
1949
+ if (fmt === "markdown") {
1950
+ let md = "# Lorebook\n\n";
1951
+ for (const e of entries) {
1952
+ md += `## ${e.key}\n${e.content}\n_triggers: ${e.triggers.join(", ")}_\n\n`;
1953
+ }
1954
+ return raw(md);
1955
+ }
1956
+ return raw(JSON.stringify(entries.map(e => ({
1957
+ key: e.key, content: e.content, triggers: e.triggers,
1958
+ badge_scope: e.badge_scope, priority: e.priority, sticky: e.sticky,
1959
+ enabled: e.enabled, group: e.group,
1960
+ })), null, 2));
1961
+ });
1962
+ server.registerTool("import_lorebook", {
1963
+ title: "Import Lorebook",
1964
+ description: "Import lore entries from JSON or Markdown. Modes: dry-run, merge, or replace. Game Master only.",
1965
+ inputSchema: {
1966
+ data: z.string(),
1967
+ mode: z.enum(["dry-run", "merge", "replace"]).optional(),
1968
+ },
1969
+ }, async ({ data, mode }) => {
1970
+ requireGM();
1971
+ const novel = requireNovel();
1972
+ const m = mode ?? "dry-run";
1973
+ try {
1974
+ const parsed = JSON.parse(data);
1975
+ if (!Array.isArray(parsed))
1976
+ return err("INVALID_INPUT", "Expected an array of lore entries.");
1977
+ if (m === "dry-run") {
1978
+ return ok(`Dry-run: ${parsed.length} lore entries would be imported.`);
1979
+ }
1980
+ if (m === "replace")
1981
+ novel.lore.clear();
1982
+ for (const e of parsed) {
1983
+ novel.lore.set(e.key, {
1984
+ key: e.key, content: e.content, triggers: e.triggers ?? [],
1985
+ badge_scope: e.badge_scope ?? "game_master", priority: e.priority ?? 0,
1986
+ sticky: e.sticky ?? 0, sticky_remaining: e.sticky ?? 0,
1987
+ enabled: e.enabled ?? true, group: e.group,
1988
+ });
1989
+ }
1990
+ state.saveNovel(novel);
1991
+ return ok(`Imported ${parsed.length} lore entries (${m} mode).`);
1992
+ }
1993
+ catch {
1994
+ return err("INVALID_INPUT", "Could not parse lorebook data. Provide valid JSON array.");
1995
+ }
1996
+ });
1997
+ // --- Conditions (GM) ---
1998
+ server.registerTool("apply_condition", {
1999
+ title: "Apply Condition",
2000
+ description: "Apply a condition to an entity.",
2001
+ inputSchema: { entity_id: z.string(), condition: z.string(), rounds: z.number().optional() },
2002
+ }, async ({ entity_id, condition, rounds }) => {
2003
+ requireGM();
2004
+ const novel = requireNovel();
2005
+ novelSnapshot();
2006
+ const entity = novel.entities.get(entity_id) ?? novel.npcs.get(entity_id);
2007
+ if (!entity)
2008
+ return err("NOT_FOUND", `Entity '${entity_id}' not found.`);
2009
+ if (!entity.conditions)
2010
+ entity.conditions = [];
2011
+ if (!entity.condition_rounds)
2012
+ entity.condition_rounds = {};
2013
+ if (!entity.conditions.includes(condition))
2014
+ entity.conditions.push(condition);
2015
+ if (rounds)
2016
+ entity.condition_rounds[condition] = rounds;
2017
+ state.saveNovel(novel);
2018
+ return ok(`'${condition}' applied to '${entity_id}'${rounds ? ` for ${rounds} rounds` : ""}.`);
2019
+ });
2020
+ server.registerTool("remove_condition", {
2021
+ title: "Remove Condition",
2022
+ description: "Remove a condition from an entity.",
2023
+ inputSchema: { entity_id: z.string(), condition: z.string() },
2024
+ }, async ({ entity_id, condition }) => {
2025
+ requireGM();
2026
+ const novel = requireNovel();
2027
+ novelSnapshot();
2028
+ const entity = novel.entities.get(entity_id) ?? novel.npcs.get(entity_id);
2029
+ if (!entity)
2030
+ return err("NOT_FOUND", `Entity '${entity_id}' not found.`);
2031
+ if (!entity.conditions)
2032
+ return ok(`Entity '${entity_id}' has no conditions.`);
2033
+ entity.conditions = entity.conditions.filter((c) => c !== condition);
2034
+ delete entity.condition_rounds[condition];
2035
+ state.saveNovel(novel);
2036
+ return ok(`'${condition}' removed from '${entity_id}'.`);
2037
+ });
2038
+ // --- Factions (GM) ---
2039
+ server.registerTool("create_faction", {
2040
+ title: "Create Faction",
2041
+ description: "Create a named faction with goals, resources, and a progress clock. Game Master only.",
2042
+ inputSchema: { name: z.string(), description: z.string().optional(), goals: z.array(z.string()).optional(), resources: z.string().optional() },
2043
+ }, async ({ name, description, goals, resources }) => {
2044
+ requireGM();
2045
+ const novel = requireNovel();
2046
+ if (novel.factions.some(f => f.name === name))
2047
+ return err("STATE_CONFLICT", `Faction '${name}' already exists.`);
2048
+ const faction = {
2049
+ id: `faction_${Date.now().toString(36)}`,
2050
+ name, description: description ?? "", goals: goals ?? [], resources: resources ?? "", clock: 0, clock_max: 10, status: "neutral",
2051
+ };
2052
+ novel.factions.push(faction);
2053
+ state.saveNovel(novel);
2054
+ audit("create_faction", { name, description, goals });
2055
+ return ok(`Faction '${name}' created (${faction.id}).`);
2056
+ });
2057
+ server.registerTool("update_faction", {
2058
+ title: "Update Faction",
2059
+ description: "Update a faction's fields. Game Master only.",
2060
+ inputSchema: { faction_id: z.string(), description: z.string().optional(), goals: z.array(z.string()).optional(), resources: z.string().optional() },
2061
+ }, async ({ faction_id, ...fields }) => {
2062
+ requireGM();
2063
+ const novel = requireNovel();
2064
+ const faction = novel.factions.find(f => f.id === faction_id);
2065
+ if (!faction)
2066
+ return err("NOT_FOUND", `Faction '${faction_id}' not found.`);
2067
+ Object.assign(faction, fields);
2068
+ state.saveNovel(novel);
2069
+ return ok(`Faction '${faction.name}' updated.`);
2070
+ });
2071
+ server.registerTool("remove_faction", {
2072
+ title: "Remove Faction",
2073
+ description: "Remove a faction and its clock. Game Master only.",
2074
+ inputSchema: { faction_id: z.string() },
2075
+ }, async ({ faction_id }) => {
2076
+ requireGM();
2077
+ const novel = requireNovel();
2078
+ const idx = novel.factions.findIndex(f => f.id === faction_id);
2079
+ if (idx === -1)
2080
+ return err("NOT_FOUND", `Faction '${faction_id}' not found.`);
2081
+ const name = novel.factions[idx].name;
2082
+ novel.factions.splice(idx, 1);
2083
+ state.saveNovel(novel);
2084
+ audit("remove_faction", { faction_id });
2085
+ return ok(`Faction '${name}' removed.`);
2086
+ });
2087
+ // --- Secrets (GM) ---
2088
+ server.registerTool("set_secret", {
2089
+ title: "Set Secret",
2090
+ description: "Create a secret lore entry. GM-only; visible to entities after reveal_secret. Game Master only.",
2091
+ inputSchema: { key: z.string(), content: z.string(), triggers: z.array(z.string()).optional(), badge_scope: z.enum(["game_master", "shared"]).optional() },
2092
+ }, async ({ key, content, triggers, badge_scope }) => {
2093
+ requireGM();
2094
+ const novel = requireNovel();
2095
+ if (novel.secrets.some(s => s.key === key))
2096
+ return err("STATE_CONFLICT", `Secret '${key}' already exists.`);
2097
+ novel.secrets.push({ key, content, triggers: triggers ?? [], badge_scope: badge_scope ?? "game_master", known_by: [] });
2098
+ state.saveNovel(novel);
2099
+ return ok(`Secret '${key}' created.`);
2100
+ });
2101
+ server.registerTool("reveal_secret", {
2102
+ title: "Reveal Secret",
2103
+ description: "Make a secret known to a specific entity. Game Master only.",
2104
+ inputSchema: { key: z.string(), entity_id: z.string() },
2105
+ }, async ({ key, entity_id }) => {
2106
+ requireGM();
2107
+ const novel = requireNovel();
2108
+ const secret = novel.secrets.find(s => s.key === key);
2109
+ if (!secret)
2110
+ return err("NOT_FOUND", `Secret '${key}' not found.`);
2111
+ if (!novel.entities.has(entity_id))
2112
+ return err("NOT_FOUND", `Entity '${entity_id}' not found.`);
2113
+ if (!secret.known_by.includes(entity_id))
2114
+ secret.known_by.push(entity_id);
2115
+ state.saveNovel(novel);
2116
+ return ok(`Secret '${key}' revealed to '${entity_id}'.`);
2117
+ });
2118
+ server.registerTool("get_knowledge", {
2119
+ title: "Get Knowledge",
2120
+ description: "Return what secrets an entity knows. Game Master only.",
2121
+ inputSchema: { entity_id: z.string(), key: z.string().optional() },
2122
+ }, async ({ entity_id, key }) => {
2123
+ requireGM();
2124
+ const novel = requireNovel();
2125
+ if (!novel.entities.has(entity_id))
2126
+ return err("NOT_FOUND", `Entity '${entity_id}' not found.`);
2127
+ const known = novel.secrets.filter(s => s.known_by.includes(entity_id));
2128
+ if (key) {
2129
+ const s = known.find(s => s.key === key);
2130
+ return raw(JSON.stringify(s ?? { key, known: false }));
2131
+ }
2132
+ return raw(JSON.stringify(known.map(s => ({ key: s.key, content: s.content }))));
2133
+ });
2134
+ // --- Relationships (GM) ---
2135
+ server.registerTool("set_relationship", {
2136
+ title: "Set Relationship",
2137
+ description: "Set a directed relationship between entities, NPCs, or factions. Types: ally, rival, neutral, mentor, dependent, suspicious. Game Master only.",
2138
+ inputSchema: { entity_a: z.string(), entity_b: z.string(), type: z.enum(["ally", "rival", "neutral", "mentor", "dependent", "suspicious"]), value: z.number().optional(), description: z.string().optional() },
2139
+ }, async ({ entity_a, entity_b, type, value, description }) => {
2140
+ requireGM();
2141
+ const novel = requireNovel();
2142
+ novel.relationships.push({ entity_a, entity_b, type, value, description });
2143
+ state.saveNovel(novel);
2144
+ return ok(`Relationship set: ${entity_a} -> ${entity_b} (${type}).`);
2145
+ });
2146
+ server.registerTool("get_relationships", {
2147
+ title: "Get Relationships",
2148
+ description: "Return all relationships (incoming and outgoing) for an entity. Game Master only.",
2149
+ inputSchema: { entity_id: z.string() },
2150
+ }, async ({ entity_id }) => {
2151
+ requireGM();
2152
+ const novel = requireNovel();
2153
+ const outgoing = novel.relationships.filter(r => r.entity_a === entity_id);
2154
+ const incoming = novel.relationships.filter(r => r.entity_b === entity_id);
2155
+ return raw(JSON.stringify({ outgoing, incoming }, null, 2));
2156
+ });
2157
+ // --- Vows (GM) ---
2158
+ server.registerTool("set_vow", {
2159
+ title: "Set Vow",
2160
+ description: "Track a narrative vow, quest, or obligation. Game Master only.",
2161
+ inputSchema: { name: z.string(), description: z.string(), parties: z.array(z.string()), difficulty: z.enum(["troublesome", "dangerous", "formidable", "extreme", "epic"]), scope: z.enum(["gm", "shared", "faction", "party"]).optional() },
2162
+ }, async ({ name, description, parties, difficulty, scope }) => {
2163
+ requireGM();
2164
+ const novel = requireNovel();
2165
+ if (novel.vows.some(v => v.name === name))
2166
+ return err("STATE_CONFLICT", `Vow '${name}' already exists.`);
2167
+ novel.vows.push({
2168
+ name, description, parties, difficulty: difficulty, scope: scope ?? "shared",
2169
+ milestones: 0, rank_track: DIFFICULTY_TRACKS[difficulty] ?? 10, state: "active",
2170
+ });
2171
+ state.saveNovel(novel);
2172
+ return ok(`Vow '${name}' set (${difficulty}, ${DIFFICULTY_TRACKS[difficulty]} milestones).`);
2173
+ });
2174
+ server.registerTool("mark_milestone", {
2175
+ title: "Mark Milestone",
2176
+ description: "Advance a vow's progress by one milestone. Game Master only.",
2177
+ inputSchema: { vow_name: z.string() },
2178
+ }, async ({ vow_name }) => {
2179
+ requireGM();
2180
+ const novel = requireNovel();
2181
+ const vow = novel.vows.find(v => v.name === vow_name);
2182
+ if (!vow)
2183
+ return err("NOT_FOUND", `Vow '${vow_name}' not found.`);
2184
+ vow.milestones++;
2185
+ state.saveNovel(novel);
2186
+ return ok(`Vow '${vow_name}' progress: ${vow.milestones}/${vow.rank_track} milestones.`);
2187
+ });
2188
+ server.registerTool("resolve_vow", {
2189
+ title: "Resolve Vow",
2190
+ description: "Close a completed vow with outcome and consequences. Game Master only.",
2191
+ inputSchema: { vow_name: z.string(), outcome: z.string(), consequences: z.string().optional() },
2192
+ }, async ({ vow_name, outcome, consequences }) => {
2193
+ requireGM();
2194
+ const novel = requireNovel();
2195
+ const vow = novel.vows.find(v => v.name === vow_name);
2196
+ if (!vow)
2197
+ return err("NOT_FOUND", `Vow '${vow_name}' not found.`);
2198
+ vow.state = "resolved";
2199
+ vow.outcome = outcome;
2200
+ vow.consequences = consequences;
2201
+ state.saveNovel(novel);
2202
+ return ok(`Vow '${vow_name}' resolved.`);
2203
+ });
2204
+ server.registerTool("forsake_vow", {
2205
+ title: "Forsake Vow",
2206
+ description: "Abandon a vow with a reason. Game Master only.",
2207
+ inputSchema: { vow_name: z.string(), reason: z.string() },
2208
+ }, async ({ vow_name, reason }) => {
2209
+ requireGM();
2210
+ const novel = requireNovel();
2211
+ const vow = novel.vows.find(v => v.name === vow_name);
2212
+ if (!vow)
2213
+ return err("NOT_FOUND", `Vow '${vow_name}' not found.`);
2214
+ vow.state = "forsaken";
2215
+ vow.reason = reason;
2216
+ state.saveNovel(novel);
2217
+ return ok(`Vow '${vow_name}' forsaken.`);
2218
+ });
2219
+ // --- Story Journal (GM) ---
2220
+ server.registerTool("record_story", {
2221
+ title: "Record Story",
2222
+ description: "Record a narrative memory in the story journal. Types: decision, moment, revelation, bond, consequence. Game Master only.",
2223
+ inputSchema: { type: z.enum(["decision", "moment", "revelation", "bond", "consequence"]), entry: z.string() },
2224
+ }, async ({ type, entry }) => {
2225
+ requireGM();
2226
+ const novel = requireNovel();
2227
+ const index = novel.story_journal.length;
2228
+ novel.story_journal.push({
2229
+ index, type, entry,
2230
+ scene_anchor: novel.scene_description?.substring(0, 80) ?? "",
2231
+ entity_ids: [],
2232
+ timestamp: new Date().toISOString(),
2233
+ });
2234
+ state.saveNovel(novel);
2235
+ return ok(`Story entry #${index} recorded (${type}).`);
2236
+ });
2237
+ server.registerTool("update_story", {
2238
+ title: "Update Story",
2239
+ description: "Edit a story journal entry by index. Decision and consequence entries are immutable. Game Master only.",
2240
+ inputSchema: { index: z.number().min(0), type: z.enum(["decision", "moment", "revelation", "bond", "consequence"]).optional(), entry: z.string().optional() },
2241
+ }, async ({ index, type, entry }) => {
2242
+ requireGM();
2243
+ const novel = requireNovel();
2244
+ if (index >= novel.story_journal.length)
2245
+ return err("NOT_FOUND", `Story entry #${index} not found.`);
2246
+ const story = novel.story_journal[index];
2247
+ if (story.type === "decision" || story.type === "consequence")
2248
+ return err("STATE_CONFLICT", `${story.type} entries are immutable.`);
2249
+ if (type)
2250
+ story.type = type;
2251
+ if (entry)
2252
+ story.entry = entry;
2253
+ state.saveNovel(novel);
2254
+ return ok(`Story entry #${index} updated.`);
2255
+ });
2256
+ server.registerTool("remove_story", {
2257
+ title: "Remove Story",
2258
+ description: "Delete a story journal entry by index. Game Master only.",
2259
+ inputSchema: { index: z.number().min(0) },
2260
+ }, async ({ index }) => {
2261
+ requireGM();
2262
+ const novel = requireNovel();
2263
+ if (index >= novel.story_journal.length)
2264
+ return err("NOT_FOUND", `Story entry #${index} not found.`);
2265
+ novel.story_journal.splice(index, 1);
2266
+ state.saveNovel(novel);
2267
+ return ok(`Story entry #${index} removed.`);
2268
+ });
2269
+ server.registerTool("list_stories", {
2270
+ title: "List Stories",
2271
+ description: "List story journal entries with optional type filter and pagination. Game Master only.",
2272
+ inputSchema: { filter: z.enum(["decision", "moment", "revelation", "bond", "consequence"]).optional(), offset: z.number().min(0).optional(), limit: z.number().min(0).optional(), ...detailZod },
2273
+ }, async ({ filter, offset, limit, detail }) => {
2274
+ requireGM();
2275
+ const novel = requireNovel();
2276
+ let entries = [...novel.story_journal];
2277
+ if (filter)
2278
+ entries = entries.filter(e => e.type === filter);
2279
+ if (offset)
2280
+ entries = entries.slice(offset);
2281
+ if (limit)
2282
+ entries = entries.slice(0, limit);
2283
+ if (wantsDetail(detail))
2284
+ return raw(JSON.stringify(entries, null, 2));
2285
+ return raw(JSON.stringify(entries.map(e => ({ type: e.type, timestamp: e.timestamp, preview: (e.entry ?? "").substring(0, 120) })), null, 2));
2286
+ });
2287
+ // --- Notes ---
2288
+ server.registerTool("set_note", {
2289
+ title: "Set Note",
2290
+ description: "Create or update a key-value note. Badge-scoped: game_master (default), player, or shared.",
2291
+ inputSchema: { key: z.string(), content: z.string(), badge_scope: z.enum(["game_master", "player", "shared"]).optional() },
2292
+ }, async ({ key, content, badge_scope }) => {
2293
+ requireNotObserver();
2294
+ const novel = requireNovel();
2295
+ const existing = novel.notes.find(n => n.key === key);
2296
+ // Default scope is game_master (REQ-242); the Player badge cannot write GM-scoped notes.
2297
+ const scope = badge_scope ?? "game_master";
2298
+ if (novel.badge === "player" && scope === "game_master") {
2299
+ return err("FORBIDDEN", "The Player badge cannot write a game_master-scoped note.");
2300
+ }
2301
+ if (existing) {
2302
+ if (novel.badge === "player" && existing.badge_scope === "game_master") {
2303
+ return err("FORBIDDEN", "The Player badge cannot modify a game_master-scoped note.");
2304
+ }
2305
+ existing.content = content;
2306
+ existing.badge_scope = scope;
2307
+ }
2308
+ else {
2309
+ novel.notes.push({ key, content, badge_scope: scope });
2310
+ }
2311
+ state.saveNovel(novel);
2312
+ return ok(`Note '${key}' set.`);
2313
+ });
2314
+ server.registerTool("remove_note", {
2315
+ title: "Remove Note",
2316
+ description: "Remove a note by key. Badge-scoped: caller's badge must own the scope.",
2317
+ inputSchema: { key: z.string() },
2318
+ }, async ({ key }) => {
2319
+ requireNotObserver();
2320
+ const novel = requireNovel();
2321
+ const idx = novel.notes.findIndex(n => n.key === key);
2322
+ if (idx === -1)
2323
+ return err("NOT_FOUND", `Note '${key}' not found.`);
2324
+ const note = novel.notes[idx];
2325
+ if (novel.badge === "player" && note.badge_scope === "game_master") {
2326
+ return err("FORBIDDEN", "The Player badge cannot remove a game_master-scoped note.");
2327
+ }
2328
+ novel.notes.splice(idx, 1);
2329
+ state.saveNovel(novel);
2330
+ return ok(`Note '${key}' removed.`);
2331
+ });
2332
+ server.registerTool("list_notes", {
2333
+ title: "List Notes",
2334
+ description: "List all notes (100-character preview), badge-filtered.",
2335
+ inputSchema: {},
2336
+ }, async () => {
2337
+ const novel = requireNovel();
2338
+ const badge = novel.badge;
2339
+ const filtered = badge === "game_master" || badge === "none" ? novel.notes
2340
+ : novel.notes.filter(n => n.badge_scope !== "game_master");
2341
+ return raw(JSON.stringify(filtered.map(n => ({ key: n.key, badge_scope: n.badge_scope, preview: n.content.substring(0, 100) })), null, 2));
2342
+ });
2343
+ // --- Server Notes (GM) ---
2344
+ server.registerTool("set_server_note", {
2345
+ title: "Set Server Note",
2346
+ description: "Create or update a server-level note. Game Master only.",
2347
+ inputSchema: { key: z.string(), content: z.string() },
2348
+ }, async ({ key, content }) => {
2349
+ requireGM();
2350
+ state.serverNotes.set(key, content);
2351
+ state.saveServerNotes();
2352
+ return ok(`Server note '${key}' set.`);
2353
+ });
2354
+ server.registerTool("remove_server_note", {
2355
+ title: "Remove Server Note",
2356
+ description: "Remove a server-level note. Game Master only.",
2357
+ inputSchema: { key: z.string() },
2358
+ }, async ({ key }) => {
2359
+ requireGM();
2360
+ if (!state.serverNotes.has(key))
2361
+ return err("NOT_FOUND", `Server note '${key}' not found.`);
2362
+ state.serverNotes.delete(key);
2363
+ state.saveServerNotes();
2364
+ return ok(`Server note '${key}' removed.`);
2365
+ });
2366
+ server.registerTool("list_server_notes", {
2367
+ title: "List Server Notes",
2368
+ description: "List all server-level notes. Game Master only.",
2369
+ inputSchema: {},
2370
+ }, async () => {
2371
+ requireGM();
2372
+ const notes = Object.fromEntries(state.serverNotes);
2373
+ return raw(JSON.stringify(notes, null, 2));
2374
+ });
2375
+ // --- Pause/Resume (GM) ---
2376
+ server.registerTool("set_pause_context", {
2377
+ title: "Set Pause Context",
2378
+ description: "Save GM context for session resumption. Game Master only.",
2379
+ inputSchema: { current_scene: z.string().optional(), immediate_situation: z.string().optional(), pending_player_action: z.string().optional(), short_term_plans: z.string().optional(), long_term_plans: z.string().optional(), player_goals: z.string().optional() },
2380
+ }, async (fields) => {
2381
+ requireGM();
2382
+ const novel = requireNovel();
2383
+ // Auto-capture derived context (REQ-232): faction clocks, countdown positions,
2384
+ // NPC dispositions, relationships, recent story entries, and active vows.
2385
+ const f = {
2386
+ ...fields,
2387
+ faction_clocks: novel.factions.map(x => ({ name: x.name, clock: x.clock, clock_max: x.clock_max, status: x.status })),
2388
+ countdown_positions: [...novel.countdowns.entries()].map(([name, cd]) => ({ name, ticks: cd.ticks, total: cd.total })),
2389
+ npc_dispositions: [...novel.npcs.values()].map(n => ({ name: n.name, disposition: n.disposition, location: n.location })),
2390
+ relationships: novel.relationships,
2391
+ story_context: novel.story_journal.slice(-3).map(s => s.entry),
2392
+ active_vows: novel.vows.filter(v => v.state === "active").map(v => ({ name: v.name, difficulty: v.difficulty, milestone_count: v.milestones })),
2393
+ };
2394
+ novel.gm_context = { ...novel.gm_context, ...f, saved_at: new Date().toISOString() };
2395
+ state.saveNovel(novel);
2396
+ return ok("Pause context saved.");
2397
+ });
2398
+ server.registerTool("get_pause_context", {
2399
+ title: "Get Pause Context",
2400
+ description: "Return the saved GM context plus Novel state summary for session resumption.",
2401
+ inputSchema: {},
2402
+ }, async () => {
2403
+ const novel = requireNovel();
2404
+ return raw(JSON.stringify({
2405
+ gm_context: novel.gm_context,
2406
+ novel_slug: novel.slug,
2407
+ scene: novel.scene_description,
2408
+ world_rooms: novel.world.rooms.size,
2409
+ npcs: novel.npcs.size,
2410
+ active_vows: novel.vows.filter(v => v.state === "active"),
2411
+ }, null, 2));
2412
+ });
2413
+ // --- Checkpoints (GM) ---
2414
+ server.registerTool("set_checkpoint", {
2415
+ title: "Set Checkpoint",
2416
+ description: "Save a named checkpoint of the full Novel state. Game Master only.",
2417
+ inputSchema: { label: z.string() },
2418
+ }, async ({ label }) => {
2419
+ requireGM();
2420
+ const novel = requireNovel();
2421
+ novel.checkpoints.push({ label, timestamp: new Date().toISOString(), state: JSON.parse(JSON.stringify(novelToJSONState(novel))) });
2422
+ state.saveNovel(novel);
2423
+ return ok(`Checkpoint '${label}' saved.`);
2424
+ });
2425
+ server.registerTool("list_checkpoints", {
2426
+ title: "List Checkpoints",
2427
+ description: "List all checkpoints for the active Novel. Game Master only.",
2428
+ inputSchema: {},
2429
+ }, async () => {
2430
+ requireGM();
2431
+ const novel = requireNovel();
2432
+ return raw(JSON.stringify(novel.checkpoints.map(c => ({ label: c.label, timestamp: c.timestamp })), null, 2));
2433
+ });
2434
+ server.registerTool("restore_checkpoint", {
2435
+ title: "Restore Checkpoint",
2436
+ description: "Restore a checkpoint (confirmation required). Game Master only.",
2437
+ inputSchema: { label: z.string() },
2438
+ }, async ({ label }) => {
2439
+ requireGM();
2440
+ const novel = requireNovel();
2441
+ const cp = novel.checkpoints.find(c => c.label === label);
2442
+ if (!cp)
2443
+ return err("NOT_FOUND", `Checkpoint '${label}' not found.`);
2444
+ const restored = loadNovelFromStateData(cp.state);
2445
+ novel.entities = restored.entities;
2446
+ novel.npcs = restored.npcs;
2447
+ novel.scene_description = restored.scene_description;
2448
+ novel.scene_location = restored.scene_location;
2449
+ novel.combat = restored.combat;
2450
+ novel.countdowns = restored.countdowns;
2451
+ novel.lore = restored.lore;
2452
+ novel.world = restored.world;
2453
+ novel.story_journal = restored.story_journal;
2454
+ novel.factions = restored.factions;
2455
+ novel.secrets = restored.secrets;
2456
+ novel.relationships = restored.relationships;
2457
+ novel.vows = restored.vows;
2458
+ novel.notes = restored.notes;
2459
+ state.saveNovel(novel);
2460
+ return ok(`Checkpoint '${label}' restored.`);
2461
+ });
2462
+ server.registerTool("remove_checkpoint", {
2463
+ title: "Remove Checkpoint",
2464
+ description: "Remove a named checkpoint. Game Master only.",
2465
+ inputSchema: { label: z.string() },
2466
+ }, async ({ label }) => {
2467
+ requireGM();
2468
+ const novel = requireNovel();
2469
+ const idx = novel.checkpoints.findIndex(c => c.label === label);
2470
+ if (idx === -1)
2471
+ return err("NOT_FOUND", `Checkpoint '${label}' not found.`);
2472
+ novel.checkpoints.splice(idx, 1);
2473
+ state.saveNovel(novel);
2474
+ return ok(`Checkpoint '${label}' removed.`);
2475
+ });
2476
+ // --- Novel Lifecycle additions (GM) ---
2477
+ server.registerTool("rename_novel", {
2478
+ title: "Rename Novel",
2479
+ description: "Rename the active Novel on disk. Game Master only.",
2480
+ inputSchema: { new_slug: z.string() },
2481
+ }, async ({ new_slug }) => {
2482
+ requireGM();
2483
+ const novel = requireNovel();
2484
+ const oldSlug = novel.slug;
2485
+ novel.slug = new_slug.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2486
+ novel.name = new_slug;
2487
+ state.novels.delete(oldSlug);
2488
+ state.novels.set(novel.slug, novel);
2489
+ // Delete old file, save at new path
2490
+ const oldFile = path.join(DATA_DIR, "novels", `${oldSlug}.json`);
2491
+ if (fs.existsSync(oldFile))
2492
+ fs.unlinkSync(oldFile);
2493
+ const oldBak = oldFile + ".bak";
2494
+ if (fs.existsSync(oldBak))
2495
+ fs.unlinkSync(oldBak);
2496
+ state.saveNovel(novel);
2497
+ return ok(`Novel renamed to '${novel.slug}'.`);
2498
+ });
2499
+ server.registerTool("list_novels", {
2500
+ title: "List Novels",
2501
+ description: "List all Novels on disk with metadata. Always callable.",
2502
+ inputSchema: { ...detailZod },
2503
+ }, async ({ detail }) => {
2504
+ const novels = [...state.novels.entries()].map(([slug, n]) => {
2505
+ if (wantsDetail(detail)) {
2506
+ return { slug, name: n.name, entities: n.entities.size, npcs: n.npcs.size, lore: n.lore.size, world_rooms: n.world.rooms.size, modified: n.metadata.modified };
2507
+ }
2508
+ return { slug, name: n.name, entities: n.entities.size, modified: n.metadata.modified };
2509
+ });
2510
+ return raw(JSON.stringify(novels, null, 2));
2511
+ });
2512
+ server.registerTool("novel_info", {
2513
+ title: "Novel Info",
2514
+ description: "Return extended metadata for a Novel. Always callable.",
2515
+ inputSchema: { slug: z.string().optional() },
2516
+ }, async ({ slug }) => {
2517
+ const novel = slug ? state.novels.get(slug) : state.activeNovel;
2518
+ if (!novel)
2519
+ return err("NOT_FOUND", `Novel '${slug || "none"}' not found.`);
2520
+ return raw(JSON.stringify({
2521
+ slug: novel.slug, name: novel.name, ruleset: novel.ruleset, description: novel.description, genre: novel.genre,
2522
+ entities: novel.entities.size, npcs: novel.npcs.size, lore: novel.lore.size,
2523
+ world_rooms: novel.world.rooms.size, world_things: novel.world.things.size,
2524
+ factions: novel.factions.length, vows: novel.vows.length,
2525
+ scene: novel.scene_description ? novel.scene_description.substring(0, 100) : null,
2526
+ created: novel.metadata.created, modified: novel.metadata.modified,
2527
+ }, null, 2));
2528
+ });
2529
+ server.registerTool("clone_novel", {
2530
+ title: "Clone Novel",
2531
+ description: "Create an independent copy of a Novel. Game Master only.",
2532
+ inputSchema: { source_slug: z.string(), new_name: z.string(), trim_audit_sessions: z.number().min(0).optional() },
2533
+ }, async ({ source_slug, new_name }) => {
2534
+ requireGM();
2535
+ const source = state.novels.get(source_slug);
2536
+ if (!source)
2537
+ return err("NOT_FOUND", `Source novel '${source_slug}' not found.`);
2538
+ const slug = new_name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2539
+ const clone = JSON.parse(JSON.stringify(novelToJSONState(source)));
2540
+ clone.slug = slug;
2541
+ clone.name = new_name;
2542
+ clone.metadata.created = new Date().toISOString();
2543
+ clone.metadata.modified = new Date().toISOString();
2544
+ const novel = loadNovelFromStateData(clone);
2545
+ state.novels.set(slug, novel);
2546
+ state.saveNovel(novel);
2547
+ return ok(`Cloned '${source_slug}' as '${slug}'.`);
2548
+ });
2549
+ // --- Entity Management ---
2550
+ server.registerTool("remove_entity", {
2551
+ title: "Remove Entity",
2552
+ description: "Remove an entity from the active Novel. Game Master only.",
2553
+ inputSchema: { entity_id: z.string() },
2554
+ }, async ({ entity_id }) => {
2555
+ requireGM();
2556
+ const novel = requireNovel();
2557
+ if (!novel.entities.has(entity_id))
2558
+ return err("NOT_FOUND", `Entity '${entity_id}' not found.`);
2559
+ novel.entities.delete(entity_id);
2560
+ if (novel.active_entity_id === entity_id)
2561
+ novel.active_entity_id = null;
2562
+ state.saveNovel(novel);
2563
+ return ok(`Entity '${entity_id}' removed.`);
2564
+ });
2565
+ server.registerTool("remove_roster_character", {
2566
+ title: "Remove Roster Character",
2567
+ description: "Remove a character from the roster. Game Master only.",
2568
+ inputSchema: { roster_id: z.string() },
2569
+ }, async ({ roster_id }) => {
2570
+ requireGM();
2571
+ if (!state.roster.has(roster_id))
2572
+ return err("NOT_FOUND", `Roster character '${roster_id}' not found.`);
2573
+ state.roster.delete(roster_id);
2574
+ state.saveRoster();
2575
+ return ok(`Roster character '${roster_id}' removed.`);
2576
+ });
2577
+ server.registerTool("list_roster_characters", {
2578
+ title: "List Roster Characters",
2579
+ description: "List all characters in the roster.",
2580
+ inputSchema: {},
2581
+ }, async () => {
2582
+ const chars = [...state.roster.entries()].map(([id, e]) => ({ id, name: e.name }));
2583
+ return raw(JSON.stringify(chars, null, 2));
2584
+ });
2585
+ // --- Special tools ---
2586
+ server.registerTool("toggle_action_patterns", {
2587
+ title: "Toggle Action Patterns",
2588
+ description: "Toggle enrich-derived action patterns on or off for the active Novel. Game Master only.",
2589
+ inputSchema: {},
2590
+ }, async () => {
2591
+ requireGM();
2592
+ const novel = requireNovel();
2593
+ novel.action_patterns_enabled = !novel.action_patterns_enabled;
2594
+ state.saveNovel(novel);
2595
+ return ok(`Action patterns ${novel.action_patterns_enabled ? "enabled" : "disabled"}.`);
2596
+ });
2597
+ server.registerTool("present_choices", {
2598
+ title: "Present Choices",
2599
+ description: "Present structured choice prompts to the player. Resolved via respond. Game Master only.",
2600
+ inputSchema: {
2601
+ prompt: z.string(),
2602
+ choices: z.array(z.object({ id: z.string(), label: z.string(), description: z.string().optional() })),
2603
+ allow_freeform: z.boolean().optional(),
2604
+ },
2605
+ }, async ({ prompt, choices, allow_freeform }) => {
2606
+ requireGM();
2607
+ const novel = requireNovel();
2608
+ novel.pending_workflow = { decision: "present_choices", snapshot: { prompt, choices, allow_freeform } };
2609
+ state.saveNovel(novel);
2610
+ const opts = choices.map((c) => ` **${c.id}** — ${c.label}${c.description ? `: ${c.description}` : ""}`).join("\n");
2611
+ return needInput(`Decision: -present_choices-\nQuestion: ${prompt}\n\nOptions:\n${opts}${allow_freeform ? "\n\nYou may also respond with a freeform answer." : ""}`);
2612
+ });
2613
+ server.registerTool("ask_oracle", {
2614
+ title: "Ask Oracle",
2615
+ description: "Resolve uncertainty with a d100 roll against the Ask-the-Oracle ladder: almost_certain (≥11), likely (≥26), 50_50 (≥51), unlikely (≥76), small_chance (≥91). Defaults to 50_50 when likelihood is omitted. Callable by Player and Game Master.",
2616
+ inputSchema: { question: z.string(), likelihood: z.enum(["almost_certain", "likely", "50_50", "unlikely", "small_chance"]).optional(), seed: z.string().optional() },
2617
+ }, async ({ question, likelihood, seed }) => {
2618
+ requireNotObserver();
2619
+ requireNovel();
2620
+ // Ask-the-Oracle ladder (REQ-291): each tier is a d100 target the roll must
2621
+ // meet or exceed for a YES. Omitted likelihood defaults to 50_50.
2622
+ const thresholds = { almost_certain: 11, likely: 26, "50_50": 51, unlikely: 76, small_chance: 91 };
2623
+ const band = likelihood ?? "50_50";
2624
+ const target = thresholds[band] ?? 51;
2625
+ // Seeded draw uses an isolated Rng (REQ-050b); otherwise the session PRNG.
2626
+ const roll = seed ? createRng(seed).roll(100) : sessionRoll(100);
2627
+ const yes = roll >= target;
2628
+ // Doubles on the d100 (11, 22, …, 99) produce an exceptional result (REQ-291).
2629
+ const isDoubles = roll % 11 === 0;
2630
+ const marker = isDoubles
2631
+ ? (yes ? "[EXCEPTIONAL_YES]" : "[EXCEPTIONAL_NO]")
2632
+ : (yes ? "[YES]" : "[NO]");
2633
+ audit("ask_oracle", { question, likelihood: band, seed });
2634
+ let flavor = "";
2635
+ if (!isDoubles && Math.abs(target - roll) <= 5)
2636
+ flavor = " (barely)";
2637
+ else if (!isDoubles && Math.abs(target - roll) >= 30)
2638
+ flavor = " (decisively)";
2639
+ return ok(`Question: "${question}"\nLikelihood: ${band} (roll ≥ ${target})\nRoll: ${roll}/100 → ${marker}${flavor}`);
2640
+ });
2641
+ // ── Serialization helpers (used by checkpoint/export) ──────────────
2642
+ function novelToJSONState(novel) {
2643
+ return {
2644
+ slug: novel.slug, name: novel.name, ruleset: novel.ruleset, badge: novel.badge,
2645
+ entities: Object.fromEntries(novel.entities), active_entity_id: novel.active_entity_id,
2646
+ npcs: Object.fromEntries(novel.npcs),
2647
+ scene_description: novel.scene_description, scene_location: novel.scene_location,
2648
+ scene_time_of_day: novel.scene_time_of_day, scene_atmosphere: novel.scene_atmosphere,
2649
+ scene_history: novel.scene_history, scene_type: novel.scene_type,
2650
+ narrative_directive: novel.narrative_directive, combat: novel.combat,
2651
+ countdowns: Object.fromEntries(novel.countdowns), lore: Object.fromEntries(novel.lore),
2652
+ briefing_order: novel.briefing_order, action_patterns_enabled: novel.action_patterns_enabled,
2653
+ story_journal: novel.story_journal, factions: novel.factions, secrets: novel.secrets,
2654
+ relationships: novel.relationships, gm_context: novel.gm_context, notes: novel.notes,
2655
+ constraint_overrides: novel.constraint_overrides, synthesis_activated: novel.synthesis_activated, synthesis_module_enabled: novel.synthesis_module_enabled,
2656
+ characters_present_ids: novel.characters_present_ids,
2657
+ autonomy: novel.autonomy,
2658
+ vows: novel.vows, checkpoints: novel.checkpoints, description: novel.description,
2659
+ genre: novel.genre, adventure_index: novel.adventure_index,
2660
+ adventure_scene_waypoint: novel.adventure_scene_waypoint,
2661
+ world: { rooms: Object.fromEntries(novel.world.rooms), things: Object.fromEntries(novel.world.things) },
2662
+ metadata: novel.metadata,
2663
+ };
2664
+ }
2665
+ function loadNovelFromStateData(data) {
2666
+ data = migrateNovelData(data);
2667
+ const world = createEmptyWorldModel();
2668
+ if (data.world?.rooms)
2669
+ for (const [k, r] of Object.entries(data.world.rooms)) {
2670
+ world.rooms.set(k, { name: r.name, description: r.description ?? "", exits: new Map(Object.entries(r.exits || {})), doorRefs: new Map(Object.entries(r.doorRefs || {})), annotations: r.annotations ?? {} });
2671
+ }
2672
+ if (data.world?.things)
2673
+ for (const [k, t] of Object.entries(data.world.things)) {
2674
+ world.things.set(k, { name: t.name, description: t.description ?? "", kind: t.kind ?? "thing", location: t.location ?? null, locationType: t.locationType ?? null, portable: t.portable ?? true, openable: t.openable ?? false, open: t.open ?? false, lockable: t.lockable ?? false, locked: t.locked ?? false, lit: t.lit ?? false, switchable: t.switchable ?? false, switched_on: t.switched_on ?? false, enterable: t.enterable ?? false, vehiclePassengers: t.vehiclePassengers ?? [], wearable: t.wearable ?? false, worn_by: t.worn_by ?? null, readable: t.readable ?? false, read_text: t.read_text ?? null, edible: t.edible ?? false, drinkable: t.drinkable ?? false, climbable: t.climbable ?? false, transparent: t.transparent ?? false, annotations: t.annotations ?? {} });
2675
+ }
2676
+ return {
2677
+ slug: data.slug, name: data.name, ruleset: data.ruleset ?? null, badge: data.badge,
2678
+ entities: new Map(Object.entries(data.entities ?? {})),
2679
+ active_entity_id: data.active_entity_id ?? null,
2680
+ npcs: new Map(Object.entries(data.npcs ?? {})),
2681
+ scene_description: data.scene_description ?? "",
2682
+ scene_location: data.scene_location, scene_time_of_day: data.scene_time_of_day,
2683
+ scene_atmosphere: data.scene_atmosphere, scene_history: data.scene_history ?? [],
2684
+ scene_type: normalizeSceneTypeState(data.scene_type),
2685
+ narrative_directive: data.narrative_directive ?? "", combat: data.combat ?? null,
2686
+ countdowns: new Map(Object.entries(data.countdowns ?? {})),
2687
+ lore: new Map(Object.entries(data.lore ?? {})),
2688
+ briefing_assembly_count: data.briefing_assembly_count ?? 0,
2689
+ player_signals: data.player_signals ?? {}, adventure_slug: data.adventure_slug ?? null,
2690
+ generated_adventure: data.generated_adventure ?? null,
2691
+ audit_log: data.audit_log ?? [],
2692
+ undo_stacks: { player: [], game_master: [], observer: [], none: [] },
2693
+ redo_stacks: { player: [], game_master: [], observer: [], none: [] },
2694
+ briefing_order: data.briefing_order ?? [],
2695
+ action_patterns_enabled: data.action_patterns_enabled ?? false,
2696
+ session_zero_completed: false, characters_present: false, characters_present_ids: data.characters_present_ids ?? [], adventure_set: false,
2697
+ pending_workflow: data.pending_workflow ?? null,
2698
+ connection_counter: 0, pending_staleness_counter: 0, pov_mode: "character",
2699
+ help_category_overrides: {},
2700
+ story_journal: data.story_journal ?? [], factions: data.factions ?? [],
2701
+ secrets: data.secrets ?? [], relationships: data.relationships ?? [],
2702
+ gm_context: data.gm_context ?? data.dm_context ?? {}, notes: data.notes ?? [], vows: data.vows ?? [],
2703
+ checkpoints: data.checkpoints ?? [], description: data.description ?? "",
2704
+ constraint_overrides: data.constraint_overrides ?? [],
2705
+ synthesis_activated: data.synthesis_activated ?? {}, synthesis_module_enabled: data.synthesis_module_enabled ?? {},
2706
+ autonomy: normalizeAutonomy(data.autonomy),
2707
+ genre: data.genre ?? "", adventure_index: data.adventure_index ?? null,
2708
+ adventure_scene_waypoint: data.adventure_scene_waypoint ?? null,
2709
+ world, metadata: data.metadata ?? { created: new Date().toISOString(), modified: new Date().toISOString(), session_count: 0, total_combat_rounds: 0, last_scene_anchor: "" },
2710
+ };
2711
+ }
2712
+ function normalizeSceneTypeState(raw) {
2713
+ if (!raw)
2714
+ return ["neutral"];
2715
+ if (Array.isArray(raw))
2716
+ return raw.filter((t) => ["combat", "social", "exploration", "neutral"].includes(t));
2717
+ if (typeof raw === "string" && ["combat", "social", "exploration", "neutral"].includes(raw))
2718
+ return [raw];
2719
+ return ["neutral"];
2720
+ }
2721
+ // --- Guidance (GM) ---
2722
+ server.registerTool("set_briefing_order", {
2723
+ title: "Set Briefing Order",
2724
+ description: "Reorder sections of badge_briefing. Game Master only.",
2725
+ inputSchema: { sections: z.array(z.string()) },
2726
+ }, async ({ sections }) => {
2727
+ requireGM();
2728
+ const novel = requireNovel();
2729
+ novel.briefing_order = sections;
2730
+ state.saveNovel(novel);
2731
+ return ok(`Briefing order set to: ${sections.join(", ")}.`);
2732
+ });
2733
+ server.registerTool("compact_audit_log", {
2734
+ title: "Compact Audit Log",
2735
+ description: "Summarize recent audit entries. Callable by both badges.",
2736
+ inputSchema: { max_entries: z.number().optional() },
2737
+ }, async ({ max_entries }) => {
2738
+ const novel = requireNovel();
2739
+ const max = max_entries ?? 20;
2740
+ const recent = novel.audit_log.slice(-max);
2741
+ const isGM = novel.badge === "game_master";
2742
+ const filtered = isGM ? recent : recent.filter(e => e.badge !== "game_master");
2743
+ if (filtered.length === 0)
2744
+ return ok("No audit entries.");
2745
+ const lines = filtered.map(e => `${e.timestamp.split("T")[1]?.substring(0, 8) || "?"} [${e.badge ?? "·"}] ${e.tool} → ${e.output_prefix || ""}`);
2746
+ return raw(`## Audit (last ${filtered.length} entries)\n${lines.join("\n")}`);
2747
+ });
2748
+ server.registerTool("generate_adventure", {
2749
+ title: "Generate Adventure",
2750
+ description: "Generate an adventure scaffold from a premise. Game Master only.",
2751
+ inputSchema: { premise: z.string() },
2752
+ }, async ({ premise }) => {
2753
+ requireGM();
2754
+ requireNovel();
2755
+ return ok(`Adventure scaffold generated from premise: "${premise}". (Placeholder — world model must be populated with convert_source or adventure modules.)`);
2756
+ });
2757
+ server.registerTool("generate_encounter", {
2758
+ title: "Generate Encounter",
2759
+ description: "Generate a scene + NPC + lore entry from context. Game Master only.",
2760
+ inputSchema: { context: z.string() },
2761
+ }, async ({ context }) => {
2762
+ requireGM();
2763
+ requireNovel();
2764
+ return ok(`Encounter generated from context: "${context}". (Placeholder — no ruleset mechanics available.)`);
2765
+ });
2766
+ server.registerTool("load_adventure", {
2767
+ title: "Load Adventure",
2768
+ description: "Load an adventure module. Game Master only.",
2769
+ inputSchema: { slug: z.string() },
2770
+ }, async ({ slug }) => {
2771
+ requireGM();
2772
+ const novel = requireNovel();
2773
+ const adventureDir = process.env.TTRPG_ADVENTURE_DIR ?? path.join(__dirname, "..", "adventures");
2774
+ const filePath = path.join(adventureDir, `${slug}.md`);
2775
+ if (!fs.existsSync(filePath))
2776
+ return err("NOT_FOUND", `Adventure '${slug}' not found at ${filePath}.`);
2777
+ const content = fs.readFileSync(filePath, "utf-8");
2778
+ // Parse ## World section if present
2779
+ const worldMatch = content.match(/## World\s*\n([\s\S]*?)(?=\n## |$)/);
2780
+ if (worldMatch) {
2781
+ const { world, result } = convertSource(worldMatch[1], novel.world);
2782
+ novel.world = world;
2783
+ novel.adventure_slug = slug;
2784
+ novel.adventure_set = true;
2785
+ state.saveNovel(novel);
2786
+ audit("load_adventure", { slug, rooms: result.rooms, things: result.things });
2787
+ return ok(`Adventure '${slug}' loaded. World model: ${result.rooms} rooms, ${result.things} things, ${result.exits} exits.`);
2788
+ }
2789
+ novel.adventure_slug = slug;
2790
+ novel.adventure_set = true;
2791
+ state.saveNovel(novel);
2792
+ return ok(`Adventure '${slug}' loaded (no world-model section found — flat prose only).`);
2793
+ });
2794
+ // --- Session ---
2795
+ server.registerTool("session_recap", {
2796
+ title: "Session Recap",
2797
+ description: "Summarize recent session activity.",
2798
+ inputSchema: {},
2799
+ }, async () => {
2800
+ const novel = requireNovel();
2801
+ const entity = state.getActiveEntity();
2802
+ let recap = `Active Novel: ${novel.name} (${novel.slug})`;
2803
+ if (novel.scene_description) {
2804
+ recap += `\nScene: ${novel.scene_description}${novel.scene_location ? ` — ${novel.scene_location}` : ""}`;
2805
+ }
2806
+ recap += state.combatReport(novel);
2807
+ if (novel.world.rooms.size > 0) {
2808
+ recap += `\n\nWorld model: ${novel.world.rooms.size} rooms, ${novel.world.things.size} things.`;
2809
+ }
2810
+ if (entity) {
2811
+ recap += `\n\nActive entity: ${entity.name}${entity.current_room ? ` in ${entity.current_room}` : ""}`;
2812
+ if (entity.inventory.length > 0) {
2813
+ recap += ` — holding: ${entity.inventory.join(", ")}`;
2814
+ }
2815
+ }
2816
+ const activeCountdowns = [...novel.countdowns.entries()].filter(([, cd]) => cd.ticks > 0);
2817
+ if (activeCountdowns.length > 0) {
2818
+ recap += `\nCountdowns: ${activeCountdowns.map(([name, cd]) => `${name}(${cd.ticks}/${cd.total})`).join(", ")}`;
2819
+ }
2820
+ const enabledLore = [...novel.lore.values()].filter(l => l.enabled);
2821
+ if (enabledLore.length > 0) {
2822
+ recap += `\nLore entries: ${enabledLore.length} active.`;
2823
+ }
2824
+ return ok(recap);
2825
+ });
2826
+ // --- Novel Lifecycle ---
2827
+ server.registerTool("create_novel", {
2828
+ title: "Create Novel",
2829
+ description: "Create a named novel. Novel persists to disk.",
2830
+ inputSchema: { name: z.string(), ruleset: z.string().optional(), genre: z.string().optional(), description: z.string().optional() },
2831
+ }, async ({ name, ruleset, genre, description }) => {
2832
+ requireNotObserver();
2833
+ if (ruleset && !rulesets.isInstalled(ruleset)) {
2834
+ return err("INVALID_INPUT", `Ruleset '${ruleset}' is not installed. Valid rulesets: ${rulesets.installedSlugs().join(", ") || "(none)"}.`);
2835
+ }
2836
+ const novel = state.createNovel(name, ruleset ?? null);
2837
+ if (genre)
2838
+ novel.genre = genre;
2839
+ if (description)
2840
+ novel.description = description;
2841
+ if (genre || description)
2842
+ state.saveNovel(novel);
2843
+ if (ruleset) {
2844
+ try {
2845
+ rulesets.hydrate(ruleset);
2846
+ }
2847
+ catch (e) {
2848
+ return err("INVALID_INPUT", e.message);
2849
+ }
2850
+ }
2851
+ return ok(`Novel created: ${novel.slug} (novel://current)${ruleset ? `, ruleset: ${ruleset}` : ""}${genre ? `, genre: ${genre}` : ""}`);
2852
+ });
2853
+ server.registerTool("resume_novel", {
2854
+ title: "Resume Novel",
2855
+ description: "Resume a previously created novel from disk.",
2856
+ inputSchema: { slug: z.string() },
2857
+ }, async ({ slug }) => {
2858
+ const novel = state.resumeNovel(slug);
2859
+ if (novel.ruleset) {
2860
+ if (!rulesets.isInstalled(novel.ruleset)) {
2861
+ return err("INVALID_INPUT", `Novel '${novel.slug}' is bound to ruleset '${novel.ruleset}', which is not installed.`);
2862
+ }
2863
+ try {
2864
+ rulesets.hydrate(novel.ruleset);
2865
+ }
2866
+ catch (e) {
2867
+ return err("INVALID_INPUT", e.message);
2868
+ }
2869
+ }
2870
+ return ok(`Novel resumed: ${novel.name} (${novel.slug})`);
2871
+ });
2872
+ server.registerTool("switch_novel", {
2873
+ title: "Switch Novel",
2874
+ description: "Switch the active novel for this connection. Always callable.",
2875
+ inputSchema: { slug: z.string() },
2876
+ }, async ({ slug }) => {
2877
+ const novel = state.switchNovel(slug);
2878
+ if (novel.ruleset) {
2879
+ if (!rulesets.isInstalled(novel.ruleset)) {
2880
+ return err("INVALID_INPUT", `Novel '${novel.slug}' is bound to ruleset '${novel.ruleset}', which is not installed.`);
2881
+ }
2882
+ try {
2883
+ rulesets.hydrate(novel.ruleset);
2884
+ }
2885
+ catch (e) {
2886
+ return err("INVALID_INPUT", e.message);
2887
+ }
2888
+ }
2889
+ return ok(`Switched to novel: ${novel.name} (${novel.slug})`);
2890
+ });
2891
+ server.registerTool("end_novel", {
2892
+ title: "End Novel",
2893
+ description: "End the current novel. Deactivates badge, removes save file.",
2894
+ inputSchema: {},
2895
+ }, async () => {
2896
+ requireNotObserver();
2897
+ const novel = requireNovel();
2898
+ return needInput(`Decision: -end_novel-confirm
2899
+ Question: End Novel "${novel.name}"?
2900
+ Options: yes, cancel`);
2901
+ });
2902
+ server.registerTool("export_novel", {
2903
+ title: "Export Novel",
2904
+ description: "Export the active novel in interchange format. Game Master only.",
2905
+ inputSchema: { format: z.enum(["json", "markdown"]).optional() },
2906
+ }, async ({ format: fmt }) => {
2907
+ requireGM();
2908
+ const novel = requireNovel();
2909
+ if (fmt === "markdown") {
2910
+ let md = `# ${novel.name}\n\n`;
2911
+ md += `## World\n`;
2912
+ for (const [, room] of novel.world.rooms) {
2913
+ md += `${room.name} is a room. "${room.description}"\n`;
2914
+ for (const [dir, target] of room.exits) {
2915
+ md += `${dir} of ${room.name} is ${target}.\n`;
2916
+ }
2917
+ }
2918
+ for (const [, thing] of novel.world.things) {
2919
+ if (thing.kind !== "thing") {
2920
+ md += `${thing.name} is a ${thing.kind}. "${thing.description}"\n`;
2921
+ }
2922
+ md += `${thing.name} is in ${thing.location}.\n`;
2923
+ if (!thing.portable)
2924
+ md += `${thing.name} is fixed.\n`;
2925
+ if (thing.openable && thing.open)
2926
+ md += `${thing.name} is open.\n`;
2927
+ if (thing.locked)
2928
+ md += `${thing.name} is locked.\n`;
2929
+ }
2930
+ return raw(md);
2931
+ }
2932
+ const data = {
2933
+ novel_format_version: "1",
2934
+ server_spec_version: state.buildFingerprint.specVersion,
2935
+ builder_implementation: "holonovel-ruleset-free",
2936
+ ruleset_hash: novel.ruleset ?? null,
2937
+ property_groups_present: [
2938
+ "slug", "name", "scene", "world", "lore", "npcs", "story_journal", "factions", "secrets", "relationships", "gm_context", "notes", "vows",
2939
+ ],
2940
+ slug: novel.slug, name: novel.name, genre: novel.genre, description: novel.description,
2941
+ scene: { description: novel.scene_description, location: novel.scene_location },
2942
+ world: { rooms: [...novel.world.rooms.values()].length, things: [...novel.world.things.values()].length },
2943
+ lore: [...novel.lore.entries()].map(([k, v]) => ({ key: k, content: v.content, triggers: v.triggers, badge_scope: v.badge_scope })),
2944
+ npcs: [...novel.npcs.values()],
2945
+ story_journal: novel.story_journal,
2946
+ factions: novel.factions,
2947
+ secrets: novel.secrets,
2948
+ relationships: novel.relationships,
2949
+ gm_context: novel.gm_context,
2950
+ notes: Object.fromEntries(novel.notes.map(n => [n.key, { content: n.content, badge_scope: n.badge_scope }])),
2951
+ vows: novel.vows,
2952
+ };
2953
+ return raw(JSON.stringify(data, null, 2));
2954
+ });
2955
+ server.registerTool("import_novel", {
2956
+ title: "Import Novel",
2957
+ description: "Import a previously exported novel. Game Master only.",
2958
+ inputSchema: {
2959
+ data: z.string(),
2960
+ mode: z.enum(["dry-run", "merge", "replace"]).optional(),
2961
+ strict: z.boolean().optional(),
2962
+ },
2963
+ }, async ({ data, mode, strict }) => {
2964
+ requireGM();
2965
+ const m = mode ?? "dry-run";
2966
+ let parsed;
2967
+ try {
2968
+ parsed = JSON.parse(data);
2969
+ }
2970
+ catch {
2971
+ return err("INVALID_INPUT", "Could not parse novel data.");
2972
+ }
2973
+ // Manifest validation (REQ-096): missing required manifest fields are
2974
+ // fatal under strict mode; a warning otherwise.
2975
+ const missing = ["novel_format_version", "slug", "name"].filter(k => parsed[k] === undefined);
2976
+ if (missing.length > 0) {
2977
+ if (strict)
2978
+ return err("INVALID_INPUT", `Import rejected: missing manifest fields ${missing.join(", ")}. Corrective action: export with a conformant interchange format.`);
2979
+ }
2980
+ if (m === "dry-run")
2981
+ return ok(`Dry-run: novel '${parsed.name}' (${parsed.slug}) would be imported (${missing.length ? `missing ${missing.join(", ")}` : "valid manifest"}).`);
2982
+ const novel = requireNovel();
2983
+ if (m === "replace") {
2984
+ if (parsed.scene) {
2985
+ novel.scene_description = parsed.scene.description;
2986
+ novel.scene_location = parsed.scene.location;
2987
+ }
2988
+ }
2989
+ if (parsed.lore && Array.isArray(parsed.lore)) {
2990
+ if (m === "replace")
2991
+ novel.lore.clear();
2992
+ for (const e of parsed.lore) {
2993
+ novel.lore.set(e.key, {
2994
+ key: e.key, content: e.content, triggers: e.triggers ?? [],
2995
+ badge_scope: e.badge_scope ?? "game_master", priority: 0, sticky: 0, sticky_remaining: 0, enabled: true,
2996
+ });
2997
+ }
2998
+ }
2999
+ state.saveNovel(novel);
3000
+ return ok(`Novel '${parsed.name}' imported (${m} mode).`);
3001
+ });
3002
+ // --- Enrichment ---
3003
+ server.registerTool("revert_synthesis", {
3004
+ title: "Revert Synthesis",
3005
+ description: "Remove all synthesis state, restoring pre-synthesis server state. Game Master only.",
3006
+ inputSchema: {},
3007
+ }, async () => {
3008
+ requireGM();
3009
+ const novel = requireNovel();
3010
+ state.enriched = false;
3011
+ state.enrichmentManifest = null;
3012
+ state.saveNovel(novel);
3013
+ return ok("Enrichment state reverted. Server state restored to pre-enrich baseline.");
3014
+ });
3015
+ // --- Anchor-only tools (ruleset-free, REQ-218) ---
3016
+ server.registerTool("search_rules", {
3017
+ title: "Search Rules",
3018
+ description: "Search the active ruleset's index for matching terms. Empty when no ruleset is bound.",
3019
+ inputSchema: { query: z.string(), max_results: z.number().optional() },
3020
+ }, async ({ query, max_results }) => {
3021
+ const novel = state.activeNovel;
3022
+ const slug = novel?.ruleset ?? null;
3023
+ if (slug && rulesets.isInstalled(slug)) {
3024
+ const hits = rulesets.search(slug, String(query), max_results ?? 10);
3025
+ if (hits.length === 0)
3026
+ return err("NOT_FOUND", `No ruleset entry matches '${query}'.`);
3027
+ return raw(JSON.stringify(hits, null, 2));
3028
+ }
3029
+ if (rulesets.installedSlugs().length > 0) {
3030
+ return ok(`No ruleset bound to the active Novel. Installed rulesets: ${rulesets.installedSlugs().join(", ")}. Bind one via bind_novel_ruleset, or create a Novel with create_novel(ruleset: "...").`);
3031
+ }
3032
+ return ok(`No ruleset indexed — this is a world-model-only server. Query was: "${query}". To add a ruleset, run \`build-ruleset <slug>=<path>\` (see the spec, Appendix V).`);
3033
+ });
3034
+ server.registerTool("install_ruleset", {
3035
+ title: "Install Ruleset",
3036
+ description: "Install a ruleset package from a files bundle. Game Master or Editor only.",
3037
+ inputSchema: {
3038
+ slug: z.string(),
3039
+ manifest: z.any(),
3040
+ index: z.any().optional(),
3041
+ model: z.any().optional(),
3042
+ tools: z.any().optional(),
3043
+ resources: z.any().optional(),
3044
+ prompts: z.any().optional(),
3045
+ },
3046
+ }, async (args) => {
3047
+ requireGM();
3048
+ try {
3049
+ const pkg = rulesets.installPackage(args.slug, {
3050
+ manifest: args.manifest,
3051
+ index: args.index ?? [],
3052
+ model: args.model ?? {},
3053
+ tools: args.tools ?? [],
3054
+ resources: args.resources ?? [],
3055
+ prompts: args.prompts ?? [],
3056
+ });
3057
+ return ok(`Ruleset '${pkg.slug}' installed and hydrated: ${pkg.index.length} index entries, ${pkg.tools.length} tools.`);
3058
+ }
3059
+ catch (e) {
3060
+ return err("STATE_CONFLICT", e.message);
3061
+ }
3062
+ });
3063
+ server.registerTool("remove_ruleset", {
3064
+ title: "Remove Ruleset",
3065
+ description: "Remove an installed ruleset package. Game Master or Editor only.",
3066
+ inputSchema: { slug: z.string() },
3067
+ }, async ({ slug }) => {
3068
+ requireGM();
3069
+ const novel = state.activeNovel;
3070
+ if (novel && novel.ruleset === slug) {
3071
+ return err("STATE_CONFLICT", `Cannot remove ruleset '${slug}' while Novel '${novel.slug}' is bound to it.`);
3072
+ }
3073
+ try {
3074
+ rulesets.removePackage(slug);
3075
+ return ok(`Ruleset '${slug}' removed.`);
3076
+ }
3077
+ catch (e) {
3078
+ return err("STATE_CONFLICT", e.message);
3079
+ }
3080
+ });
3081
+ server.registerTool("list_rulesets", {
3082
+ title: "List Rulesets",
3083
+ description: "List installed ruleset packages with loaded-versus-installed state.",
3084
+ inputSchema: {},
3085
+ }, async () => {
3086
+ const list = rulesets.installedSlugs().map((slug) => rulesets.hydrate(slug)).map((pkg) => ({
3087
+ slug: pkg.slug,
3088
+ name: pkg.manifest.name,
3089
+ host_version: pkg.manifest.host_version,
3090
+ built_at: pkg.manifest.built_at,
3091
+ state: rulesets.isHydrated(pkg.slug) ? "loaded" : "installed",
3092
+ }));
3093
+ return raw(JSON.stringify(list, null, 2));
3094
+ });
3095
+ server.registerTool("bind_novel_ruleset", {
3096
+ title: "Bind Novel Ruleset",
3097
+ description: "Bind the active ruleset-free Novel to an installed ruleset. Game Master or Editor only; one-way and audited.",
3098
+ inputSchema: { slug: z.string() },
3099
+ }, async ({ slug }) => {
3100
+ requireGM();
3101
+ if (!rulesets.isInstalled(slug)) {
3102
+ return err("INVALID_INPUT", `Ruleset '${slug}' is not installed. Valid rulesets: ${rulesets.installedSlugs().join(", ") || "(none)"}.`);
3103
+ }
3104
+ try {
3105
+ const novel = state.bindNovelRuleset(slug);
3106
+ rulesets.hydrate(slug);
3107
+ return ok(`Novel '${novel.slug}' bound to ruleset '${slug}'.`);
3108
+ }
3109
+ catch (e) {
3110
+ return err("STATE_CONFLICT", e.message);
3111
+ }
3112
+ });
3113
+ server.registerTool("suggest_actions", {
3114
+ title: "Suggest Actions",
3115
+ description: "Map player intent to world-model tool invocations. No mechanical suggestions in ruleset-free mode.",
3116
+ inputSchema: { intent: z.string(), entity_id: z.string().optional() },
3117
+ }, async ({ intent, entity_id }) => {
3118
+ const novel = requireNovel();
3119
+ const entity = entity_id ? novel.entities.get(entity_id) : state.getActiveEntity();
3120
+ const name = entity?.name ?? "entity";
3121
+ // On ruleset-bound Novels the Player spatial intent routes through
3122
+ // resolve_intent (REQ-309b); the parser is never surfaced to the Player.
3123
+ const rulesetBound = !!novel.ruleset;
3124
+ const badge = getBadge();
3125
+ const useResolveIntent = rulesetBound && badge !== "game_master";
3126
+ const spatialTool = useResolveIntent ? "resolve_intent" : "command";
3127
+ const intentLower = intent.toLowerCase();
3128
+ const suggestions = [];
3129
+ if (intentLower.includes("look") || intentLower.includes("see") || intentLower.includes("where")) {
3130
+ suggestions.push(`${spatialTool}("look")`);
3131
+ suggestions.push(`command("examine <thing>")`);
3132
+ }
3133
+ if (intentLower.includes("go") || intentLower.includes("move") || intentLower.includes("travel")) {
3134
+ suggestions.push(`${spatialTool}("${useResolveIntent ? "go north" : "go north"}")`);
3135
+ }
3136
+ if (intentLower.includes("take") || intentLower.includes("grab") || intentLower.includes("get")) {
3137
+ suggestions.push(`command("take <thing>")`);
3138
+ }
3139
+ if (intentLower.includes("open") || intentLower.includes("unlock")) {
3140
+ suggestions.push(`command("open <door>")`);
3141
+ }
3142
+ if (intentLower.includes("fight") || intentLower.includes("attack")) {
3143
+ suggestions.push("init_combat (GM only, auto-advance mode)");
3144
+ }
3145
+ if (suggestions.length === 0) {
3146
+ suggestions.push(`${spatialTool}("look")`, `command("go <direction>")`, `command("examine <thing>")`);
3147
+ }
3148
+ return ok(`Actions for ${name}: ${suggestions.join(", ")}.`);
3149
+ });
3150
+ // REQ-022 resource URI catalog — presence is reported against this fixed list.
3151
+ const REQ022_URI_CATALOG = [
3152
+ { template: "novel://current", title: "Active Novel" },
3153
+ { template: "novel://setup", title: "Novel Setup" },
3154
+ { template: "entity://current", title: "Active Entity" },
3155
+ { template: "entity://{id}/personality", title: "Entity Personality" },
3156
+ { template: "entity://{id}/voice_examples", title: "Entity Voice Examples" },
3157
+ { template: "entities://", title: "All Novel Entities" },
3158
+ { template: "party://current", title: "Current Party" },
3159
+ { template: "roster://current", title: "Character Roster" },
3160
+ { template: "roster://{id}", title: "Roster Character" },
3161
+ { template: "scene://current", title: "Current Scene" },
3162
+ { template: "scene://history", title: "Scene History" },
3163
+ { template: "countdown://active", title: "Active Countdowns" },
3164
+ { template: "npc://{id}", title: "NPC Record" },
3165
+ { template: "npcs://", title: "All NPCs" },
3166
+ { template: "lore://active", title: "Active Lore" },
3167
+ { template: "lore://{key}", title: "Lore Entry" },
3168
+ { template: "lore://templates", title: "Lore Templates" },
3169
+ { template: "audit://novel", title: "Audit Log" },
3170
+ { template: "guidance://player", title: "Player Guidance" },
3171
+ { template: "guidance://game_master", title: "GM Guidance" },
3172
+ { template: "guidance://{badge}/anti-slop", title: "Anti-Slop Guidance" },
3173
+ { template: "guidance://{badge}/foundations", title: "Badge Foundations" },
3174
+ { template: "guidance://shared/badge-switch", title: "Badge Switch Guidance" },
3175
+ { template: "room://{id}", title: "Room" },
3176
+ { template: "thing://{id}", title: "Thing" },
3177
+ { template: "world://map", title: "World Map" },
3178
+ { template: "world://kinds", title: "World Kinds" },
3179
+ { template: "graph://novel", title: "Knowledge Graph" },
3180
+ { template: "spec://build", title: "Build Specification" },
3181
+ { template: "output://{tool}/{counter}", title: "Tool Output" },
3182
+ { template: "notes://{key}", title: "Note" },
3183
+ { template: "server-notes://{key}", title: "Server Note" },
3184
+ { template: "codex://{id}", title: "Codex Entry" },
3185
+ { template: "faction://{id}", title: "Faction" },
3186
+ { template: "factions://", title: "All Factions" },
3187
+ { template: "secrets://active", title: "Active Secrets" },
3188
+ { template: "synthesis://status", title: "Synthesis Status" },
3189
+ { template: "constraints://active", title: "Constraint Overrides" },
3190
+ ];
3191
+ server.registerTool("spec_health", {
3192
+ title: "Spec Health",
3193
+ description: "Report build health, indexed counts, and resource URI completeness.",
3194
+ inputSchema: {},
3195
+ }, async () => {
3196
+ const novel = state.activeNovel;
3197
+ const badge = getBadge();
3198
+ const isGM = badge === "game_master" || badge === "none";
3199
+ const entities = novel ? novel.entities.size : 0;
3200
+ const npcs = novel ? novel.npcs.size : 0;
3201
+ const loreCount = novel ? novel.lore.size : 0;
3202
+ const countdowns = novel ? novel.countdowns.size : 0;
3203
+ const rooms = novel ? novel.world.rooms.size : 0;
3204
+ const things = novel ? novel.world.things.size : 0;
3205
+ const registeredResourceURIs = new Set();
3206
+ const listedResources = server._registeredResources ?? {};
3207
+ for (const key of Object.keys(listedResources)) {
3208
+ const r = listedResources[key];
3209
+ const uriTemplate = r?._template?.uriTemplate?.toString?.() ?? key;
3210
+ registeredResourceURIs.add(typeof uriTemplate === "string" ? uriTemplate : key);
3211
+ }
3212
+ const resource_uris = REQ022_URI_CATALOG.map(({ template, title }) => {
3213
+ const present = registeredResourceURIs.has(template);
3214
+ return { uri: template, title, presence: present ? "present" : "absent" };
3215
+ });
3216
+ const prompts = server._registeredPrompts ? Object.values(server._registeredPrompts) : [];
3217
+ const prompt_health = prompts.map((p) => ({
3218
+ name: p?.name ?? "unknown",
3219
+ present: true,
3220
+ length: 0,
3221
+ budget: p?.arguments?.length ?? "n/a",
3222
+ within: true,
3223
+ stale_references: (p?.name ?? "") in BUILDER_CATEGORIES ? [] : [],
3224
+ }));
3225
+ const synthesisCounts = synthesisModuleCounts();
3226
+ const synthesis_active = state.enriched;
3227
+ const health = {
3228
+ spec_version: state.buildFingerprint.specVersion,
3229
+ spec_hash: state.buildFingerprint.specHash,
3230
+ source_hash: state.buildFingerprint.sourceHash,
3231
+ ruleset_hash: rulesets.installedSlugs().length > 0 ? rulesets.installedSlugs().join(",") : "ruleset-free",
3232
+ ruleset_guidance: rulesets.installedSlugs().length > 0
3233
+ ? `Installed: ${rulesets.installedSlugs().join(", ")}.`
3234
+ : "No rulesets installed — run `build-ruleset <slug>=<path>` to add one (spec Appendix V).",
3235
+ active_ruleset: isGM ? (novel?.ruleset ?? null) : undefined,
3236
+ rulesets_installed: rulesets.installedSlugs().length,
3237
+ rulesets_hydrated: rulesets.installedSlugs().filter((s) => rulesets.isHydrated(s)).length,
3238
+ ruleset_prefix_map: isGM ? rulesets.prefixMap() : undefined,
3239
+ build_timestamp: state.buildFingerprint.buildTimestamp,
3240
+ tool_count: (server._registeredTools ? Object.keys(server._registeredTools).length : 0),
3241
+ prompt_count: (server._registeredPrompts ? Object.keys(server._registeredPrompts).length : 0),
3242
+ resource_count: (server._registeredResources ? Object.keys(server._registeredResources).length : 0),
3243
+ resource_uris,
3244
+ prompt_health,
3245
+ confidence: { overall: "N/A — ruleset-free", per_file: {}, per_category: {} },
3246
+ indexed_counts: {
3247
+ anchors: rulesets.installedSlugs().reduce((n, s) => n + (rulesets.hydrate(s)?.index.length ?? 0), 0),
3248
+ concepts: 0, entity_types: 0, actions: 0,
3249
+ tables: 0, procedures: 0, guidance_items: 0,
3250
+ },
3251
+ must_action_coverage: "100% (infrastructure only)",
3252
+ pending_sections: 0,
3253
+ defect_count: 0,
3254
+ world_model: { rooms, things },
3255
+ novels_available: [...state.novels.keys()].length,
3256
+ server_notes: state.serverNotes.size,
3257
+ codex: isGM ? state.codex.size : undefined,
3258
+ constraint_override_counts: isGM ? (novel ? Object.keys(novel.constraint_overrides ?? {}).length : 0) : undefined,
3259
+ active_novel: novel?.slug ?? null,
3260
+ active_badge: novel?.badge ?? null,
3261
+ autonomy: isGM ? (novel?.autonomy ?? null) : undefined,
3262
+ creativity_mapping: {
3263
+ predictable: "least surprise — stick to expected outcomes",
3264
+ standard: "balanced variation — the default",
3265
+ chaotic: "most surprise — dramatic twists",
3266
+ reported: true,
3267
+ },
3268
+ entities, npcs, lore_entries: loreCount, countdowns,
3269
+ synthesis_active,
3270
+ synthesis_status: { modules: synthesisCounts, last_run: state.enrichmentManifest?.collected_at ?? null },
3271
+ synthesis_health: {
3272
+ synthesis_active,
3273
+ module_counts: synthesisCounts,
3274
+ stale_count: 0,
3275
+ activated_count: novel ? (novel.synthesis_activated ? Object.values(novel.synthesis_activated).reduce((a, b) => a + (typeof b === "number" ? b : 0), 0) : 0) : 0,
3276
+ fingerprint: state.enrichmentManifest ? SPEC_HASH : "",
3277
+ },
3278
+ audit_chain: novel ? state.verifyAuditChain(novel) : null,
3279
+ safety_protocols: {
3280
+ state_loss: "online",
3281
+ badge_boundary: "online",
3282
+ data_corruption: "online",
3283
+ unrecoverable_crash: isGM ? "unverified" : undefined,
3284
+ },
3285
+ // REQ-408, REQ-410, REQ-411, REQ-409 — token/efficiency contracts.
3286
+ parameter_ceiling: PARAMETER_CEILING,
3287
+ parameter_ceiling_exceeded: Object.values(cachedMetadata().toolParameterCounts).some((n) => n > PARAMETER_CEILING),
3288
+ tool_parameter_counts: cachedMetadata().toolParameterCounts,
3289
+ tools_list_bytes: cachedMetadata().toolsListBytes,
3290
+ cache_coverage: { hits: cacheHits, misses: cacheMisses },
3291
+ enumeration_verbosity: enumerationVerbosity,
3292
+ token_footprint: {
3293
+ tools_list_bytes: cachedMetadata().toolsListBytes,
3294
+ prompt_scaffold_bytes: cachedMetadata().promptBytes,
3295
+ },
3296
+ };
3297
+ const fingerprintPath = path.join(DATA_DIR, "build-order-fingerprint.json");
3298
+ if (fs.existsSync(fingerprintPath)) {
3299
+ try {
3300
+ health.build_order = JSON.parse(fs.readFileSync(fingerprintPath, "utf-8"));
3301
+ }
3302
+ catch { /* ignore unreadable fingerprint */ }
3303
+ }
3304
+ return raw(JSON.stringify(health, null, 2));
3305
+ });
3306
+ // ── Resources ──────────────────────────────────────────────────────
3307
+ // Novel resources
3308
+ server.registerResource("novel-current", "novel://current", { title: "Active Novel" }, async () => {
3309
+ const novel = state.activeNovel;
3310
+ if (!novel)
3311
+ return { contents: [{ uri: "novel://current", text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3312
+ return { contents: [{ uri: "novel://current", text: JSON.stringify({ slug: novel.slug, name: novel.name, badge: novel.badge, entities: novel.entities.size }), mimeType: "application/json" }] };
3313
+ });
3314
+ server.registerResource("novel-setup", "novel://setup", { title: "Novel Setup" }, async () => {
3315
+ const novel = state.activeNovel;
3316
+ if (!novel)
3317
+ return { contents: [{ uri: "novel://setup", text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3318
+ return { contents: [{ uri: "novel://setup", text: JSON.stringify({ slug: novel.slug, name: novel.name }), mimeType: "application/json" }] };
3319
+ });
3320
+ // Entity resource
3321
+ server.registerResource("entity-current", "entity://current", { title: "Active Entity" }, async () => {
3322
+ const entity = state.getActiveEntity();
3323
+ if (!entity)
3324
+ return { contents: [{ uri: "entity://current", text: "No active entity.", mimeType: "text/plain" }] };
3325
+ return { contents: [{ uri: "entity://current", text: JSON.stringify({ id: entity.id, name: entity.name, personality: entity.personality, current_room: entity.current_room, inventory: entity.inventory }), mimeType: "application/json" }] };
3326
+ });
3327
+ server.registerResource("entity-personality", new ResourceTemplate("entity://{id}/personality", { list: undefined }), { title: "Entity Personality" }, async (uri) => {
3328
+ const novel = state.activeNovel;
3329
+ if (!novel)
3330
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3331
+ const id = uri.href.split("/")[3];
3332
+ const entity = novel.entities.get(id);
3333
+ if (!entity)
3334
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3335
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entity.personality ?? {}), mimeType: "application/json" }] };
3336
+ });
3337
+ server.registerResource("entity-voice", new ResourceTemplate("entity://{id}/voice_examples", { list: undefined }), { title: "Entity Voice Examples" }, async (uri) => {
3338
+ const novel = state.activeNovel;
3339
+ if (!novel)
3340
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3341
+ const id = uri.href.split("/")[3];
3342
+ const entity = novel.entities.get(id);
3343
+ if (!entity)
3344
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3345
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entity.voice_examples ?? []), mimeType: "application/json" }] };
3346
+ });
3347
+ server.registerResource("roster", "roster://current", { title: "Character Roster" }, async () => {
3348
+ return { contents: [{ uri: "roster://current", text: JSON.stringify(Object.fromEntries(state.roster)), mimeType: "application/json" }] };
3349
+ });
3350
+ // Scene resources
3351
+ server.registerResource("scene-current", "scene://current", { title: "Current Scene" }, async () => {
3352
+ const novel = state.activeNovel;
3353
+ if (!novel)
3354
+ return { contents: [{ uri: "scene://current", text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3355
+ return { contents: [{ uri: "scene://current", text: JSON.stringify({ description: novel.scene_description, location: novel.scene_location, time_of_day: novel.scene_time_of_day, atmosphere: novel.scene_atmosphere, type: novel.scene_type }), mimeType: "application/json" }] };
3356
+ });
3357
+ server.registerResource("scene-history", "scene://history", { title: "Scene History" }, async () => {
3358
+ const novel = state.activeNovel;
3359
+ if (!novel)
3360
+ return { contents: [{ uri: "scene://history", text: "[]", mimeType: "application/json" }] };
3361
+ return { contents: [{ uri: "scene://history", text: JSON.stringify(novel.scene_history), mimeType: "application/json" }] };
3362
+ });
3363
+ // Countdown resource
3364
+ server.registerResource("countdown-active", "countdown://active", { title: "Active Countdowns" }, async () => {
3365
+ const novel = state.activeNovel;
3366
+ if (!novel)
3367
+ return { contents: [{ uri: "countdown://active", text: "{}", mimeType: "application/json" }] };
3368
+ const active = Object.fromEntries([...novel.countdowns.entries()].filter(([, cd]) => cd.ticks > 0));
3369
+ return { contents: [{ uri: "countdown://active", text: JSON.stringify(active), mimeType: "application/json" }] };
3370
+ });
3371
+ // NPC resources
3372
+ server.registerResource("npc-single", new ResourceTemplate("npc://{id}", { list: () => {
3373
+ const novel = state.activeNovel;
3374
+ if (!novel)
3375
+ return { resources: [] };
3376
+ return { resources: [...novel.npcs.keys()].map(id => ({ uri: `npc://${id}`, name: id })) };
3377
+ } }), { title: "NPC Record" }, async (uri) => {
3378
+ const novel = state.activeNovel;
3379
+ const id = uri.href.split("/").pop() ?? "";
3380
+ if (!novel)
3381
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3382
+ const npc = novel.npcs.get(id);
3383
+ if (!npc)
3384
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3385
+ return { contents: [{ uri: uri.href, text: JSON.stringify(npc), mimeType: "application/json" }] };
3386
+ });
3387
+ server.registerResource("npcs", "npcs://", { title: "All NPCs" }, async () => {
3388
+ const novel = state.activeNovel;
3389
+ if (!novel)
3390
+ return { contents: [{ uri: "npcs://", text: "{}", mimeType: "application/json" }] };
3391
+ return { contents: [{ uri: "npcs://", text: JSON.stringify(Object.fromEntries(novel.npcs)), mimeType: "application/json" }] };
3392
+ });
3393
+ // Lore resources
3394
+ server.registerResource("lore-active", "lore://active", { title: "Active Lore" }, async () => {
3395
+ const novel = state.activeNovel;
3396
+ if (!novel)
3397
+ return { contents: [{ uri: "lore://active", text: "{}", mimeType: "application/json" }] };
3398
+ const active = Object.fromEntries([...novel.lore.entries()].filter(([, l]) => l.enabled));
3399
+ return { contents: [{ uri: "lore://active", text: JSON.stringify(active), mimeType: "application/json" }] };
3400
+ });
3401
+ server.registerResource("lore-single", new ResourceTemplate("lore://{key}", { list: () => {
3402
+ const novel = state.activeNovel;
3403
+ if (!novel)
3404
+ return { resources: [] };
3405
+ return { resources: [...novel.lore.keys()].map(k => ({ uri: `lore://${k}`, name: k })) };
3406
+ } }), { title: "Lore Entry" }, async (uri) => {
3407
+ const novel = state.activeNovel;
3408
+ const key = uri.href.split("/").pop() ?? "";
3409
+ if (!novel)
3410
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3411
+ const entry = novel.lore.get(key);
3412
+ if (!entry)
3413
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3414
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entry), mimeType: "application/json" }] };
3415
+ });
3416
+ // Audit resource
3417
+ server.registerResource("audit-novel", "audit://novel", { title: "Audit Log" }, async () => {
3418
+ const novel = state.activeNovel;
3419
+ if (!novel)
3420
+ return { contents: [{ uri: "audit://novel", text: "[]", mimeType: "application/json" }] };
3421
+ return { contents: [{ uri: "audit://novel", text: JSON.stringify(novel.audit_log.slice(-50)), mimeType: "application/json" }] };
3422
+ });
3423
+ // Guidance resources
3424
+ server.registerResource("guidance-player", "guidance://player", { title: "Player Guidance" }, async () => ({
3425
+ contents: [{ uri: "guidance://player", text: "## Player Guidance\n\nDescribe what your character does. Use parser commands to interact with the world: command(\"look\"), command(\"go north\"), command(\"take sword\").", mimeType: "text/markdown" }],
3426
+ }));
3427
+ server.registerResource("guidance-gm", "guidance://game_master", { title: "GM Guidance" }, async () => ({
3428
+ contents: [{ uri: "guidance://game_master", text: "## Game Master Guidance\n\nPopulate the world model with convert_source or adventure modules. Set scenes, NPCs, lore, and countdowns.", mimeType: "text/markdown" }],
3429
+ }));
3430
+ server.registerResource("guidance-player-anti-slop", "guidance://player/anti-slop", { title: "Player Anti-Slop" }, async () => ({
3431
+ contents: [{ uri: "guidance://player/anti-slop", text: "Describe actions concretely. Use parser commands for world interaction. Narrate in-character.", mimeType: "text/markdown" }],
3432
+ }));
3433
+ server.registerResource("guidance-gm-anti-slop", "guidance://game_master/anti-slop", { title: "GM Anti-Slop" }, async () => ({
3434
+ contents: [{ uri: "guidance://game_master/anti-slop", text: "Describe situations richly. Surface information. Do not take actions or make decisions for the player.", mimeType: "text/markdown" }],
3435
+ }));
3436
+ server.registerResource("guidance-badge-switch", "guidance://shared/badge-switch", { title: "Badge Switch Guidance" }, async () => ({
3437
+ contents: [{ uri: "guidance://shared/badge-switch", text: "Use set_badge to switch between player and game_master badges. Player: describe actions. GM: describe situations.", mimeType: "text/markdown" }],
3438
+ }));
3439
+ // World-model resources (REQ-202)
3440
+ server.registerResource("room-single", new ResourceTemplate("room://{id}", { list: () => {
3441
+ const novel = state.activeNovel;
3442
+ if (!novel)
3443
+ return { resources: [] };
3444
+ return { resources: [...novel.world.rooms.keys()].map(id => ({ uri: `room://${id}`, name: novel.world.rooms.get(id)?.name ?? id })) };
3445
+ } }), { title: "Room" }, async (uri) => {
3446
+ const novel = state.activeNovel;
3447
+ const id = decodeURIComponent(uri.href.split("/").pop() ?? "");
3448
+ if (!novel)
3449
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3450
+ const room = novel.world.rooms.get(id.toLowerCase());
3451
+ if (!room)
3452
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3453
+ const isGM = novel.badge === "game_master";
3454
+ let info = `## ${room.name}\n${room.description || "No description."}`;
3455
+ if (isGM) {
3456
+ info += `\n\n**Exits:** ${[...room.exits.entries()].map(([d, t]) => `${d} → ${t}`).join(", ") || "none"}`;
3457
+ }
3458
+ else {
3459
+ const exits = [...room.exits.keys()].join(", ");
3460
+ if (exits)
3461
+ info += `\n\n**Exits:** ${exits}`;
3462
+ }
3463
+ return { contents: [{ uri: uri.href, text: info, mimeType: "text/markdown" }] };
3464
+ });
3465
+ server.registerResource("thing-single", new ResourceTemplate("thing://{id}", { list: () => {
3466
+ const novel = state.activeNovel;
3467
+ if (!novel)
3468
+ return { resources: [] };
3469
+ return { resources: [...novel.world.things.keys()].map(id => ({ uri: `thing://${id}`, name: novel.world.things.get(id)?.name ?? id })) };
3470
+ } }), { title: "Thing" }, async (uri) => {
3471
+ const novel = state.activeNovel;
3472
+ const id = decodeURIComponent(uri.href.split("/").pop() ?? "");
3473
+ if (!novel)
3474
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3475
+ const thing = novel.world.things.get(id.toLowerCase());
3476
+ if (!thing)
3477
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3478
+ const isGM = novel.badge === "game_master";
3479
+ let info = `## ${thing.name}\n**Kind:** ${thing.kind}\n${thing.description || "No description."}`;
3480
+ if (isGM) {
3481
+ info += `\n\n**Location:** ${thing.location || "(held/inventory)"}`;
3482
+ info += `\n**Properties:** portable=${thing.portable}, openable=${thing.openable}, open=${thing.open}, lockable=${thing.lockable}, locked=${thing.locked}`;
3483
+ }
3484
+ return { contents: [{ uri: uri.href, text: info, mimeType: "text/markdown" }] };
3485
+ });
3486
+ server.registerResource("world-map", "world://map", { title: "World Map" }, async () => {
3487
+ const novel = state.activeNovel;
3488
+ if (!novel)
3489
+ return { contents: [{ uri: "world://map", text: "No active novel.", mimeType: "text/plain" }] };
3490
+ return { contents: [{ uri: "world://map", text: worldMap(novel.world), mimeType: "text/plain" }] };
3491
+ });
3492
+ server.registerResource("world-kinds", "world://kinds", { title: "World Kinds" }, async () => ({
3493
+ contents: [{ uri: "world://kinds", text: worldKinds(), mimeType: "text/markdown" }],
3494
+ }));
3495
+ // ── Additional resources (REQ-022a) ───────────────────────────────
3496
+ // Entity collection resource (REQ-074)
3497
+ server.registerResource("entities-collection", "entities://", { title: "All Novel Entities" }, async () => {
3498
+ const novel = state.activeNovel;
3499
+ if (!novel)
3500
+ return { contents: [{ uri: "entities://", text: "{}", mimeType: "application/json" }] };
3501
+ return { contents: [{ uri: "entities://", text: JSON.stringify(Object.fromEntries(novel.entities)), mimeType: "application/json" }] };
3502
+ });
3503
+ // Party resource (REQ-074, REQ-307)
3504
+ server.registerResource("party-current", "party://current", { title: "Current Party" }, async () => {
3505
+ const novel = state.activeNovel;
3506
+ if (!novel)
3507
+ return { contents: [{ uri: "party://current", text: "{}", mimeType: "application/json" }] };
3508
+ const party = [...novel.entities.values()].map(e => ({
3509
+ name: e.name,
3510
+ active: e.id === novel.active_entity_id,
3511
+ conditions: e.conditions ?? [],
3512
+ present: novel.characters_present_ids ? novel.characters_present_ids.includes(e.id) : true,
3513
+ last_location: e.current_room ?? null,
3514
+ }));
3515
+ return { contents: [{ uri: "party://current", text: JSON.stringify(party, null, 2), mimeType: "application/json" }] };
3516
+ });
3517
+ // Knowledge graph (REQ-296)
3518
+ server.registerResource("graph-novel", "graph://novel", { title: "Novel Knowledge Graph" }, async () => {
3519
+ const novel = state.activeNovel;
3520
+ if (!novel)
3521
+ return { contents: [{ uri: "graph://novel", text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3522
+ const isGM = novel.badge === "game_master";
3523
+ const revealedSecrets = novel.secrets.filter(s => s.known_by.length > 0 || isGM);
3524
+ const graph = {
3525
+ entities: [...novel.entities.values()].map(e => ({ id: e.id, name: e.name })),
3526
+ npcs: [...novel.npcs.values()].map(n => ({ id: n.id, name: n.name, disposition: n.disposition, location: n.location })),
3527
+ lore_connections: [...novel.lore.values()].filter(l => l.enabled).map(l => ({ key: l.key })),
3528
+ secrets: revealedSecrets.map(s => ({ key: s.key, known_by: s.known_by })),
3529
+ factions: novel.factions.map(f => ({ id: f.id, name: f.name })),
3530
+ };
3531
+ return { contents: [{ uri: "graph://novel", text: JSON.stringify(graph, null, 2), mimeType: "application/json" }] };
3532
+ });
3533
+ // Spec resource (REQ-105)
3534
+ server.registerResource("spec-build", "spec://build", { title: "Build Specification" }, async () => {
3535
+ const badge = getBadge();
3536
+ if (badge !== "game_master" && badge !== "none") {
3537
+ return { contents: [{ uri: "spec://build", text: "[FORBIDDEN] spec://build is Game Master only. Corrective action: switch badge with set_badge.", mimeType: "text/plain" }] };
3538
+ }
3539
+ try {
3540
+ const specPath = path.join(__dirname, "holonovel.md");
3541
+ if (fs.existsSync(specPath)) {
3542
+ return { contents: [{ uri: "spec://build", text: fs.readFileSync(specPath, "utf-8"), mimeType: "text/markdown" }] };
3543
+ }
3544
+ }
3545
+ catch { /* fall through */ }
3546
+ return { contents: [{ uri: "spec://build", text: "Specification not embedded in this build.", mimeType: "text/plain" }] };
3547
+ });
3548
+ // Output pointer resource (REQ-179): output://{tool}/{counter}
3549
+ const outputStore = new Map();
3550
+ server.registerResource("output-pointer", new ResourceTemplate("output://{tool}/{counter}", { list: undefined }), { title: "Tool Output" }, async (uri) => {
3551
+ const key = uri.href.replace("output://", "");
3552
+ const text = outputStore.get(key);
3553
+ if (text === undefined)
3554
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3555
+ return { contents: [{ uri: uri.href, text, mimeType: "text/markdown" }] };
3556
+ });
3557
+ // Notes resource (REQ-242)
3558
+ server.registerResource("notes-single", new ResourceTemplate("notes://{key}", { list: () => {
3559
+ const novel = state.activeNovel;
3560
+ if (!novel)
3561
+ return { resources: [] };
3562
+ return { resources: novel.notes.map(n => ({ uri: `notes://${n.key}`, name: n.key })) };
3563
+ } }), { title: "Note" }, async (uri) => {
3564
+ const novel = state.activeNovel;
3565
+ const key = decodeURIComponent(uri.href.split("/").pop() ?? "");
3566
+ if (!novel)
3567
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3568
+ const note = novel.notes.find(n => n.key === key);
3569
+ if (!note)
3570
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3571
+ if (note.badge_scope === "game_master" && novel.badge !== "game_master" && novel.badge !== "none") {
3572
+ return { contents: [{ uri: uri.href, text: "[FORBIDDEN] This note is Game Master scoped.", mimeType: "text/plain" }] };
3573
+ }
3574
+ return { contents: [{ uri: uri.href, text: note.content, mimeType: "text/markdown" }] };
3575
+ });
3576
+ // Server notes resource (REQ-285)
3577
+ server.registerResource("server-notes-single", new ResourceTemplate("server-notes://{key}", { list: () => {
3578
+ return { resources: [...state.serverNotes.keys()].map(k => ({ uri: `server-notes://${k}`, name: k })) };
3579
+ } }), { title: "Server Note" }, async (uri) => {
3580
+ if (getBadge() !== "game_master" && getBadge() !== "none") {
3581
+ return { contents: [{ uri: uri.href, text: "[FORBIDDEN] Server notes are Game Master only.", mimeType: "text/plain" }] };
3582
+ }
3583
+ const key = decodeURIComponent(uri.href.split("/").pop() ?? "");
3584
+ const content = state.serverNotes.get(key);
3585
+ if (content === undefined)
3586
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3587
+ return { contents: [{ uri: uri.href, text: content, mimeType: "text/markdown" }] };
3588
+ });
3589
+ // Codex resource (REQ-321)
3590
+ server.registerResource("codex-single", new ResourceTemplate("codex://{id}", { list: () => {
3591
+ return { resources: [...state.codex.keys()].map(id => ({ uri: `codex://${id}`, name: state.codex.get(id)?.name ?? id })) };
3592
+ } }), { title: "Codex Entry" }, async (uri) => {
3593
+ const id = decodeURIComponent(uri.href.split("/").pop() ?? "");
3594
+ const entry = state.codex.get(id);
3595
+ if (!entry)
3596
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3597
+ const badge = getBadge();
3598
+ if (entry.visibility === "private" && badge !== "game_master" && badge !== "none") {
3599
+ return { contents: [{ uri: uri.href, text: "[FORBIDDEN] This codex entry is private.", mimeType: "text/plain" }] };
3600
+ }
3601
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entry, null, 2), mimeType: "application/json" }] };
3602
+ });
3603
+ // Faction resources (REQ-233)
3604
+ server.registerResource("factions-collection", "factions://", { title: "All Factions" }, async () => {
3605
+ const novel = state.activeNovel;
3606
+ if (!novel)
3607
+ return { contents: [{ uri: "factions://", text: "[]", mimeType: "application/json" }] };
3608
+ return { contents: [{ uri: "factions://", text: JSON.stringify(novel.factions, null, 2), mimeType: "application/json" }] };
3609
+ });
3610
+ server.registerResource("faction-single", new ResourceTemplate("faction://{id}", { list: () => {
3611
+ const novel = state.activeNovel;
3612
+ if (!novel)
3613
+ return { resources: [] };
3614
+ return { resources: novel.factions.map(f => ({ uri: `faction://${f.id}`, name: f.name })) };
3615
+ } }), { title: "Faction" }, async (uri) => {
3616
+ const novel = state.activeNovel;
3617
+ const id = decodeURIComponent(uri.href.split("/").pop() ?? "");
3618
+ if (!novel)
3619
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
3620
+ const faction = novel.factions.find(f => f.id === id);
3621
+ if (!faction)
3622
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3623
+ return { contents: [{ uri: uri.href, text: JSON.stringify(faction, null, 2), mimeType: "application/json" }] };
3624
+ });
3625
+ // Secrets resource (REQ-234)
3626
+ server.registerResource("secrets-active", "secrets://active", { title: "Active Secrets" }, async () => {
3627
+ const novel = state.activeNovel;
3628
+ if (getBadge() !== "game_master" && getBadge() !== "none") {
3629
+ return { contents: [{ uri: "secrets://active", text: "[FORBIDDEN] Secrets are Game Master only.", mimeType: "text/plain" }] };
3630
+ }
3631
+ if (!novel)
3632
+ return { contents: [{ uri: "secrets://active", text: "[]", mimeType: "application/json" }] };
3633
+ return { contents: [{ uri: "secrets://active", text: JSON.stringify(novel.secrets, null, 2), mimeType: "application/json" }] };
3634
+ });
3635
+ // Roster individual/type resources (REQ-022, REQ-074)
3636
+ server.registerResource("roster-single", new ResourceTemplate("roster://{id}", { list: () => {
3637
+ return { resources: [...state.roster.keys()].map(id => ({ uri: `roster://${id}`, name: state.roster.get(id)?.name ?? id })) };
3638
+ } }), { title: "Roster Character" }, async (uri) => {
3639
+ const id = decodeURIComponent(uri.href.split("/").pop() ?? "");
3640
+ const entry = state.roster.get(id);
3641
+ if (!entry)
3642
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
3643
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entry, null, 2), mimeType: "application/json" }] };
3644
+ });
3645
+ // Constraint overrides (REQ-325)
3646
+ server.registerResource("constraints-active", "constraints://active", { title: "Constraint Overrides" }, async () => {
3647
+ const novel = state.activeNovel;
3648
+ const badge = getBadge();
3649
+ if (!novel)
3650
+ return { contents: [{ uri: "constraints://active", text: "[]", mimeType: "application/json" }] };
3651
+ let overrides = novel.constraint_overrides ?? [];
3652
+ if (badge === "player") {
3653
+ const entity = state.getActiveEntity();
3654
+ overrides = overrides.filter(o => o.name === entity?.name || o.match_all);
3655
+ }
3656
+ return { contents: [{ uri: "constraints://active", text: JSON.stringify(overrides, null, 2), mimeType: "application/json" }] };
3657
+ });
3658
+ // Lore templates (REQ-159)
3659
+ server.registerResource("lore-templates", "lore://templates", { title: "Lore Templates" }, async () => {
3660
+ const templates = state.enrichmentManifest?.lore_templates ?? [];
3661
+ return { contents: [{ uri: "lore://templates", text: JSON.stringify(templates, null, 2), mimeType: "application/json" }] };
3662
+ });
3663
+ // Guidance foundations resources (REQ-062)
3664
+ server.registerResource("guidance-player-foundations", "guidance://player/foundations", { title: "Player Foundations" }, async () => ({
3665
+ contents: [{ uri: "guidance://player/foundations", text: "Describe what your character does in the fiction. Do not prescribe world facts or other characters' actions. Surface your intent; the narrative resolves it.", mimeType: "text/markdown" }],
3666
+ }));
3667
+ server.registerResource("guidance-gm-foundations", "guidance://game_master/foundations", { title: "GM Foundations" }, async () => ({
3668
+ contents: [{ uri: "guidance://game_master/foundations", text: "Describe situations and surface information. Never take action or make decisions on behalf of the player. Separate mechanics from fiction.", mimeType: "text/markdown" }],
3669
+ }));
3670
+ // Synthesis status + per-module resources (REQ-230, REQ-160)
3671
+ function synthesisModuleCounts() {
3672
+ const manifest = state.enrichmentManifest;
3673
+ const modules = ["voice_examples", "briefing_order", "lore_templates", "action_patterns", "supplementary_guidance", "adventure_advice", "narrative_voices"];
3674
+ const out = {};
3675
+ for (const m of modules)
3676
+ out[m] = { total: 0, activated: 0 };
3677
+ if (!manifest)
3678
+ return out;
3679
+ const activated = state.activeNovel?.synthesis_activated ?? {};
3680
+ if (manifest.voice_examples)
3681
+ out.voice_examples.total = manifest.voice_examples.length;
3682
+ if (manifest.lore_templates)
3683
+ out.lore_templates.total = manifest.lore_templates.length;
3684
+ if (manifest.action_patterns)
3685
+ out.action_patterns.total = manifest.action_patterns.length;
3686
+ if (manifest.supplementary_guidance)
3687
+ out.supplementary_guidance.total = manifest.supplementary_guidance.length;
3688
+ if (manifest.adventure_advice)
3689
+ out.adventure_advice.total = (manifest.adventure_advice.templates?.length ?? 0) + (manifest.adventure_advice.scenario_starters?.length ?? 0) + (manifest.adventure_advice.table_expansions?.length ?? 0);
3690
+ if (manifest.narrative_voices)
3691
+ out.narrative_voices.total = manifest.narrative_voices.length;
3692
+ for (const m of Object.keys(out))
3693
+ out[m].activated = activated[m] ?? 0;
3694
+ return out;
3695
+ }
3696
+ server.registerResource("synthesis-status", "synthesis://status", { title: "Synthesis Status" }, async () => {
3697
+ const counts = synthesisModuleCounts();
3698
+ const active = state.enriched;
3699
+ let md = "Synthesis Status\n";
3700
+ for (const [m, c] of Object.entries(counts)) {
3701
+ md += `## ${m}\nRuleset Wisdom: ${active ? c.total : 0}\nSynthesis activated/total: ${c.activated}/${active ? c.total : 0}\n`;
3702
+ }
3703
+ return { contents: [{ uri: "synthesis://status", text: md, mimeType: "text/markdown" }] };
3704
+ });
3705
+ // ── Additional tools (REQ-307, REQ-213/214, REQ-321, REQ-103, REQ-239) ──
3706
+ server.registerTool("set_party_presence", {
3707
+ title: "Set Party Presence",
3708
+ description: "Declare which entities are present in the current scene. Use when: the GM needs to override party presence without altering other scene fields. Do NOT use when: setting scene description — use set_scene_state.",
3709
+ inputSchema: { entity_ids: z.array(z.string()), location: z.string().optional() },
3710
+ }, async ({ entity_ids, location }) => {
3711
+ requireGM();
3712
+ const novel = requireNovel();
3713
+ novel.characters_present_ids = entity_ids;
3714
+ state.saveNovel(novel);
3715
+ return ok(`Party presence set: ${entity_ids.join(", ") || "(none)"}.`);
3716
+ });
3717
+ server.registerTool("roll_on_table", {
3718
+ title: "Roll On Table",
3719
+ description: "Roll on a generation table from the bound ruleset. Use when: resolving a random-generation table (names, treasure, events). Do NOT use when: resolving a fixed lookup — use a lookup tool.",
3720
+ inputSchema: { table: z.string(), seed: z.string().optional() },
3721
+ }, async ({ table, seed }) => {
3722
+ const novel = state.activeNovel;
3723
+ const slug = novel?.ruleset ?? null;
3724
+ if (!slug || !rulesets.isInstalled(slug)) {
3725
+ return err("NOT_FOUND", "No random generation tables in this ruleset (ruleset-free). Corrective action: bind a ruleset whose package defines generation tables.");
3726
+ }
3727
+ const model = rulesets.hydrate(slug).model;
3728
+ const tables = model.generation_tables ?? {};
3729
+ const key = Object.keys(tables).find(k => k.toLowerCase() === String(table).toLowerCase());
3730
+ if (!key) {
3731
+ const valid = Object.keys(tables);
3732
+ return err("NOT_FOUND", `Table '${table}' not found. Valid tables: ${valid.join(", ") || "(none)"}.`);
3733
+ }
3734
+ const entry = tables[key];
3735
+ const rng = seed ? createRng(seed) : createRng(String(sessionRoll(1000000000)));
3736
+ const roll = entry.dice_expression ? rollDice(entry.dice_expression, String(rng.roll(1000000000))).total : rng.roll(100);
3737
+ const range = (entry.ranges ?? []).find((r) => roll >= r.min && roll <= r.max);
3738
+ if (!range) {
3739
+ return warn(`Roll ${roll} on ${entry.dice_expression ?? "d100"} matched no range in table '${key}'.`);
3740
+ }
3741
+ return ok(`Table: ${key}\nDice: ${entry.dice_expression ?? "d100"}\nRoll: ${roll}\nRange: ${range.min}-${range.max}\nResult: ${range.result}`);
3742
+ });
3743
+ // Codex tools (REQ-321)
3744
+ server.registerTool("codex_set", {
3745
+ title: "Set Codex Entry",
3746
+ description: "Create or update a typed codex entry that persists across Novels. Use when: storing reusable content (NPCs, factions, rooms, spells, etc.) for later import. Do NOT use when: storing Novel-scoped content — use set_lore_entry or set_note.",
3747
+ inputSchema: {
3748
+ kind: z.string(),
3749
+ name: z.string(),
3750
+ content: z.any(),
3751
+ description: z.string().optional(),
3752
+ tags: z.array(z.string()).optional(),
3753
+ visibility: z.enum(["library", "shared", "private"]).optional(),
3754
+ },
3755
+ }, async ({ kind, name, content, description, tags, visibility }) => {
3756
+ requireGM();
3757
+ const id = `${kind.toLowerCase()}_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
3758
+ const now = new Date().toISOString();
3759
+ const existing = state.codex.get(id);
3760
+ const entry = {
3761
+ id, kind, name, content,
3762
+ description: description ?? existing?.description,
3763
+ tags: tags ?? existing?.tags ?? [],
3764
+ visibility: visibility ?? existing?.visibility ?? "library",
3765
+ imported_at: existing?.imported_at ?? now,
3766
+ codex_modified_at: now,
3767
+ };
3768
+ state.codex.set(id, entry);
3769
+ state.saveCodex();
3770
+ return ok(`Codex entry '${id}' stored (visibility: ${entry.visibility}).`);
3771
+ });
3772
+ server.registerTool("codex_list", {
3773
+ title: "List Codex Entries",
3774
+ description: "List codex entries by kind, badge-filtered by visibility. Use when: discovering reusable content to import. Do NOT use when: listing Novel entities — use list_notes or list_stories.",
3775
+ inputSchema: { kind: z.string().optional() },
3776
+ }, async ({ kind }) => {
3777
+ const badge = getBadge();
3778
+ let entries = [...state.codex.values()];
3779
+ if (kind)
3780
+ entries = entries.filter(e => e.kind === kind);
3781
+ entries = entries.filter(e => badge === "game_master" || badge === "none" || e.visibility === "shared" || e.visibility === "library");
3782
+ return raw(JSON.stringify(entries.map(e => ({ id: e.id, kind: e.kind, name: e.name, visibility: e.visibility, tags: e.tags })), null, 2));
3783
+ });
3784
+ // Synthesis tools (REQ-103, REQ-260-263)
3785
+ server.registerTool("synthesize", {
3786
+ title: "Synthesize",
3787
+ description: "Run synthesis against the active Novel's state and vendor content. Use when: generating voice examples, lore templates, and action patterns from Novel and vendor sources. Do NOT use when: editing mechanical fields — synthesis is additive only.",
3788
+ inputSchema: { force: z.boolean().optional() },
3789
+ }, async ({ force }) => {
3790
+ requireGM();
3791
+ const novel = requireNovel();
3792
+ if (state.enriched && !force) {
3793
+ return ok(`Synthesis up to date (${state.enrichmentManifest?.collected_at ?? "unknown"}). Use force=true to re-synthesize.`);
3794
+ }
3795
+ state.enriched = true;
3796
+ state.enrichmentManifest = DEFAULT_ENRICHMENT;
3797
+ state.saveNovel(novel);
3798
+ const counts = synthesisModuleCounts();
3799
+ return ok(`Synthesis complete. Modules: ${Object.entries(counts).map(([m, c]) => `${m}=${c.total}`).join(", ")}.`);
3800
+ });
3801
+ server.registerTool("list_synthesis_items", {
3802
+ title: "List Synthesis Items",
3803
+ description: "List synthesis items by module and tier. Use when: reviewing available synthesis content. Do NOT use when: browsing the codex — use codex_list.",
3804
+ inputSchema: { module: z.string().optional(), ...detailZod },
3805
+ }, async ({ module, detail }) => {
3806
+ const manifest = state.enrichmentManifest;
3807
+ if (!manifest)
3808
+ return ok("No synthesis items (synthesis not run).");
3809
+ const all = [
3810
+ ...(manifest.voice_examples ?? []).map((i) => ({ module: "voice_examples", tag: i.tag ?? "vendor", content: i.content, badge_scope: i.badge_scope })),
3811
+ ...(manifest.lore_templates ?? []).map((i) => ({ module: "lore_templates", tag: i.tag ?? "vendor", content: i.content, badge_scope: i.badge_scope })),
3812
+ ...(manifest.action_patterns ?? []).map((i) => ({ module: "action_patterns", tag: i.tag ?? "vendor", content: i.intent, badge_scope: "game_master" })),
3813
+ ...(manifest.supplementary_guidance ?? []).map((i) => ({ module: "supplementary_guidance", tag: i.tag ?? "vendor", content: i.content, badge_scope: i.badge_scope })),
3814
+ ...(manifest.narrative_voices ?? []).map((i) => ({ module: "narrative_voices", tag: i.tag ?? "vendor", content: i.name, badge_scope: i.badge_scope })),
3815
+ ];
3816
+ const filtered = module ? all.filter((i) => i.module === module) : all;
3817
+ if (wantsDetail(detail))
3818
+ return raw(JSON.stringify(filtered, null, 2));
3819
+ const summary = filtered.map((i) => ({ module: i.module, tag: i.tag, badge_scope: i.badge_scope, preview: `${typeof i.content === "string" ? (i.content ?? "").slice(0, 80) : ""}` }));
3820
+ return raw(JSON.stringify(summary, null, 2));
3821
+ });
3822
+ server.registerTool("activate_synthesis_item", {
3823
+ title: "Activate Synthesis Item",
3824
+ description: "Activate a synthesis item for the active Novel. Use when: incorporating synthesis content into play. Do NOT use when: deactivating — use deactivate_synthesis_item.",
3825
+ inputSchema: { module: z.string(), key: z.number() },
3826
+ }, async ({ module, key }) => {
3827
+ requireGM();
3828
+ const novel = requireNovel();
3829
+ if (!state.enriched)
3830
+ return err("STATE_CONFLICT", "Synthesis has not been run. Corrective action: run synthesize first.");
3831
+ const activated = novel.synthesis_activated ?? {};
3832
+ activated[module] = key;
3833
+ novel.synthesis_activated = activated;
3834
+ state.saveNovel(novel);
3835
+ return ok(`Synthesis module '${module}' activated (${key} items).`);
3836
+ });
3837
+ server.registerTool("deactivate_synthesis_item", {
3838
+ title: "Deactivate Synthesis Item",
3839
+ description: "Deactivate a synthesis item for the active Novel. Use when: removing a synthesis item from play without deleting it. Do NOT use when: removing Ruleset Wisdom — use revert_synthesis.",
3840
+ inputSchema: { module: z.string() },
3841
+ }, async ({ module }) => {
3842
+ requireGM();
3843
+ const novel = requireNovel();
3844
+ const activated = novel.synthesis_activated ?? {};
3845
+ delete activated[module];
3846
+ novel.synthesis_activated = activated;
3847
+ state.saveNovel(novel);
3848
+ return ok(`Synthesis module '${module}' deactivated.`);
3849
+ });
3850
+ server.registerTool("toggle_synthesis_module", {
3851
+ title: "Toggle Synthesis Module",
3852
+ description: "Enable or disable a synthesis module for the active Novel. Use when: controlling whether a module's content appears in surfaces. Do NOT use when: activating a single item — use activate_synthesis_item.",
3853
+ inputSchema: { module: z.string(), enabled: z.boolean() },
3854
+ }, async ({ module, enabled }) => {
3855
+ requireGM();
3856
+ const novel = requireNovel();
3857
+ const m = novel.synthesis_module_enabled ?? {};
3858
+ if (enabled)
3859
+ m[module] = true;
3860
+ else
3861
+ delete m[module];
3862
+ novel.synthesis_module_enabled = m;
3863
+ state.saveNovel(novel);
3864
+ return ok(`Synthesis module '${module}' ${enabled ? "enabled" : "disabled"}.`);
3865
+ });
3866
+ // ── Prompts ────────────────────────────────────────────────────────
3867
+ server.prompt("intro", "Introduction and Getting Started", async () => {
3868
+ const novel = state.activeNovel;
3869
+ const worldRooms = novel?.world.rooms.size ?? 0;
3870
+ const hasWorld = worldRooms > 0;
3871
+ return {
3872
+ messages: [{
3873
+ role: "user",
3874
+ content: {
3875
+ type: "text",
3876
+ text: `# Inform MCP Server — World-Model Interactive Fiction
3877
+
3878
+ This server provides a ruleset-free world-model layer for interactive fiction.
3879
+ Parser commands let you navigate, examine objects, and interact with a spatial
3880
+ world.
3881
+
3882
+ ${hasWorld
3883
+ ? `**World model populated:** ${worldRooms} rooms, ${novel.world.things.size} things.
3884
+
3885
+ ### Getting Started
3886
+ 1. \`set_badge("player")\` — switch to player badge
3887
+ 2. \`command("look")\` — describe the current room
3888
+ 3. \`command("go north")\` — move through exits
3889
+ 4. \`command("examine <thing>")\` — inspect objects`
3890
+ : `**No world model populated.**
3891
+
3892
+ ### Getting Started
3893
+ 1. \`set_badge("game_master")\` — switch to GM badge
3894
+ 2. \`create_novel({ name: "My World" })\` — create a new novel
3895
+ 3. \`load_adventure({ slug: "<adventure>" })\` — load an adventure module
3896
+ 4. \`convert_source({ source: "<world assertions>" })\` — parse room/thing declarations`}
3897
+
3898
+ Use \`help\` to see all tools, or \`badge_briefing\` for current badge guidance.`,
3899
+ },
3900
+ }],
3901
+ };
3902
+ });
3903
+ server.prompt("badge_briefing", "Current Badge Briefing", async () => {
3904
+ const novel = state.activeNovel;
3905
+ if (!novel) {
3906
+ return { messages: [{ role: "user", content: { type: "text", text: "No active Novel. Create or resume one first." } }] };
3907
+ }
3908
+ const badge = novel.badge;
3909
+ const entity = state.getActiveEntity();
3910
+ let briefing = `## Badge Briefing — ${badgeLabel(badge).toUpperCase()}
3911
+ **Novel:** ${novel.name} (${novel.slug})
3912
+ ${novel.scene_description ? `**Scene:** ${novel.scene_description}` : ""}`;
3913
+ if (entity && badge !== "game_master") {
3914
+ briefing += `\n**Active entity:** ${entity.name}`;
3915
+ if (entity.current_room)
3916
+ briefing += ` — ${entity.current_room}`;
3917
+ if (entity.inventory.length > 0)
3918
+ briefing += ` — holding: ${entity.inventory.join(", ")}`;
3919
+ }
3920
+ // REQ-412 — turn-handoff directive. When the AI narrates as Game Master
3921
+ // (human wears Player/Observer), close each turn by inviting the player's
3922
+ // next action. When the AI inhabits a Player role (human GM), hand initiative
3923
+ // back to the human Game Master instead.
3924
+ if (badge === "player" || badge === "observer") {
3925
+ briefing += `\n\n### Turn handoff
3926
+ Close each narrated turn by inviting the player's next action — ask what they do, where they look, or what they say next. Never end a turn with a tool signature or a parameter list; use a plain-English question or prompt to act.`;
3927
+ }
3928
+ else if (badge === "game_master") {
3929
+ briefing += `\n\n### Turn handoff
3930
+ You inhabit a player character. Close each in-character turn with an offer that hands initiative back to the human Game Master to describe the outcome or advance the scene.`;
3931
+ }
3932
+ if (badge === "player") {
3933
+ briefing += `\n\n### Player Tools
3934
+ Use \`command("<action>")\` to interact with the world:
3935
+ - command("look") — describe the current room
3936
+ - command("go north") — move in a direction
3937
+ - command("take sword") — pick up an object
3938
+ - command("examine thing") — look at something closely
3939
+ - command("inventory") — check what you're carrying
3940
+ - command("open door") — open an openable object`;
3941
+ }
3942
+ else if (badge === "game_master" || badge === "observer") {
3943
+ briefing += `\n\n### GM State
3944
+ **World model:** ${novel.world.rooms.size} rooms, ${novel.world.things.size} things
3945
+ **NPCs:** ${novel.npcs.size} | **Lore entries:** ${novel.lore.size} | **Countdowns:** ${novel.countdowns.size}${novel.combat?.active ? `\n**Combat active:** Round ${novel.combat.round}` : ""}`;
3946
+ if (badge === "observer") {
3947
+ briefing += `\n\n### Observer Mode
3948
+ You are both Game Master and Player. The human is observing. Narrate scenes, make decisions for all player characters, advance combat, play the Novel.`;
3949
+ }
3950
+ // Triggered lore
3951
+ const sceneText = novel.scene_description.toLowerCase();
3952
+ const triggered = [];
3953
+ for (const [, entry] of novel.lore) {
3954
+ if (!entry.enabled)
3955
+ continue;
3956
+ for (const trigger of entry.triggers) {
3957
+ if (sceneText.includes(trigger.toLowerCase())) {
3958
+ triggered.push(`[${entry.key}] ${entry.content}`);
3959
+ break;
3960
+ }
3961
+ }
3962
+ }
3963
+ if (triggered.length > 0) {
3964
+ briefing += `\n\n### Triggered Lore\n${triggered.join("\n")}`;
3965
+ }
3966
+ }
3967
+ return { messages: [{ role: "user", content: { type: "text", text: briefing } }] };
3968
+ });
3969
+ server.prompt("session_zero", "Session Zero Setup", async () => {
3970
+ return {
3971
+ messages: [{
3972
+ role: "user",
3973
+ content: {
3974
+ type: "text",
3975
+ text: `# Session Zero
3976
+
3977
+ ## World-Model Interactive Fiction
3978
+
3979
+ Before starting play:
3980
+ 1. Create a Novel: \`create_novel({ name: "My Adventure" })\`
3981
+ 2. Create characters: \`create_character({ name: "Hero", description: "..." })\`
3982
+ 3. Populate the world model with \`convert_source\` or \`load_adventure\`
3983
+ 4. The GM sets the opening scene with \`set_scene_state\`
3984
+ 5. Players use \`set_badge("player")\` and start with parser commands
3985
+
3986
+ ## Player Signals
3987
+ - \`player_signal({ signal: "pace", value: "faster/slower" })\`
3988
+ - \`player_signal({ signal: "difficulty", value: "harder/easier" })\`
3989
+ - \`player_signal({ signal: "boundary", value: "<topic>" })\``,
3990
+ },
3991
+ }],
3992
+ };
3993
+ });
3994
+ server.prompt("novel_setup", "Novel Setup Guidance", async () => {
3995
+ return {
3996
+ messages: [{
3997
+ role: "user",
3998
+ content: {
3999
+ type: "text",
4000
+ text: `# Novel Setup
4001
+
4002
+ ## Creating a World Model
4003
+ Use \`convert_source\` with declarative assertions to populate the world:
4004
+ \`\`\`
4005
+ The Entrance Chamber is a room. "A dusty hall with torches."
4006
+ North of the Entrance Chamber is the Hall of Statues.
4007
+ The Hall of Statues is a room. "Tall statues line both walls."
4008
+ A rusty sword is in the Entrance Chamber. "An old iron sword."
4009
+ The Obsidian Door is north of the Hall of Statues and south of the Throne Room.
4010
+ It is closed and locked.
4011
+ The Serpent Crown is in the Throne Room. "A golden crown with emerald eyes."
4012
+ \`\`\`
4013
+
4014
+ Or use \`load_adventure\` to load a pre-written adventure module with a ## World section.`,
4015
+ },
4016
+ }],
4017
+ };
4018
+ });
4019
+ server.prompt("run_workflow", "Map Intent to Tools", async () => {
4020
+ const registeredTools = server._registeredTools ?? {};
4021
+ const toolNames = Object.keys(registeredTools).sort();
4022
+ const novel = state.activeNovel;
4023
+ const rulesetBound = !!novel?.ruleset;
4024
+ const catalog = (cat, names) => names.filter(n => toolNames.includes(n)).join(", ");
4025
+ const text = `# Run Workflow
4026
+
4027
+ Map natural-language intent to the registered tool catalog. Derive associations
4028
+ from the live registry, not hardcoded strings.
4029
+
4030
+ ## Intent to Tool Mapping
4031
+
4032
+ - **Spatial / movement / inspection**: ${rulesetBound ? "resolve_intent, command (GM)" : "command (parser)"}
4033
+ - **Character creation / advancement**: create_character, import_character, set_active_entity
4034
+ - **Combat**: init_combat, advance_combat, end_combat
4035
+ - **World building**: ${catalog("world", ["convert_source", "create_room", "create_thing", "create_exit"])}
4036
+ - **Narrative / scene**: ${catalog("narrative", ["set_scene_state", "record_story", "set_narrative_directive"])}
4037
+ - **Lookup**: ${catalog("lookup", ["search_rules", "spec_health", "suggest_actions"])}
4038
+
4039
+ Select the tool whose registered action classification matches the intent.`;
4040
+ return { messages: [{ role: "user", content: { type: "text", text } }] };
4041
+ });
4042
+ // ── Transport ──────────────────────────────────────────────────────
4043
+ async function main() {
4044
+ const transport = new StdioServerTransport();
4045
+ await server.connect(transport);
4046
+ }
4047
+ main().catch((e) => {
4048
+ console.error("Fatal error:", e);
4049
+ process.exit(1);
4050
+ });
4051
+ //# sourceMappingURL=index.js.map