run-dmcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -10
- package/dist/bin/run-dmcp.d.ts +2 -0
- package/dist/bin/run-dmcp.js +55 -0
- package/dist/db/connection.d.ts +32 -0
- package/dist/db/connection.js +38 -16
- package/dist/db/schema.d.ts +29 -1
- package/dist/db/schema.js +439 -7
- package/dist/http/server.js +3 -3
- package/dist/index.d.ts +36 -2
- package/dist/index.js +184 -92
- package/dist/mcp-server.d.ts +49 -0
- package/dist/mcp-server.js +127 -0
- package/dist/reader/turnReader.d.ts +185 -0
- package/dist/reader/turnReader.js +288 -0
- package/dist/register/batch.js +5 -79
- package/dist/register/mcp-resources.d.ts +9 -0
- package/dist/register/mcp-resources.js +16 -62
- package/dist/register/render.d.ts +17 -0
- package/dist/register/render.js +50 -0
- package/dist/register/resolve.d.ts +14 -0
- package/dist/register/resolve.js +102 -0
- package/dist/register/resources.js +11 -4
- package/dist/register/timeline.d.ts +2 -0
- package/dist/register/timeline.js +311 -0
- package/dist/rpg/index.d.ts +29 -0
- package/dist/rpg/index.js +55 -0
- package/dist/rpg/register/abilities.d.ts +2 -0
- package/dist/rpg/register/abilities.js +165 -0
- package/dist/rpg/register/batch.d.ts +2 -0
- package/dist/rpg/register/batch.js +92 -0
- package/dist/rpg/register/combat.d.ts +2 -0
- package/dist/rpg/register/combat.js +207 -0
- package/dist/rpg/register/mcp-prompts.d.ts +2 -0
- package/dist/rpg/register/mcp-prompts.js +684 -0
- package/dist/rpg/register/mcp-resources.d.ts +2 -0
- package/dist/rpg/register/mcp-resources.js +61 -0
- package/dist/rpg/register/quests.d.ts +2 -0
- package/dist/rpg/register/quests.js +118 -0
- package/dist/rpg/register/status.d.ts +2 -0
- package/dist/rpg/register/status.js +130 -0
- package/dist/rpg/register/tables.d.ts +2 -0
- package/dist/rpg/register/tables.js +146 -0
- package/dist/rpg/tools/ability.d.ts +48 -0
- package/dist/rpg/tools/ability.js +238 -0
- package/dist/rpg/tools/combat.d.ts +13 -0
- package/dist/rpg/tools/combat.js +195 -0
- package/dist/rpg/tools/dice.d.ts +23 -0
- package/dist/rpg/tools/dice.js +111 -0
- package/dist/rpg/tools/quest.d.ts +34 -0
- package/dist/rpg/tools/quest.js +164 -0
- package/dist/rpg/tools/status.d.ts +36 -0
- package/dist/rpg/tools/status.js +218 -0
- package/dist/rpg/tools/tables.d.ts +33 -0
- package/dist/rpg/tools/tables.js +209 -0
- package/dist/schemas/index.d.ts +12 -12
- package/dist/timeline/adjudication.d.ts +150 -0
- package/dist/timeline/adjudication.js +174 -0
- package/dist/timeline/changes.d.ts +100 -0
- package/dist/timeline/changes.js +161 -0
- package/dist/timeline/checkpoint.d.ts +69 -0
- package/dist/timeline/checkpoint.js +131 -0
- package/dist/timeline/clock.d.ts +89 -0
- package/dist/timeline/clock.js +173 -0
- package/dist/timeline/constrained.d.ts +220 -0
- package/dist/timeline/constrained.js +671 -0
- package/dist/timeline/export.d.ts +171 -0
- package/dist/timeline/export.js +329 -0
- package/dist/timeline/irreversible.d.ts +85 -0
- package/dist/timeline/irreversible.js +108 -0
- package/dist/timeline/kinds.d.ts +14 -0
- package/dist/timeline/kinds.js +22 -0
- package/dist/timeline/narration.d.ts +175 -0
- package/dist/timeline/narration.js +259 -0
- package/dist/timeline/projection.d.ts +97 -0
- package/dist/timeline/projection.js +330 -0
- package/dist/timeline/provenance.d.ts +66 -0
- package/dist/timeline/provenance.js +45 -0
- package/dist/timeline/registry.d.ts +95 -0
- package/dist/timeline/registry.js +124 -0
- package/dist/timeline/render.d.ts +121 -0
- package/dist/timeline/render.js +187 -0
- package/dist/timeline/replay.d.ts +64 -0
- package/dist/timeline/replay.js +104 -0
- package/dist/timeline/resolve.d.ts +262 -0
- package/dist/timeline/resolve.js +226 -0
- package/dist/timeline/schema.d.ts +13 -0
- package/dist/timeline/schema.js +262 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +31 -6
- package/dist/tools/resource.js +106 -153
- package/dist/types/index.d.ts +19 -1
- package/dist/utils/output-schemas.d.ts +593 -2
- package/dist/utils/output-schemas.js +3 -0
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +20 -4
- package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
- package/dist/__tests__/engineVocabulary.test.js +0 -147
- package/dist/db/__tests__/connection.test.d.ts +0 -1
- package/dist/db/__tests__/connection.test.js +0 -72
- package/dist/db/__tests__/testDb.d.ts +0 -33
- package/dist/db/__tests__/testDb.js +0 -41
- package/dist/test-setup.d.ts +0 -1
- package/dist/test-setup.js +0 -13
- package/dist/tools/__tests__/audio.test.d.ts +0 -1
- package/dist/tools/__tests__/audio.test.js +0 -59
- package/dist/tools/__tests__/conserved.test.d.ts +0 -1
- package/dist/tools/__tests__/conserved.test.js +0 -488
- package/dist/tools/__tests__/constraint.test.d.ts +0 -1
- package/dist/tools/__tests__/constraint.test.js +0 -212
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
- package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
- package/dist/tools/__tests__/images.test.d.ts +0 -1
- package/dist/tools/__tests__/images.test.js +0 -59
- package/dist/tools/__tests__/relationship.test.d.ts +0 -1
- package/dist/tools/__tests__/relationship.test.js +0 -132
- package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
- package/dist/tools/__tests__/resource-constraints.test.js +0 -131
- package/dist/tools/__tests__/resource.test.d.ts +0 -1
- package/dist/tools/__tests__/resource.test.js +0 -190
- package/dist/tools/__tests__/time.test.d.ts +0 -1
- package/dist/tools/__tests__/time.test.js +0 -404
- package/dist/tools/__tests__/timers.test.d.ts +0 -1
- package/dist/tools/__tests__/timers.test.js +0 -426
- package/dist/tools/__tests__/world.test.d.ts +0 -1
- package/dist/tools/__tests__/world.test.js +0 -70
- package/dist/utils/__tests__/json.test.d.ts +0 -1
- package/dist/utils/__tests__/json.test.js +0 -55
- package/dist/utils/__tests__/validation.test.d.ts +0 -1
- package/dist/utils/__tests__/validation.test.js +0 -90
|
@@ -2,9 +2,24 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import * as gameTools from "../tools/game.js";
|
|
3
3
|
import * as characterTools from "../tools/character.js";
|
|
4
4
|
import * as worldTools from "../tools/world.js";
|
|
5
|
-
import * as questTools from "../tools/quest.js";
|
|
6
5
|
import * as narrativeTools from "../tools/narrative.js";
|
|
7
6
|
import * as rulesTools from "../tools/rules.js";
|
|
7
|
+
// Helper to create list callback for game-scoped resources. Hoisted to
|
|
8
|
+
// module scope and exported (design §8, issue #17) so that
|
|
9
|
+
// src/rpg/register/mcp-resources.ts's game-quests and quest resources --
|
|
10
|
+
// the two RPG-shaped entries pulled out of this file -- share this one
|
|
11
|
+
// implementation rather than growing a second copy of it.
|
|
12
|
+
export const createGameListCallback = (suffix, nameFormatter) => {
|
|
13
|
+
return async () => {
|
|
14
|
+
const games = gameTools.listGames();
|
|
15
|
+
return {
|
|
16
|
+
resources: games.map((g) => ({
|
|
17
|
+
uri: `dmcp://game/${g.id}${suffix}`,
|
|
18
|
+
name: nameFormatter(g),
|
|
19
|
+
})),
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
};
|
|
8
23
|
export function registerMcpResources(server) {
|
|
9
24
|
// ============================================================================
|
|
10
25
|
// STATIC RESOURCES
|
|
@@ -28,18 +43,6 @@ export function registerMcpResources(server) {
|
|
|
28
43
|
// ============================================================================
|
|
29
44
|
// GAME-SCOPED RESOURCES (templated)
|
|
30
45
|
// ============================================================================
|
|
31
|
-
// Helper to create list callback for game-scoped resources
|
|
32
|
-
const createGameListCallback = (suffix, nameFormatter) => {
|
|
33
|
-
return async () => {
|
|
34
|
-
const games = gameTools.listGames();
|
|
35
|
-
return {
|
|
36
|
-
resources: games.map((g) => ({
|
|
37
|
-
uri: `dmcp://game/${g.id}${suffix}`,
|
|
38
|
-
name: nameFormatter(g),
|
|
39
|
-
})),
|
|
40
|
-
};
|
|
41
|
-
};
|
|
42
|
-
};
|
|
43
46
|
// Game details
|
|
44
47
|
server.registerResource("game", new ResourceTemplate("dmcp://game/{gameId}", {
|
|
45
48
|
list: createGameListCallback("", (g) => g.name),
|
|
@@ -171,25 +174,6 @@ export function registerMcpResources(server) {
|
|
|
171
174
|
],
|
|
172
175
|
};
|
|
173
176
|
});
|
|
174
|
-
// Game quests
|
|
175
|
-
server.registerResource("game-quests", new ResourceTemplate("dmcp://game/{gameId}/quests", {
|
|
176
|
-
list: createGameListCallback("/quests", (g) => `${g.name} - Quests`),
|
|
177
|
-
}), {
|
|
178
|
-
description: "All quests in the game",
|
|
179
|
-
mimeType: "application/json",
|
|
180
|
-
}, async (uri, variables) => {
|
|
181
|
-
const gameId = variables.gameId;
|
|
182
|
-
const quests = questTools.listQuests(gameId);
|
|
183
|
-
return {
|
|
184
|
-
contents: [
|
|
185
|
-
{
|
|
186
|
-
uri: uri.href,
|
|
187
|
-
mimeType: "application/json",
|
|
188
|
-
text: JSON.stringify(quests, null, 2),
|
|
189
|
-
},
|
|
190
|
-
],
|
|
191
|
-
};
|
|
192
|
-
});
|
|
193
177
|
// Game history (recent narrative events)
|
|
194
178
|
server.registerResource("game-history", new ResourceTemplate("dmcp://game/{gameId}/history", {
|
|
195
179
|
list: createGameListCallback("/history", (g) => `${g.name} - History`),
|
|
@@ -302,34 +286,4 @@ export function registerMcpResources(server) {
|
|
|
302
286
|
],
|
|
303
287
|
};
|
|
304
288
|
});
|
|
305
|
-
// Quest by ID
|
|
306
|
-
server.registerResource("quest", new ResourceTemplate("dmcp://quest/{questId}", {
|
|
307
|
-
list: undefined, // No enumeration - access by ID only
|
|
308
|
-
}), {
|
|
309
|
-
description: "Quest details with objectives",
|
|
310
|
-
mimeType: "application/json",
|
|
311
|
-
}, async (uri, variables) => {
|
|
312
|
-
const questId = variables.questId;
|
|
313
|
-
const quest = questTools.getQuest(questId);
|
|
314
|
-
if (!quest) {
|
|
315
|
-
return {
|
|
316
|
-
contents: [
|
|
317
|
-
{
|
|
318
|
-
uri: uri.href,
|
|
319
|
-
mimeType: "application/json",
|
|
320
|
-
text: JSON.stringify({ error: "Quest not found" }),
|
|
321
|
-
},
|
|
322
|
-
],
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
return {
|
|
326
|
-
contents: [
|
|
327
|
-
{
|
|
328
|
-
uri: uri.href,
|
|
329
|
-
mimeType: "application/json",
|
|
330
|
-
text: JSON.stringify(quest, null, 2),
|
|
331
|
-
},
|
|
332
|
-
],
|
|
333
|
-
};
|
|
334
|
-
});
|
|
335
289
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { StateRenderer } from "../timeline/render.js";
|
|
3
|
+
/**
|
|
4
|
+
* The MCP surface for the engine's state-to-text projection (design §7,
|
|
5
|
+
* §8, GitHub issue #16) -- one tool, `render_state_at`. Registered ONLY
|
|
6
|
+
* when a caller supplies a `StateRenderer` to `createCoreMcpServer`
|
|
7
|
+
* (src/mcp-server.ts), itself built by `createStateRenderer({ vocabulary
|
|
8
|
+
* })` (timeline/render.ts) over a caller-injected, caller-owned vocabulary.
|
|
9
|
+
* An engine with no injected vocabulary has nothing to name and registers
|
|
10
|
+
* no render tool at all -- the same "injection at construction" shape
|
|
11
|
+
* `registerResolveTools` (resolve.ts) already uses for its `Resolver`, and
|
|
12
|
+
* for the identical reason: there is no global renderer anywhere in this
|
|
13
|
+
* codebase, and this file carries no vocabulary of its own, not even an
|
|
14
|
+
* example (root CLAUDE.md hard rule 3, design §7's "mechanism is core, the
|
|
15
|
+
* vocabulary is injected").
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerRenderTools(server: McpServer, renderer: StateRenderer): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ANNOTATIONS } from "../utils/tool-annotations.js";
|
|
3
|
+
import { createLogger } from "../utils/logger.js";
|
|
4
|
+
const log = createLogger("render");
|
|
5
|
+
/**
|
|
6
|
+
* The MCP surface for the engine's state-to-text projection (design §7,
|
|
7
|
+
* §8, GitHub issue #16) -- one tool, `render_state_at`. Registered ONLY
|
|
8
|
+
* when a caller supplies a `StateRenderer` to `createCoreMcpServer`
|
|
9
|
+
* (src/mcp-server.ts), itself built by `createStateRenderer({ vocabulary
|
|
10
|
+
* })` (timeline/render.ts) over a caller-injected, caller-owned vocabulary.
|
|
11
|
+
* An engine with no injected vocabulary has nothing to name and registers
|
|
12
|
+
* no render tool at all -- the same "injection at construction" shape
|
|
13
|
+
* `registerResolveTools` (resolve.ts) already uses for its `Resolver`, and
|
|
14
|
+
* for the identical reason: there is no global renderer anywhere in this
|
|
15
|
+
* codebase, and this file carries no vocabulary of its own, not even an
|
|
16
|
+
* example (root CLAUDE.md hard rule 3, design §7's "mechanism is core, the
|
|
17
|
+
* vocabulary is injected").
|
|
18
|
+
*/
|
|
19
|
+
export function registerRenderTools(server, renderer) {
|
|
20
|
+
server.registerTool("render_state_at", {
|
|
21
|
+
description: "Render a game's world at t as positive concrete nouns, drawn only from the caller's own injected " +
|
|
22
|
+
"vocabulary -- 'the grain stores are full and the treasury coffers overflow', never 'the grain stores " +
|
|
23
|
+
"are no longer empty'. The renderer's sole source of state is replay(t): a fact that does not hold at " +
|
|
24
|
+
"t produces nothing, not a phrase about its absence. A fact that DOES hold but has no vocabulary entry " +
|
|
25
|
+
"is reported as an unnamed row rather than described or invented -- that is how a caller learns its " +
|
|
26
|
+
"vocabulary is too thin, without the engine passing judgement on it. No diff, no comparison against " +
|
|
27
|
+
"another t, and no transition/change form: state at one t, full stop.",
|
|
28
|
+
inputSchema: {
|
|
29
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
30
|
+
t: z
|
|
31
|
+
.number()
|
|
32
|
+
.finite()
|
|
33
|
+
.describe("An opaque ordinal on this game's declared time axis -- never a datetime, and never an index " +
|
|
34
|
+
"into units you might later re-cut."),
|
|
35
|
+
},
|
|
36
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
37
|
+
}, async ({ gameId, t }) => {
|
|
38
|
+
try {
|
|
39
|
+
const rendered = renderer.render({ gameId, t });
|
|
40
|
+
return { content: [{ type: "text", text: JSON.stringify(rendered, null, 2) }] };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
log.error("render_state_at failed", { gameId, t, error: error.message });
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type Resolver } from "../timeline/resolve.js";
|
|
3
|
+
/**
|
|
4
|
+
* The MCP surface for the resolve protocol (design §5.2a, issue #10) --
|
|
5
|
+
* `resolve` and `list_mechanics`. Registered ONLY when a caller supplies a
|
|
6
|
+
* non-empty `mechanics` array to `createCoreMcpServer` (src/mcp-server.ts): an
|
|
7
|
+
* engine with no mechanics has no resolve surface, and that is the correct
|
|
8
|
+
* shape rather than a resolve tool that could only ever answer
|
|
9
|
+
* "unknown-mechanic" and a list_mechanics that could only ever answer `[]`.
|
|
10
|
+
* This keeps injection at construction -- there is still no global resolver
|
|
11
|
+
* anywhere in this codebase, only the one `Resolver` instance a caller built
|
|
12
|
+
* and handed in, closed over by the two tool handlers below.
|
|
13
|
+
*/
|
|
14
|
+
export declare function registerResolveTools(server: McpServer, resolver: Resolver): void;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ANNOTATIONS } from "../utils/tool-annotations.js";
|
|
3
|
+
import { createLogger } from "../utils/logger.js";
|
|
4
|
+
import { errors, formatErrorResponse } from "../utils/errors.js";
|
|
5
|
+
import { ConstraintViolationError } from "../timeline/registry.js";
|
|
6
|
+
import { ResolveProtocolError } from "../timeline/resolve.js";
|
|
7
|
+
const log = createLogger("resolve");
|
|
8
|
+
/**
|
|
9
|
+
* The MCP surface for the resolve protocol (design §5.2a, issue #10) --
|
|
10
|
+
* `resolve` and `list_mechanics`. Registered ONLY when a caller supplies a
|
|
11
|
+
* non-empty `mechanics` array to `createCoreMcpServer` (src/mcp-server.ts): an
|
|
12
|
+
* engine with no mechanics has no resolve surface, and that is the correct
|
|
13
|
+
* shape rather than a resolve tool that could only ever answer
|
|
14
|
+
* "unknown-mechanic" and a list_mechanics that could only ever answer `[]`.
|
|
15
|
+
* This keeps injection at construction -- there is still no global resolver
|
|
16
|
+
* anywhere in this codebase, only the one `Resolver` instance a caller built
|
|
17
|
+
* and handed in, closed over by the two tool handlers below.
|
|
18
|
+
*/
|
|
19
|
+
export function registerResolveTools(server, resolver) {
|
|
20
|
+
server.registerTool("resolve", {
|
|
21
|
+
description: "Propose a resolution to a registered mechanic and get back its outcome. The engine enforces the " +
|
|
22
|
+
"PROTOCOL -- resolution happens before narration, writes go through the audited path, declared " +
|
|
23
|
+
"expectations are checked before dispatch -- without knowing what the mechanic itself means. " +
|
|
24
|
+
"Refuses (no window opens, nothing is written) for an unknown mechanic, a game with no timeline " +
|
|
25
|
+
"clock, or a declared expectation that does not hold at the game's current t -- the last of these " +
|
|
26
|
+
"carries one hop of causality per contradicted expectation, so a caller can tell whether the fact " +
|
|
27
|
+
"is wrong or the claim is. A constraint violated mid-adjudication (bounded, monotonic, conserved, " +
|
|
28
|
+
"resolve_only, irreversible) rolls back every change in the resolution and records no event.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
31
|
+
mechanic: z.string().min(1).max(200).describe("The name of a mechanic registered with this resolver"),
|
|
32
|
+
parameters: z
|
|
33
|
+
.record(z.string(), z.unknown())
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Opaque to the engine -- handed to the mechanic verbatim, never inspected here"),
|
|
36
|
+
expects: z
|
|
37
|
+
.array(z.object({
|
|
38
|
+
entityId: z.string().max(100).describe("The entity this expectation is about"),
|
|
39
|
+
key: z.string().max(200).describe("The fact key this expectation is about"),
|
|
40
|
+
value: z.union([z.string(), z.number()]).describe("The value this proposal declares it depends on"),
|
|
41
|
+
}))
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Facts this proposal declares it depends on, verified BEFORE the mechanic is dispatched. A caller's " +
|
|
44
|
+
"own declared precondition -- the engine only reports whether it holds, never why it should."),
|
|
45
|
+
},
|
|
46
|
+
annotations: ANNOTATIONS.UPDATE,
|
|
47
|
+
}, async ({ gameId, mechanic, parameters, expects }) => {
|
|
48
|
+
try {
|
|
49
|
+
const outcome = resolver.resolve({ gameId, mechanic, parameters, expects });
|
|
50
|
+
return { content: [{ type: "text", text: JSON.stringify(outcome, null, 2) }] };
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof ResolveProtocolError) {
|
|
54
|
+
log.error("resolve refused", { gameId, mechanic, reason: error.reason, error: error.message });
|
|
55
|
+
return {
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "text",
|
|
59
|
+
text: JSON.stringify({
|
|
60
|
+
error: error.message,
|
|
61
|
+
reason: error.reason,
|
|
62
|
+
contradictions: error.contradictions ?? undefined,
|
|
63
|
+
}, null, 2),
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (error instanceof ConstraintViolationError) {
|
|
70
|
+
log.error("resolve rolled back", {
|
|
71
|
+
gameId,
|
|
72
|
+
mechanic,
|
|
73
|
+
constraintKind: error.constraintKind,
|
|
74
|
+
error: error.message,
|
|
75
|
+
});
|
|
76
|
+
return formatErrorResponse(errors.constraintViolation(error.resourceId, error.message));
|
|
77
|
+
}
|
|
78
|
+
log.error("resolve failed", { gameId, mechanic, error: error.message });
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
server.registerTool("list_mechanics", {
|
|
86
|
+
description: "List every mechanic name registered with this resolver. The engine holds these names; it never " +
|
|
87
|
+
"reads meaning into them -- they are exactly what a caller handed to createCoreMcpServer's mechanics option.",
|
|
88
|
+
inputSchema: {},
|
|
89
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
90
|
+
}, async () => {
|
|
91
|
+
try {
|
|
92
|
+
return { content: [{ type: "text", text: JSON.stringify(resolver.mechanics(), null, 2) }] };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
log.error("list_mechanics failed", { error: error.message });
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
98
|
+
isError: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -207,17 +207,19 @@ export function registerResourceTools(server) {
|
|
|
207
207
|
description: "Declare a server-enforced invariant on one or more resources, so update_resource_value cannot write a value that violates it. " +
|
|
208
208
|
"'bounded': the resource must already have minValue and/or maxValue set (via create_resource/update_resource) -- once declared, writes outside those bounds are REJECTED instead of the default silent clamp. " +
|
|
209
209
|
"'monotonic': the resource's value may only move in one direction ('increasing' = never decreases, 'decreasing' = never increases); holding steady is always allowed. " +
|
|
210
|
-
"'conserved': declares a set of 2+ resources that must always sum to a fixed total -- the members' current values must already sum to `total` (this does not rewrite them to match). Once declared, update_resource_value REJECTS direct writes to any member (ambiguous -- it can't know where the offsetting change comes from); use transfer_resource_value to move value between two members of the set atomically instead. A resource can belong to at most one 'conserved' set at a time."
|
|
210
|
+
"'conserved': declares a set of 2+ resources that must always sum to a fixed total -- the members' current values must already sum to `total` (this does not rewrite them to match). Once declared, update_resource_value REJECTS direct writes to any member (ambiguous -- it can't know where the offsetting change comes from); use transfer_resource_value to move value between two members of the set atomically instead. A resource can belong to at most one 'conserved' set at a time. " +
|
|
211
|
+
"'resolve_only': every DIRECT write to the given fact key is refused -- both update_resource_value and transfer_resource_value -- so it can move only through an adjudicating call. Scoped to one fact key (factKey, default 'value'); a resource can hold a separate 'resolve_only' declaration per fact key.",
|
|
211
212
|
inputSchema: {
|
|
212
213
|
gameId: z.string().max(100).describe("The game ID"),
|
|
213
|
-
kind: z.enum(["bounded", "monotonic", "conserved"]).describe("Constraint kind"),
|
|
214
|
-
resourceId: z.string().max(100).optional().describe("Required for 'bounded' or '
|
|
214
|
+
kind: z.enum(["bounded", "monotonic", "conserved", "resolve_only"]).describe("Constraint kind"),
|
|
215
|
+
resourceId: z.string().max(100).optional().describe("Required for 'bounded', 'monotonic' or 'resolve_only': the resource to constrain"),
|
|
215
216
|
resourceIds: z.array(z.string().max(100)).optional().describe("Required for 'conserved': 2 or more resource IDs that must sum to `total`"),
|
|
216
217
|
direction: z.enum(["increasing", "decreasing"]).optional().describe("Required for 'monotonic': the only direction the value may move"),
|
|
217
218
|
total: z.number().optional().describe("Required for 'conserved': the fixed sum the resource set must maintain"),
|
|
219
|
+
factKey: z.string().max(100).optional().describe("For 'resolve_only' only: the fact key the constraint governs, defaulting to 'value'"),
|
|
218
220
|
},
|
|
219
221
|
annotations: ANNOTATIONS.CREATE,
|
|
220
|
-
}, async ({ gameId, kind, resourceId, resourceIds, direction, total }) => {
|
|
222
|
+
}, async ({ gameId, kind, resourceId, resourceIds, direction, total, factKey }) => {
|
|
221
223
|
try {
|
|
222
224
|
let constraint;
|
|
223
225
|
if (kind === "bounded") {
|
|
@@ -231,6 +233,11 @@ export function registerResourceTools(server) {
|
|
|
231
233
|
}
|
|
232
234
|
constraint = constraintTools.declareMonotonicConstraint({ gameId, resourceId, direction });
|
|
233
235
|
}
|
|
236
|
+
else if (kind === "resolve_only") {
|
|
237
|
+
if (!resourceId)
|
|
238
|
+
throw new Error("resourceId is required for a 'resolve_only' constraint");
|
|
239
|
+
constraint = constraintTools.declareResolveOnlyConstraint({ gameId, resourceId, factKey });
|
|
240
|
+
}
|
|
234
241
|
else {
|
|
235
242
|
if (!resourceIds || total === undefined) {
|
|
236
243
|
throw new Error("resourceIds and total are required for a 'conserved' constraint");
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ANNOTATIONS } from "../utils/tool-annotations.js";
|
|
3
|
+
import { createLogger } from "../utils/logger.js";
|
|
4
|
+
import { replay } from "../timeline/replay.js";
|
|
5
|
+
import { declareTimeAxis, setStoryTime, currentStoryTime } from "../timeline/clock.js";
|
|
6
|
+
import { declareIrreversible, listIrreversibleFacts } from "../timeline/irreversible.js";
|
|
7
|
+
import { exportTimelineToFile, importTimelineFromFile } from "../timeline/export.js";
|
|
8
|
+
import { changesWithin } from "../timeline/changes.js";
|
|
9
|
+
import { narrationConstraintAt } from "../timeline/narration.js";
|
|
10
|
+
const log = createLogger("timeline");
|
|
11
|
+
/**
|
|
12
|
+
* The `t` every tool below takes or returns. This is the surface an
|
|
13
|
+
* implementer actually meets the invariance property at (design §14, root
|
|
14
|
+
* CLAUDE.md hard rule 6) -- the description is deliberately short, because
|
|
15
|
+
* the property itself lives in `t.ts`'s `TimeAxis` doc comment; this is
|
|
16
|
+
* where a caller is reminded of it at the moment it matters.
|
|
17
|
+
*/
|
|
18
|
+
const tSchema = z
|
|
19
|
+
.number()
|
|
20
|
+
.finite()
|
|
21
|
+
.describe("An opaque ordinal on this game's declared time axis -- never a datetime, and never an " +
|
|
22
|
+
"index into units you might later re-cut.");
|
|
23
|
+
/**
|
|
24
|
+
* Exactly the three variants `TimeAxis` (t.ts) allows, and no index-into-
|
|
25
|
+
* units fourth one -- the schema shape itself is part of what this issue
|
|
26
|
+
* asks for. `sequence` needs no `unit`; `elapsed`/`counter` need a
|
|
27
|
+
* caller-named one so the tool description alone can't be mistaken for
|
|
28
|
+
* always meaning "seconds".
|
|
29
|
+
*/
|
|
30
|
+
const timeAxisSchema = z.discriminatedUnion("kind", [
|
|
31
|
+
z.object({
|
|
32
|
+
kind: z.literal("sequence").describe("The engine's own append ordinal, one tick per write. Default for a game that declares nothing."),
|
|
33
|
+
}),
|
|
34
|
+
z.object({
|
|
35
|
+
kind: z.literal("elapsed").describe("Time since a fixed origin."),
|
|
36
|
+
unit: z.string().min(1).max(100).describe("What the elapsed count is measured in (e.g. 'second', 'minute')."),
|
|
37
|
+
}),
|
|
38
|
+
z.object({
|
|
39
|
+
kind: z.literal("counter").describe("A count of things that happened -- turns, ticks -- not of things authored."),
|
|
40
|
+
unit: z.string().min(1).max(100).describe("What is being counted (e.g. 'turn', 'tick')."),
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
export function registerTimelineTools(server) {
|
|
44
|
+
server.registerTool("replay_world_at", {
|
|
45
|
+
description: "Replay a game's world as it stood at a given t: every entity alive then, with every fact valid then. Works at any point in the game's recorded history, not only 'now'.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
48
|
+
t: tSchema,
|
|
49
|
+
},
|
|
50
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
51
|
+
}, async ({ gameId, t }) => {
|
|
52
|
+
try {
|
|
53
|
+
const snapshot = replay({ gameId, t });
|
|
54
|
+
return { content: [{ type: "text", text: JSON.stringify(snapshot, null, 2) }] };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
log.error("replay_world_at failed", { gameId, t, error: error.message });
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
60
|
+
isError: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
server.registerTool("changes_within", {
|
|
65
|
+
description: "List every event and fact-interval transition a game recorded in the half-open window [t0, t1) -- t0 is in, t1 is not. The range companion to replay_world_at's point query, for callers whose units have duration. A fact that both opens and closes inside the window returns two rows, one per endpoint. Returns rows and nothing else: no verdict, no severity, no judgement about whether the window is 'clean'. What a change inside a window means is the caller's policy, not the engine's.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
68
|
+
t0: tSchema.describe("Start of the window, inclusive. Same axis as every other t for this game."),
|
|
69
|
+
t1: tSchema.describe("End of the window, exclusive. Must be >= t0. There is no open-ended form and no start-plus-length form -- a window is two points on the axis, never a point and a duration."),
|
|
70
|
+
},
|
|
71
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
72
|
+
}, async ({ gameId, t0, t1 }) => {
|
|
73
|
+
try {
|
|
74
|
+
const changeSet = changesWithin({ gameId, t0, t1 });
|
|
75
|
+
return { content: [{ type: "text", text: JSON.stringify(changeSet, null, 2) }] };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
log.error("changes_within failed", { gameId, t0, t1, error: error.message });
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
server.registerTool("get_story_time", {
|
|
86
|
+
description: "Get a game's current position on its own timeline: t, and the axis it is measured on.",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
89
|
+
},
|
|
90
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
91
|
+
}, async ({ gameId }) => {
|
|
92
|
+
try {
|
|
93
|
+
const storyTime = currentStoryTime(gameId);
|
|
94
|
+
if (!storyTime) {
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text",
|
|
99
|
+
text: JSON.stringify({
|
|
100
|
+
error: `timeline: game '${gameId}' has no timeline clock yet -- nothing has been declared or written for it`,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
isError: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { content: [{ type: "text", text: JSON.stringify(storyTime, null, 2) }] };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
log.error("get_story_time failed", { gameId, error: error.message });
|
|
111
|
+
return {
|
|
112
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
113
|
+
isError: true,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
server.registerTool("declare_time_axis", {
|
|
118
|
+
description: "Declare the axis a game's t is measured on -- fixed for the life of that game's timeline once set for real. Call this before creating anything else so the game's world starts at its own origin, not partway up the engine's default append ordinal. A game that already has a recorded t (e.g. from its own creation) has a floor there: startAt below it is refused with the floor named, never silently shifted down to fit.",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
121
|
+
axis: timeAxisSchema.describe("The time axis. There is no variant for an index into units you might later re-cut -- only sequence, elapsed, or counter."),
|
|
122
|
+
startAt: z
|
|
123
|
+
.number()
|
|
124
|
+
.finite()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("Where this axis's t should start (or continue); defaults to 0 for a brand-new game, or to the current t otherwise. Must never be behind the current t."),
|
|
127
|
+
},
|
|
128
|
+
annotations: ANNOTATIONS.IDEMPOTENT_UPDATE,
|
|
129
|
+
}, async ({ gameId, axis, startAt }) => {
|
|
130
|
+
try {
|
|
131
|
+
const storyTime = declareTimeAxis({ gameId, axis, startAt });
|
|
132
|
+
return { content: [{ type: "text", text: JSON.stringify(storyTime, null, 2) }] };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
log.error("declare_time_axis failed", {
|
|
136
|
+
gameId,
|
|
137
|
+
axis,
|
|
138
|
+
startAt,
|
|
139
|
+
error: error.message,
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
143
|
+
isError: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
server.registerTool("set_story_time", {
|
|
148
|
+
description: "Move a game's t forward on its declared axis. Only works once a non-sequence axis has been declared -- the engine's own append ordinal cannot be positioned by a caller. Never moves t backwards, including below whatever floor the axis inherited at declaration time.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
151
|
+
t: tSchema,
|
|
152
|
+
},
|
|
153
|
+
annotations: ANNOTATIONS.IDEMPOTENT_UPDATE,
|
|
154
|
+
}, async ({ gameId, t }) => {
|
|
155
|
+
try {
|
|
156
|
+
const storyTime = setStoryTime({ gameId, t });
|
|
157
|
+
return { content: [{ type: "text", text: JSON.stringify(storyTime, null, 2) }] };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
log.error("set_story_time failed", { gameId, t, error: error.message });
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
163
|
+
isError: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
server.registerTool("declare_fact_irreversible", {
|
|
168
|
+
description: "Mark the fact currently open for one entity and key as irreversible: from its valid_from_t onward, the engine refuses any later assertion of a different value under that key. The temporal member of the same constraint family as bounded, monotonic and conserved sets. Closing the fact, and deleting the entity, both remain legal -- ending a record is not asserting its opposite -- but reopening the key at a different value is still refused. Irreversibility cannot be withdrawn once declared. Because the flag is per-fact, a property you intend to declare irreversible must live under its own key: declaring it locks the whole value stored under that key.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
entityId: z
|
|
171
|
+
.string()
|
|
172
|
+
.max(100)
|
|
173
|
+
.describe("The entity ID -- the same id as the row it was projected from (e.g. a resource ID)"),
|
|
174
|
+
key: z
|
|
175
|
+
.string()
|
|
176
|
+
.max(200)
|
|
177
|
+
.describe("The fact key, i.e. the column name the fact was projected from (e.g. 'value')"),
|
|
178
|
+
},
|
|
179
|
+
annotations: ANNOTATIONS.IDEMPOTENT_UPDATE,
|
|
180
|
+
}, async ({ entityId, key }) => {
|
|
181
|
+
try {
|
|
182
|
+
const fact = declareIrreversible({ entityId, key });
|
|
183
|
+
return { content: [{ type: "text", text: JSON.stringify(fact, null, 2) }] };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
log.error("declare_fact_irreversible failed", {
|
|
187
|
+
entityId,
|
|
188
|
+
key,
|
|
189
|
+
error: error.message,
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
193
|
+
isError: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
server.registerTool("list_irreversible_facts", {
|
|
198
|
+
description: "List every fact declared irreversible in a game, optionally narrowed to one entity. Each row carries the fact, its valid_from_t, and the event that opened it -- one hop of provenance, so a reviewer meeting a refusal can tell whether the fact is wrong or the claim is. Returns rows, never a verdict.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
201
|
+
entityId: z.string().max(100).optional().describe("Narrow the listing to one entity"),
|
|
202
|
+
},
|
|
203
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
204
|
+
}, async ({ gameId, entityId }) => {
|
|
205
|
+
try {
|
|
206
|
+
const facts = listIrreversibleFacts({ gameId, entityId });
|
|
207
|
+
return { content: [{ type: "text", text: JSON.stringify(facts, null, 2) }] };
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
log.error("list_irreversible_facts failed", {
|
|
211
|
+
gameId,
|
|
212
|
+
entityId,
|
|
213
|
+
error: error.message,
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
217
|
+
isError: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
/**
|
|
222
|
+
* Both export tools deal in a PATH and return only counts, never the
|
|
223
|
+
* artifact itself. That is design §6's point -- the deliverable is a file
|
|
224
|
+
* the client owns, not a payload passed back through a conversation -- and
|
|
225
|
+
* it is also what keeps a whole world's timeline from being pasted into
|
|
226
|
+
* the caller's context window, which is how a tool result kills the
|
|
227
|
+
* conversation that asked for it.
|
|
228
|
+
*/
|
|
229
|
+
server.registerTool("export_timeline", {
|
|
230
|
+
description: "Freeze a game's entire timeline -- every entity, every fact interval, every event, and the declared time axis -- into a JSON file the caller owns. Deterministic: the same world exports byte-identically every time. Carries no media references and no live table rows by design, so the file can be re-imported anywhere and answer replay(t) identically. Returns the path and row counts, never the artifact itself.",
|
|
231
|
+
inputSchema: {
|
|
232
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
233
|
+
filePath: z.string().min(1).max(4096).describe("Absolute path to write the export file to"),
|
|
234
|
+
},
|
|
235
|
+
annotations: ANNOTATIONS.IDEMPOTENT_UPDATE,
|
|
236
|
+
}, async ({ gameId, filePath }) => {
|
|
237
|
+
try {
|
|
238
|
+
const artifact = exportTimelineToFile({ gameId, filePath });
|
|
239
|
+
return {
|
|
240
|
+
content: [
|
|
241
|
+
{
|
|
242
|
+
type: "text",
|
|
243
|
+
text: JSON.stringify({
|
|
244
|
+
filePath,
|
|
245
|
+
gameId: artifact.gameId,
|
|
246
|
+
formatVersion: artifact.formatVersion,
|
|
247
|
+
entities: artifact.entities.length,
|
|
248
|
+
facts: artifact.facts.length,
|
|
249
|
+
events: artifact.events.length,
|
|
250
|
+
}, null, 2),
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
log.error("export_timeline failed", { gameId, filePath, error: error.message });
|
|
257
|
+
return {
|
|
258
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
259
|
+
isError: true,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
server.registerTool("import_timeline", {
|
|
264
|
+
description: "Restore a frozen timeline export into this database, verbatim -- ids and every t are carried through unchanged. Refuses rather than merges if the game already has any recorded history, and refuses an artifact whose rows do not all belong to the game it names. Imports the timeline only; the live tables are not repopulated.",
|
|
265
|
+
inputSchema: {
|
|
266
|
+
filePath: z.string().min(1).max(4096).describe("Absolute path of the export file to read"),
|
|
267
|
+
},
|
|
268
|
+
annotations: ANNOTATIONS.CREATE,
|
|
269
|
+
}, async ({ filePath }) => {
|
|
270
|
+
try {
|
|
271
|
+
const result = importTimelineFromFile(filePath);
|
|
272
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
log.error("import_timeline failed", { filePath, error: error.message });
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
278
|
+
isError: true,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
server.registerTool("narration_constraint_at", {
|
|
283
|
+
description: "Return what IS true for a game at t, and nothing else: every fact valid then on an entity alive then, plus every irreversible fact from valid_from_t onward even if it has since closed or its entity has been destroyed. Each fact carries one hop of causality -- the event that opened it, if any is recorded -- so a reviewer can tell whether a disputed fact or a disputed claim is the one that's wrong. This tool does not check anything and returns no verdict: it hands back the serialized constraint so a caller can run its own check (a live one now, or an offline lint over the saved JSON hours later) -- see the library-exported `contradictions` function for that half, which takes no database and is not exposed as a tool because wrapping a pure function in a tool call would defeat the point of it being callable offline.",
|
|
284
|
+
inputSchema: {
|
|
285
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
286
|
+
t: tSchema,
|
|
287
|
+
entityIds: z
|
|
288
|
+
.array(z.string().max(100))
|
|
289
|
+
.optional()
|
|
290
|
+
.describe("Narrow the constraint to only these entities. Omit for the whole game; an empty array narrows to nothing."),
|
|
291
|
+
},
|
|
292
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
293
|
+
}, async ({ gameId, t, entityIds }) => {
|
|
294
|
+
try {
|
|
295
|
+
const constraint = narrationConstraintAt({ gameId, t, entityIds });
|
|
296
|
+
return { content: [{ type: "text", text: JSON.stringify(constraint, null, 2) }] };
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
log.error("narration_constraint_at failed", {
|
|
300
|
+
gameId,
|
|
301
|
+
t,
|
|
302
|
+
entityIds,
|
|
303
|
+
error: error.message,
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
307
|
+
isError: true,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|