run-dmcp 0.1.0 → 0.3.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 +101 -11
- 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 +594 -10
- package/dist/http/server.js +25 -4
- package/dist/index.d.ts +69 -2
- package/dist/index.js +262 -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 +13 -6
- 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 +108 -0
- package/dist/timeline/changes.js +169 -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 +181 -0
- package/dist/timeline/export.js +339 -0
- package/dist/timeline/irreversible.d.ts +87 -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 +86 -0
- package/dist/timeline/replay.js +126 -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 +264 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/audio.js +13 -9
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/game.js +33 -1
- package/dist/tools/images.js +17 -10
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +33 -8
- package/dist/tools/resource.js +106 -153
- package/dist/tools/time.js +18 -3
- package/dist/types/index.d.ts +20 -2
- package/dist/utils/media-path.d.ts +52 -0
- package/dist/utils/media-path.js +106 -0
- package/dist/utils/output-schemas.d.ts +594 -3
- package/dist/utils/output-schemas.js +4 -1
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +25 -5
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { Mechanic } from "../timeline/resolve.js";
|
|
3
|
+
import type { RenderVocabulary } from "../timeline/render.js";
|
|
4
|
+
/**
|
|
5
|
+
* Register every RPG-layer tool, resource and prompt onto an existing
|
|
6
|
+
* server. Combat brings dice with it (registerCombatTools registers both,
|
|
7
|
+
* matching how the pre-split assembly registered them as one domain).
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerRpgTools(server: McpServer): void;
|
|
10
|
+
/**
|
|
11
|
+
* Build the FULL assembly: every core tool plus every RPG tool, resource and
|
|
12
|
+
* prompt this engine has always served. Same name, same `{ mechanics?,
|
|
13
|
+
* vocabulary? }` options as the pre-split `createMcpServer` in
|
|
14
|
+
* src/mcp-server.ts -- this is that function, now composed from two layers
|
|
15
|
+
* instead of one, so src/bin/run-dmcp.ts's zero-argument call site keeps
|
|
16
|
+
* working unchanged and the registered tool/resource/prompt set is
|
|
17
|
+
* unchanged too (src/__tests__/layerBoundary.test.ts asserts this exactly).
|
|
18
|
+
*/
|
|
19
|
+
export declare function createMcpServer(options?: {
|
|
20
|
+
mechanics?: readonly Mechanic[];
|
|
21
|
+
vocabulary?: RenderVocabulary;
|
|
22
|
+
}): McpServer;
|
|
23
|
+
export { createHttpServer, startHttpServer } from "../http/server.js";
|
|
24
|
+
export * from "./tools/dice.js";
|
|
25
|
+
export * from "./tools/combat.js";
|
|
26
|
+
export * from "./tools/ability.js";
|
|
27
|
+
export * from "./tools/status.js";
|
|
28
|
+
export * from "./tools/tables.js";
|
|
29
|
+
export * from "./tools/quest.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createCoreMcpServer } from "../mcp-server.js";
|
|
2
|
+
import { registerCombatTools } from "./register/combat.js";
|
|
3
|
+
import { registerAbilityTools } from "./register/abilities.js";
|
|
4
|
+
import { registerStatusTools } from "./register/status.js";
|
|
5
|
+
import { registerTableTools } from "./register/tables.js";
|
|
6
|
+
import { registerQuestTools } from "./register/quests.js";
|
|
7
|
+
import { registerRpgBatchTools } from "./register/batch.js";
|
|
8
|
+
import { registerRpgMcpResources } from "./register/mcp-resources.js";
|
|
9
|
+
import { registerMcpPrompts } from "./register/mcp-prompts.js";
|
|
10
|
+
/**
|
|
11
|
+
* Register every RPG-layer tool, resource and prompt onto an existing
|
|
12
|
+
* server. Combat brings dice with it (registerCombatTools registers both,
|
|
13
|
+
* matching how the pre-split assembly registered them as one domain).
|
|
14
|
+
*/
|
|
15
|
+
export function registerRpgTools(server) {
|
|
16
|
+
registerCombatTools(server); // Combat, Dice, Checks
|
|
17
|
+
registerQuestTools(server); // Quests, Objectives
|
|
18
|
+
registerTableTools(server); // Random Tables
|
|
19
|
+
registerStatusTools(server); // Status Effects
|
|
20
|
+
registerAbilityTools(server); // Abilities/Powers
|
|
21
|
+
registerRpgBatchTools(server); // setup_combat_encounter
|
|
22
|
+
registerRpgMcpResources(server); // game-quests, quest
|
|
23
|
+
registerMcpPrompts(server); // Reusable prompt templates (game-master session library)
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build the FULL assembly: every core tool plus every RPG tool, resource and
|
|
27
|
+
* prompt this engine has always served. Same name, same `{ mechanics?,
|
|
28
|
+
* vocabulary? }` options as the pre-split `createMcpServer` in
|
|
29
|
+
* src/mcp-server.ts -- this is that function, now composed from two layers
|
|
30
|
+
* instead of one, so src/bin/run-dmcp.ts's zero-argument call site keeps
|
|
31
|
+
* working unchanged and the registered tool/resource/prompt set is
|
|
32
|
+
* unchanged too (src/__tests__/layerBoundary.test.ts asserts this exactly).
|
|
33
|
+
*/
|
|
34
|
+
export function createMcpServer(options) {
|
|
35
|
+
const server = createCoreMcpServer(options);
|
|
36
|
+
registerRpgTools(server);
|
|
37
|
+
return server;
|
|
38
|
+
}
|
|
39
|
+
// The web UI. An application opts into serving it; importing this never
|
|
40
|
+
// does. It lives on disk at src/http/server.ts, not under src/rpg/ -- it
|
|
41
|
+
// imports RPG tools (quest/ability/combat), which is why it cannot be
|
|
42
|
+
// reachable from the core entry point, but its CLIENT_DIST path resolution
|
|
43
|
+
// is depth-sensitive in both src/ and compiled dist/, so it stays put and
|
|
44
|
+
// only its EXPORT moves up to this layer.
|
|
45
|
+
export { createHttpServer, startHttpServer } from "../http/server.js";
|
|
46
|
+
// The RPG tool modules, exported as library functions -- the same shape
|
|
47
|
+
// core's index.ts uses for the timeline (replay, changesWithin, and so on):
|
|
48
|
+
// a consumer that wants to call combat/quest/table/status/ability/dice logic
|
|
49
|
+
// directly, without going through an MCP tool call, can.
|
|
50
|
+
export * from "./tools/dice.js";
|
|
51
|
+
export * from "./tools/combat.js";
|
|
52
|
+
export * from "./tools/ability.js";
|
|
53
|
+
export * from "./tools/status.js";
|
|
54
|
+
export * from "./tools/tables.js";
|
|
55
|
+
export * from "./tools/quest.js";
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as abilityTools from "../tools/ability.js";
|
|
3
|
+
import { LIMITS } from "../../utils/validation.js";
|
|
4
|
+
import { ANNOTATIONS } from "../../utils/tool-annotations.js";
|
|
5
|
+
export function registerAbilityTools(server) {
|
|
6
|
+
server.registerTool("create_ability", {
|
|
7
|
+
description: "Create an ability template or character-owned ability",
|
|
8
|
+
inputSchema: {
|
|
9
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
10
|
+
ownerType: z.enum(["template", "character"]).describe("'template' for reusable, 'character' for owned"),
|
|
11
|
+
ownerId: z.string().max(100).optional().describe("Character ID if ownerType is 'character'"),
|
|
12
|
+
name: z.string().min(1).max(LIMITS.NAME_MAX).describe("Ability name"),
|
|
13
|
+
description: z.string().max(LIMITS.DESCRIPTION_MAX).optional().describe("Ability description"),
|
|
14
|
+
category: z.string().max(100).optional().describe("Category (e.g., 'spell', 'skill', 'power')"),
|
|
15
|
+
cost: z.record(z.number()).optional().describe("Resource costs (e.g., {mana: 10, stamina: 5})"),
|
|
16
|
+
cooldown: z.number().optional().describe("Cooldown in rounds"),
|
|
17
|
+
effects: z.array(z.string().max(LIMITS.DESCRIPTION_MAX)).max(LIMITS.ARRAY_MAX).optional().describe("Effect descriptions"),
|
|
18
|
+
requirements: z.record(z.number()).optional().describe("Requirements (e.g., {level: 5, strength: 10})"),
|
|
19
|
+
tags: z.array(z.string().max(LIMITS.NAME_MAX)).max(LIMITS.ARRAY_MAX).optional().describe("Tags for categorization"),
|
|
20
|
+
},
|
|
21
|
+
annotations: ANNOTATIONS.CREATE,
|
|
22
|
+
}, async (params) => {
|
|
23
|
+
const ability = abilityTools.createAbility(params);
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: JSON.stringify(ability, null, 2) }],
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
server.registerTool("get_ability", {
|
|
29
|
+
description: "Get an ability by ID",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
abilityId: z.string().max(100).describe("The ability ID"),
|
|
32
|
+
},
|
|
33
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
34
|
+
}, async ({ abilityId }) => {
|
|
35
|
+
const ability = abilityTools.getAbility(abilityId);
|
|
36
|
+
if (!ability) {
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: "Ability not found" }],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text", text: JSON.stringify(ability, null, 2) }],
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
server.registerTool("update_ability", {
|
|
47
|
+
description: "Update an ability's details",
|
|
48
|
+
inputSchema: {
|
|
49
|
+
abilityId: z.string().max(100).describe("The ability ID"),
|
|
50
|
+
name: z.string().max(LIMITS.NAME_MAX).optional().describe("New name"),
|
|
51
|
+
description: z.string().max(LIMITS.DESCRIPTION_MAX).optional().describe("New description"),
|
|
52
|
+
category: z.string().max(100).nullable().optional().describe("New category"),
|
|
53
|
+
cost: z.record(z.number()).optional().describe("New cost"),
|
|
54
|
+
cooldown: z.number().nullable().optional().describe("New cooldown"),
|
|
55
|
+
effects: z.array(z.string().max(LIMITS.DESCRIPTION_MAX)).max(LIMITS.ARRAY_MAX).optional().describe("New effects"),
|
|
56
|
+
requirements: z.record(z.number()).optional().describe("New requirements"),
|
|
57
|
+
tags: z.array(z.string().max(LIMITS.NAME_MAX)).max(LIMITS.ARRAY_MAX).optional().describe("New tags"),
|
|
58
|
+
},
|
|
59
|
+
annotations: ANNOTATIONS.UPDATE,
|
|
60
|
+
}, async ({ abilityId, ...updates }) => {
|
|
61
|
+
const ability = abilityTools.updateAbility(abilityId, updates);
|
|
62
|
+
if (!ability) {
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: "Ability not found" }],
|
|
65
|
+
isError: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: "text", text: JSON.stringify(ability, null, 2) }],
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
server.registerTool("delete_ability", {
|
|
73
|
+
description: "Delete an ability",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
abilityId: z.string().max(100).describe("The ability ID"),
|
|
76
|
+
},
|
|
77
|
+
annotations: ANNOTATIONS.DESTRUCTIVE,
|
|
78
|
+
}, async ({ abilityId }) => {
|
|
79
|
+
const success = abilityTools.deleteAbility(abilityId);
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: success ? "Ability deleted" : "Ability not found" }],
|
|
82
|
+
isError: !success,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
server.registerTool("list_abilities", {
|
|
86
|
+
description: "List abilities with optional filters",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
89
|
+
ownerType: z.enum(["template", "character"]).optional().describe("Filter by owner type"),
|
|
90
|
+
ownerId: z.string().max(100).optional().describe("Filter by owner ID"),
|
|
91
|
+
category: z.string().max(100).optional().describe("Filter by category"),
|
|
92
|
+
},
|
|
93
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
94
|
+
}, async ({ gameId, ...filter }) => {
|
|
95
|
+
const abilities = abilityTools.listAbilities(gameId, filter);
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text", text: JSON.stringify(abilities, null, 2) }],
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
server.registerTool("learn_ability", {
|
|
101
|
+
description: "Copy a template ability to a character",
|
|
102
|
+
inputSchema: {
|
|
103
|
+
templateId: z.string().max(100).describe("The template ability ID"),
|
|
104
|
+
characterId: z.string().max(100).describe("The character ID to learn the ability"),
|
|
105
|
+
},
|
|
106
|
+
annotations: ANNOTATIONS.CREATE,
|
|
107
|
+
}, async ({ templateId, characterId }) => {
|
|
108
|
+
const ability = abilityTools.learnAbility(templateId, characterId);
|
|
109
|
+
if (!ability) {
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: "text", text: "Template not found or is not a template" }],
|
|
112
|
+
isError: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: JSON.stringify(ability, null, 2) }],
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
server.registerTool("use_ability", {
|
|
120
|
+
description: "Use an ability (checks cooldown, sets new cooldown)",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
abilityId: z.string().max(100).describe("The ability ID"),
|
|
123
|
+
characterId: z.string().max(100).describe("The character using the ability"),
|
|
124
|
+
},
|
|
125
|
+
annotations: ANNOTATIONS.UPDATE,
|
|
126
|
+
}, async ({ abilityId, characterId }) => {
|
|
127
|
+
const result = abilityTools.useAbility(abilityId, characterId);
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
130
|
+
isError: !result.success,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
server.registerTool("tick_ability_cooldowns", {
|
|
134
|
+
description: "Reduce all ability cooldowns (call at end of round)",
|
|
135
|
+
inputSchema: {
|
|
136
|
+
gameId: z.string().max(100).describe("The game ID"),
|
|
137
|
+
amount: z.number().optional().describe("Rounds to tick (default: 1)"),
|
|
138
|
+
},
|
|
139
|
+
annotations: ANNOTATIONS.UPDATE,
|
|
140
|
+
}, async ({ gameId, amount }) => {
|
|
141
|
+
const updated = abilityTools.tickCooldowns(gameId, amount);
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text", text: JSON.stringify({ updatedCount: updated.length, abilities: updated }, null, 2) }],
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
server.registerTool("check_ability_requirements", {
|
|
147
|
+
description: "Check if a character meets an ability's requirements",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
abilityId: z.string().max(100).describe("The ability ID"),
|
|
150
|
+
characterId: z.string().max(100).describe("The character ID"),
|
|
151
|
+
},
|
|
152
|
+
annotations: ANNOTATIONS.READ_ONLY,
|
|
153
|
+
}, async ({ abilityId, characterId }) => {
|
|
154
|
+
const result = abilityTools.checkRequirements(abilityId, characterId);
|
|
155
|
+
if (!result) {
|
|
156
|
+
return {
|
|
157
|
+
content: [{ type: "text", text: "Ability or character not found" }],
|
|
158
|
+
isError: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as characterTools from "../../tools/character.js";
|
|
3
|
+
import * as narrativeTools from "../../tools/narrative.js";
|
|
4
|
+
import * as combatTools from "../tools/combat.js";
|
|
5
|
+
import { LIMITS } from "../../utils/validation.js";
|
|
6
|
+
import { ANNOTATIONS } from "../../utils/tool-annotations.js";
|
|
7
|
+
// The one multi-entity workflow tool that reaches into combat, split out of
|
|
8
|
+
// src/register/batch.ts (design §8, issue #17): everything else there --
|
|
9
|
+
// batch_create_npcs, scene_transition, get_character_context,
|
|
10
|
+
// get_location_context -- touches only entity/property concepts and stays in
|
|
11
|
+
// core. This one calls combatTools.startCombat, so it is genuinely
|
|
12
|
+
// game-shaped and belongs in the RPG layer above it, not beside it.
|
|
13
|
+
export function registerRpgBatchTools(server) {
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// SETUP COMBAT ENCOUNTER - Create NPCs, start combat with all participants
|
|
16
|
+
// ============================================================================
|
|
17
|
+
server.registerTool("setup_combat_encounter", {
|
|
18
|
+
description: "Complete combat setup in one call: creates enemy NPCs and starts combat with all participants (enemies + players at location). Returns the ready-to-play combat state.",
|
|
19
|
+
inputSchema: {
|
|
20
|
+
gameId: z.string().describe("The game ID"),
|
|
21
|
+
locationId: z.string().describe("Location where combat takes place"),
|
|
22
|
+
enemies: z
|
|
23
|
+
.array(z.object({
|
|
24
|
+
name: z.string().min(1).max(LIMITS.NAME_MAX).describe("Enemy name"),
|
|
25
|
+
attributes: z.record(z.string(), z.number()).optional(),
|
|
26
|
+
status: z
|
|
27
|
+
.object({
|
|
28
|
+
health: z.number().optional(),
|
|
29
|
+
maxHealth: z.number().optional(),
|
|
30
|
+
})
|
|
31
|
+
.optional(),
|
|
32
|
+
}))
|
|
33
|
+
.min(1)
|
|
34
|
+
.max(10)
|
|
35
|
+
.describe("Enemies to create and add to combat"),
|
|
36
|
+
includePlayersAtLocation: z
|
|
37
|
+
.boolean()
|
|
38
|
+
.default(true)
|
|
39
|
+
.describe("Auto-add player characters at this location"),
|
|
40
|
+
},
|
|
41
|
+
annotations: ANNOTATIONS.CREATE,
|
|
42
|
+
}, async ({ gameId, locationId, enemies, includePlayersAtLocation = true }) => {
|
|
43
|
+
// 1. Create enemy NPCs
|
|
44
|
+
const createdEnemies = [];
|
|
45
|
+
for (const enemy of enemies) {
|
|
46
|
+
const character = characterTools.createCharacter({
|
|
47
|
+
gameId,
|
|
48
|
+
name: enemy.name,
|
|
49
|
+
isPlayer: false,
|
|
50
|
+
attributes: enemy.attributes,
|
|
51
|
+
status: enemy.status,
|
|
52
|
+
locationId,
|
|
53
|
+
});
|
|
54
|
+
createdEnemies.push(character);
|
|
55
|
+
}
|
|
56
|
+
// 2. Get players at location if needed
|
|
57
|
+
const participantIds = [...createdEnemies.map((e) => e.id)];
|
|
58
|
+
if (includePlayersAtLocation) {
|
|
59
|
+
const playersAtLocation = characterTools.listCharacters(gameId, {
|
|
60
|
+
isPlayer: true,
|
|
61
|
+
locationId,
|
|
62
|
+
});
|
|
63
|
+
participantIds.push(...playersAtLocation.map((p) => p.id));
|
|
64
|
+
}
|
|
65
|
+
// 3. Start combat with all participants
|
|
66
|
+
const combat = combatTools.startCombat({
|
|
67
|
+
gameId,
|
|
68
|
+
locationId,
|
|
69
|
+
participantIds,
|
|
70
|
+
});
|
|
71
|
+
// 4. Log the encounter start
|
|
72
|
+
narrativeTools.logEvent({
|
|
73
|
+
gameId,
|
|
74
|
+
eventType: "combat",
|
|
75
|
+
content: `Combat begins! ${createdEnemies.length} enemies attack.`,
|
|
76
|
+
metadata: {
|
|
77
|
+
enemyIds: createdEnemies.map((e) => e.id),
|
|
78
|
+
locationId,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const result = {
|
|
82
|
+
combat,
|
|
83
|
+
createdEnemies: createdEnemies.map((e) => ({ id: e.id, name: e.name })),
|
|
84
|
+
participantCount: participantIds.length,
|
|
85
|
+
summary: `Combat started with ${participantIds.length} participants. ${createdEnemies.length} enemies created.`,
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
89
|
+
structuredContent: result,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|