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.
Files changed (135) hide show
  1. package/README.md +76 -10
  2. package/dist/bin/run-dmcp.d.ts +2 -0
  3. package/dist/bin/run-dmcp.js +55 -0
  4. package/dist/db/connection.d.ts +32 -0
  5. package/dist/db/connection.js +38 -16
  6. package/dist/db/schema.d.ts +29 -1
  7. package/dist/db/schema.js +439 -7
  8. package/dist/http/server.js +3 -3
  9. package/dist/index.d.ts +36 -2
  10. package/dist/index.js +184 -92
  11. package/dist/mcp-server.d.ts +49 -0
  12. package/dist/mcp-server.js +127 -0
  13. package/dist/reader/turnReader.d.ts +185 -0
  14. package/dist/reader/turnReader.js +288 -0
  15. package/dist/register/batch.js +5 -79
  16. package/dist/register/mcp-resources.d.ts +9 -0
  17. package/dist/register/mcp-resources.js +16 -62
  18. package/dist/register/render.d.ts +17 -0
  19. package/dist/register/render.js +50 -0
  20. package/dist/register/resolve.d.ts +14 -0
  21. package/dist/register/resolve.js +102 -0
  22. package/dist/register/resources.js +11 -4
  23. package/dist/register/timeline.d.ts +2 -0
  24. package/dist/register/timeline.js +311 -0
  25. package/dist/rpg/index.d.ts +29 -0
  26. package/dist/rpg/index.js +55 -0
  27. package/dist/rpg/register/abilities.d.ts +2 -0
  28. package/dist/rpg/register/abilities.js +165 -0
  29. package/dist/rpg/register/batch.d.ts +2 -0
  30. package/dist/rpg/register/batch.js +92 -0
  31. package/dist/rpg/register/combat.d.ts +2 -0
  32. package/dist/rpg/register/combat.js +207 -0
  33. package/dist/rpg/register/mcp-prompts.d.ts +2 -0
  34. package/dist/rpg/register/mcp-prompts.js +684 -0
  35. package/dist/rpg/register/mcp-resources.d.ts +2 -0
  36. package/dist/rpg/register/mcp-resources.js +61 -0
  37. package/dist/rpg/register/quests.d.ts +2 -0
  38. package/dist/rpg/register/quests.js +118 -0
  39. package/dist/rpg/register/status.d.ts +2 -0
  40. package/dist/rpg/register/status.js +130 -0
  41. package/dist/rpg/register/tables.d.ts +2 -0
  42. package/dist/rpg/register/tables.js +146 -0
  43. package/dist/rpg/tools/ability.d.ts +48 -0
  44. package/dist/rpg/tools/ability.js +238 -0
  45. package/dist/rpg/tools/combat.d.ts +13 -0
  46. package/dist/rpg/tools/combat.js +195 -0
  47. package/dist/rpg/tools/dice.d.ts +23 -0
  48. package/dist/rpg/tools/dice.js +111 -0
  49. package/dist/rpg/tools/quest.d.ts +34 -0
  50. package/dist/rpg/tools/quest.js +164 -0
  51. package/dist/rpg/tools/status.d.ts +36 -0
  52. package/dist/rpg/tools/status.js +218 -0
  53. package/dist/rpg/tools/tables.d.ts +33 -0
  54. package/dist/rpg/tools/tables.js +209 -0
  55. package/dist/schemas/index.d.ts +12 -12
  56. package/dist/timeline/adjudication.d.ts +150 -0
  57. package/dist/timeline/adjudication.js +174 -0
  58. package/dist/timeline/changes.d.ts +100 -0
  59. package/dist/timeline/changes.js +161 -0
  60. package/dist/timeline/checkpoint.d.ts +69 -0
  61. package/dist/timeline/checkpoint.js +131 -0
  62. package/dist/timeline/clock.d.ts +89 -0
  63. package/dist/timeline/clock.js +173 -0
  64. package/dist/timeline/constrained.d.ts +220 -0
  65. package/dist/timeline/constrained.js +671 -0
  66. package/dist/timeline/export.d.ts +171 -0
  67. package/dist/timeline/export.js +329 -0
  68. package/dist/timeline/irreversible.d.ts +85 -0
  69. package/dist/timeline/irreversible.js +108 -0
  70. package/dist/timeline/kinds.d.ts +14 -0
  71. package/dist/timeline/kinds.js +22 -0
  72. package/dist/timeline/narration.d.ts +175 -0
  73. package/dist/timeline/narration.js +259 -0
  74. package/dist/timeline/projection.d.ts +97 -0
  75. package/dist/timeline/projection.js +330 -0
  76. package/dist/timeline/provenance.d.ts +66 -0
  77. package/dist/timeline/provenance.js +45 -0
  78. package/dist/timeline/registry.d.ts +95 -0
  79. package/dist/timeline/registry.js +124 -0
  80. package/dist/timeline/render.d.ts +121 -0
  81. package/dist/timeline/render.js +187 -0
  82. package/dist/timeline/replay.d.ts +64 -0
  83. package/dist/timeline/replay.js +104 -0
  84. package/dist/timeline/resolve.d.ts +262 -0
  85. package/dist/timeline/resolve.js +226 -0
  86. package/dist/timeline/schema.d.ts +13 -0
  87. package/dist/timeline/schema.js +262 -0
  88. package/dist/timeline/t.d.ts +80 -0
  89. package/dist/timeline/t.js +37 -0
  90. package/dist/tools/constraint.d.ts +44 -80
  91. package/dist/tools/constraint.js +115 -124
  92. package/dist/tools/relationship.d.ts +83 -2
  93. package/dist/tools/relationship.js +139 -62
  94. package/dist/tools/resource.d.ts +31 -6
  95. package/dist/tools/resource.js +106 -153
  96. package/dist/types/index.d.ts +19 -1
  97. package/dist/utils/output-schemas.d.ts +593 -2
  98. package/dist/utils/output-schemas.js +3 -0
  99. package/dist/utils/webui.d.ts +32 -0
  100. package/dist/utils/webui.js +54 -1
  101. package/package.json +20 -4
  102. package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
  103. package/dist/__tests__/engineVocabulary.test.js +0 -147
  104. package/dist/db/__tests__/connection.test.d.ts +0 -1
  105. package/dist/db/__tests__/connection.test.js +0 -72
  106. package/dist/db/__tests__/testDb.d.ts +0 -33
  107. package/dist/db/__tests__/testDb.js +0 -41
  108. package/dist/test-setup.d.ts +0 -1
  109. package/dist/test-setup.js +0 -13
  110. package/dist/tools/__tests__/audio.test.d.ts +0 -1
  111. package/dist/tools/__tests__/audio.test.js +0 -59
  112. package/dist/tools/__tests__/conserved.test.d.ts +0 -1
  113. package/dist/tools/__tests__/conserved.test.js +0 -488
  114. package/dist/tools/__tests__/constraint.test.d.ts +0 -1
  115. package/dist/tools/__tests__/constraint.test.js +0 -212
  116. package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
  117. package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
  118. package/dist/tools/__tests__/images.test.d.ts +0 -1
  119. package/dist/tools/__tests__/images.test.js +0 -59
  120. package/dist/tools/__tests__/relationship.test.d.ts +0 -1
  121. package/dist/tools/__tests__/relationship.test.js +0 -132
  122. package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
  123. package/dist/tools/__tests__/resource-constraints.test.js +0 -131
  124. package/dist/tools/__tests__/resource.test.d.ts +0 -1
  125. package/dist/tools/__tests__/resource.test.js +0 -190
  126. package/dist/tools/__tests__/time.test.d.ts +0 -1
  127. package/dist/tools/__tests__/time.test.js +0 -404
  128. package/dist/tools/__tests__/timers.test.d.ts +0 -1
  129. package/dist/tools/__tests__/timers.test.js +0 -426
  130. package/dist/tools/__tests__/world.test.d.ts +0 -1
  131. package/dist/tools/__tests__/world.test.js +0 -70
  132. package/dist/utils/__tests__/json.test.d.ts +0 -1
  133. package/dist/utils/__tests__/json.test.js +0 -55
  134. package/dist/utils/__tests__/validation.test.d.ts +0 -1
  135. package/dist/utils/__tests__/validation.test.js +0 -90
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The turn reader (design §12 seam 3, GitHub issue #15): one model call per
3
+ * unit of progress, answering the questions a server cannot answer with
4
+ * code -- "did the surplus hold", "was the tithe paid" -- structured facts a
5
+ * caller needs to keep its own world consistent, not narration.
6
+ *
7
+ * THE SPLIT, AND IT IS THE WHOLE POINT. The ENGINE owns the call, the
8
+ * citation rule, coercion, the safe default, and the fallback ladder. The
9
+ * CALLER owns the questions and the key vocabulary they are answered in.
10
+ * The engine never reads a question (`ReaderQuestion.prompt` is opaque, the
11
+ * same discipline `Proposal.parameters` observes in resolve.ts and `Claim`
12
+ * observes in narration.ts -- see those modules' own doc comments), never
13
+ * interprets an answer beyond "is this key a member of the set the caller
14
+ * declared", and never learns what any key MEANS. `"continued"` and
15
+ * `"TITHE"` are the same kind of token to this module: an opaque string
16
+ * dispatched or compared, never parsed for content.
17
+ *
18
+ * THE HARD CONSTRAINT THIS MODULE IS BUILT AROUND: `ReaderTransport` is an
19
+ * INJECTED INTERFACE, exactly like `Mechanic.adjudicate` (resolve.ts) is an
20
+ * injected interface rather than a call this module makes itself. There is
21
+ * no vendor SDK import here, no API key, no configured request URL, no
22
+ * `fetch`, no read of ambient environment configuration for any
23
+ * credential, and no network code of any kind --
24
+ * a transport is a plain async function the CALLER wrote and handed to
25
+ * `createTurnReader`, closing over whatever local model, hosted API, or
26
+ * scripted fixture it likes. This module is provably ignorant of what is on
27
+ * the other end of a transport; `noVendorTransports.test.ts` (this
28
+ * directory) enforces that mechanically by scanning every file here for
29
+ * vendor/network tokens, the same device `engineVocabulary.test.ts` uses for
30
+ * client vocabulary.
31
+ *
32
+ * RULE 4'S NARROW DOOR, AND WHY THE CITATION CHECK WALKS THROUGH IT WITHOUT
33
+ * VIOLATING IT. Root CLAUDE.md hard rule 4 forbids deriving state by
34
+ * pattern-matching meaning out of natural language -- and the citation check
35
+ * below (`quote occurs verbatim in that source's text`) is a
36
+ * `String.prototype.includes` call over text a CALLER supplied, checking for
37
+ * a span the CALLER'S OWN transport claims it read there. That is a literal
38
+ * presence test, structurally identical to the "a check for a token WE
39
+ * defined in output WE generated is fine" carve-out root CLAUDE.md states
40
+ * for itself -- except here the token is not one this codebase generated,
41
+ * it is one the caller's source text already contained, and the check is
42
+ * "does this exact byte sequence occur in that exact byte sequence", never
43
+ * "does this mean the same thing as that". No case folding, no whitespace
44
+ * trimming, no normalisation of any kind: the moment this check learned to
45
+ * ignore a difference between two strings, it would have taken one step
46
+ * toward understanding English rather than comparing text, which is exactly
47
+ * the slope hard rule 4 exists to keep this module off of. It is legal
48
+ * because it never asks what the quote or the source MEAN; it asks only
49
+ * whether one is a substring of the other.
50
+ *
51
+ * THE ENGINE RECORDS DECISIONS; IT DOES NOT MAKE THEM (hard rule 2). A
52
+ * rejected offer is a row -- `RejectedOffer.reason`, one of a fixed,
53
+ * literal, engine-defined vocabulary, never a severity or a verdict about
54
+ * whether the transport that offered it is "good" or "bad". There is no
55
+ * `confidence`, no `score`, no `valid`/`ok` field anywhere in this module.
56
+ * The caller's own `safeDefault` is likewise not this module's opinion about
57
+ * which direction is safe -- the caller declared it, per question, at
58
+ * construction; the engine has none of its own.
59
+ *
60
+ * THE FALLBACK LADDER is `transports`, tried in the caller's own order. The
61
+ * engine never learns which rung is "the local model" and which is "the
62
+ * hosted one" -- it is an ordered list of opaque functions, and a rung that
63
+ * throws, rejects, or returns something that is not an array of answers is
64
+ * treated identically: exhausted, advance to the next rung with only the
65
+ * still-unanswered questions. `attemptsPerTransport` gives one rung its own
66
+ * retry budget before the ladder gives up on it and moves on.
67
+ */
68
+ /** A question the caller wants answered. `prompt` is the caller's text and
69
+ * is never read, parsed, or matched against anything by this module --
70
+ * carried opaquely from `ReadRequest` through to a transport, the same way
71
+ * `Proposal.parameters` (resolve.ts) is handed to a mechanic verbatim. */
72
+ export interface ReaderQuestion {
73
+ id: string;
74
+ /** The caller's text. Opaque to the engine. */
75
+ prompt: string;
76
+ /** The CLOSED set of keys an answer may take. Answers are keys, never
77
+ * prose -- a transport that wants to say "mostly, with an exception"
78
+ * cannot; it must pick one of these, or the offer is discarded. */
79
+ answerKeys: readonly string[];
80
+ /** The key to fall to when the read fails. The CALLER declares which
81
+ * direction is safe; the engine has no opinion of its own. Must be a
82
+ * member of `answerKeys` -- enforced at construction. */
83
+ safeDefault: string;
84
+ }
85
+ /** One source a claim may be cited against. Caller-supplied text; the
86
+ * engine never interprets it beyond a literal substring test (see the
87
+ * module doc comment's rule-4 discussion). */
88
+ export interface ReaderSource {
89
+ id: string;
90
+ text: string;
91
+ }
92
+ /** What a transport is handed for one call: the questions still needing an
93
+ * answer (already-validly-answered questions are never re-asked -- see
94
+ * the module doc comment), and every source available to cite against. */
95
+ export interface ReadRequest {
96
+ questions: readonly ReaderQuestion[];
97
+ sources: readonly ReaderSource[];
98
+ }
99
+ /** What a transport returns: KEYS and citations, never prose. */
100
+ export interface TransportAnswer {
101
+ questionId: string;
102
+ answerKey: string;
103
+ citation: {
104
+ sourceId: string;
105
+ quote: string;
106
+ };
107
+ }
108
+ /** An injected capability, not a call this module makes itself -- see the
109
+ * module doc comment's "hard constraint" section. A transport may throw,
110
+ * reject, or return `TransportAnswer[]`; anything else (including a
111
+ * non-array return value) is treated as an unusable rung, identically to a
112
+ * throw. */
113
+ export type ReaderTransport = (request: ReadRequest) => Promise<readonly TransportAnswer[]>;
114
+ /** The literal, engine-defined reasons an offered answer is discarded.
115
+ * Never a severity, never a score -- a row names exactly which mechanical
116
+ * check failed (hard rule 2). */
117
+ export type RejectionReason = "unknown-question" | "unknown-answer-key" | "unknown-source-id" | "quote-not-in-source" | "empty-quote" | "duplicate-answer";
118
+ /** One discarded offer, carried verbatim so a reviewer can see exactly what
119
+ * was offered and why it did not count -- never summarised, never
120
+ * scored. `rung` is the index into the caller's own `transports` array
121
+ * that produced this offer; the engine reports the position, it does not
122
+ * interpret what that position means. */
123
+ export interface RejectedOffer {
124
+ reason: RejectionReason;
125
+ rung: number;
126
+ /** The offer exactly as the transport returned it. */
127
+ offer: TransportAnswer;
128
+ }
129
+ /** One row of the result: a question's final answer, whether it came from
130
+ * a transport or from the caller's own safe default, and every offer for
131
+ * that question this read discarded along the way. */
132
+ export interface AnsweredQuestion {
133
+ questionId: string;
134
+ /** Always a member of the question's own `answerKeys` -- true whether
135
+ * this came from a transport or from `safeDefault` itself. */
136
+ answerKey: string;
137
+ fromSafeDefault: boolean;
138
+ /** The index into `transports` that produced this answer; `null` when
139
+ * every rung was exhausted and the question took its safe default. */
140
+ answeredByRung: number | null;
141
+ /** `null` when `fromSafeDefault` is true -- a default was never cited
142
+ * against anything, because nothing was accepted for it to cite. */
143
+ citation: {
144
+ sourceId: string;
145
+ quote: string;
146
+ } | null;
147
+ rejected: readonly RejectedOffer[];
148
+ }
149
+ export interface ReaderResult {
150
+ /** One row per question this read declared, in the caller's own declared
151
+ * order -- deterministic regardless of which rung answered what or in
152
+ * what order a transport's own response array happened to list them. */
153
+ answers: readonly AnsweredQuestion[];
154
+ /** Offers naming a `questionId` that is not any question this read
155
+ * declared -- cannot be attached to a row in `answers` because there is
156
+ * no question for it to belong to. Always `reason: "unknown-question"`. */
157
+ unmatched: readonly RejectedOffer[];
158
+ }
159
+ export interface TurnReader {
160
+ /** Runs the ladder for this reader's declared questions against `sources`
161
+ * -- one call per unit of progress, per the module's own framing.
162
+ * `sources` varies per call; `questions`/`transports` are fixed at
163
+ * construction, because the questions and the vocabulary they are
164
+ * answered in are the caller's declared capability, not something that
165
+ * changes turn to turn. */
166
+ read(sources: readonly ReaderSource[]): Promise<ReaderResult>;
167
+ }
168
+ /**
169
+ * Builds the reader a caller uses for the lifetime of a session -- the
170
+ * `createResolver({ mechanics })` idiom (resolve.ts) copied directly:
171
+ * capability injected at construction, engine owns the protocol and never
172
+ * learns what the injected things mean. `questions` and `transports` are
173
+ * parameters, not globals, for the identical reason resolve.ts's own doc
174
+ * comment gives: a registry would make behaviour depend on import order.
175
+ *
176
+ * An empty `transports` list is legal -- every `read()` call against it
177
+ * returns every question's safe default, which is the correct behaviour
178
+ * for a caller that has not registered any capability to answer with, not
179
+ * a special case this function needs to guard against.
180
+ */
181
+ export declare function createTurnReader(params: {
182
+ questions: readonly ReaderQuestion[];
183
+ transports: readonly ReaderTransport[];
184
+ attemptsPerTransport?: number;
185
+ }): TurnReader;
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The turn reader (design §12 seam 3, GitHub issue #15): one model call per
3
+ * unit of progress, answering the questions a server cannot answer with
4
+ * code -- "did the surplus hold", "was the tithe paid" -- structured facts a
5
+ * caller needs to keep its own world consistent, not narration.
6
+ *
7
+ * THE SPLIT, AND IT IS THE WHOLE POINT. The ENGINE owns the call, the
8
+ * citation rule, coercion, the safe default, and the fallback ladder. The
9
+ * CALLER owns the questions and the key vocabulary they are answered in.
10
+ * The engine never reads a question (`ReaderQuestion.prompt` is opaque, the
11
+ * same discipline `Proposal.parameters` observes in resolve.ts and `Claim`
12
+ * observes in narration.ts -- see those modules' own doc comments), never
13
+ * interprets an answer beyond "is this key a member of the set the caller
14
+ * declared", and never learns what any key MEANS. `"continued"` and
15
+ * `"TITHE"` are the same kind of token to this module: an opaque string
16
+ * dispatched or compared, never parsed for content.
17
+ *
18
+ * THE HARD CONSTRAINT THIS MODULE IS BUILT AROUND: `ReaderTransport` is an
19
+ * INJECTED INTERFACE, exactly like `Mechanic.adjudicate` (resolve.ts) is an
20
+ * injected interface rather than a call this module makes itself. There is
21
+ * no vendor SDK import here, no API key, no configured request URL, no
22
+ * `fetch`, no read of ambient environment configuration for any
23
+ * credential, and no network code of any kind --
24
+ * a transport is a plain async function the CALLER wrote and handed to
25
+ * `createTurnReader`, closing over whatever local model, hosted API, or
26
+ * scripted fixture it likes. This module is provably ignorant of what is on
27
+ * the other end of a transport; `noVendorTransports.test.ts` (this
28
+ * directory) enforces that mechanically by scanning every file here for
29
+ * vendor/network tokens, the same device `engineVocabulary.test.ts` uses for
30
+ * client vocabulary.
31
+ *
32
+ * RULE 4'S NARROW DOOR, AND WHY THE CITATION CHECK WALKS THROUGH IT WITHOUT
33
+ * VIOLATING IT. Root CLAUDE.md hard rule 4 forbids deriving state by
34
+ * pattern-matching meaning out of natural language -- and the citation check
35
+ * below (`quote occurs verbatim in that source's text`) is a
36
+ * `String.prototype.includes` call over text a CALLER supplied, checking for
37
+ * a span the CALLER'S OWN transport claims it read there. That is a literal
38
+ * presence test, structurally identical to the "a check for a token WE
39
+ * defined in output WE generated is fine" carve-out root CLAUDE.md states
40
+ * for itself -- except here the token is not one this codebase generated,
41
+ * it is one the caller's source text already contained, and the check is
42
+ * "does this exact byte sequence occur in that exact byte sequence", never
43
+ * "does this mean the same thing as that". No case folding, no whitespace
44
+ * trimming, no normalisation of any kind: the moment this check learned to
45
+ * ignore a difference between two strings, it would have taken one step
46
+ * toward understanding English rather than comparing text, which is exactly
47
+ * the slope hard rule 4 exists to keep this module off of. It is legal
48
+ * because it never asks what the quote or the source MEAN; it asks only
49
+ * whether one is a substring of the other.
50
+ *
51
+ * THE ENGINE RECORDS DECISIONS; IT DOES NOT MAKE THEM (hard rule 2). A
52
+ * rejected offer is a row -- `RejectedOffer.reason`, one of a fixed,
53
+ * literal, engine-defined vocabulary, never a severity or a verdict about
54
+ * whether the transport that offered it is "good" or "bad". There is no
55
+ * `confidence`, no `score`, no `valid`/`ok` field anywhere in this module.
56
+ * The caller's own `safeDefault` is likewise not this module's opinion about
57
+ * which direction is safe -- the caller declared it, per question, at
58
+ * construction; the engine has none of its own.
59
+ *
60
+ * THE FALLBACK LADDER is `transports`, tried in the caller's own order. The
61
+ * engine never learns which rung is "the local model" and which is "the
62
+ * hosted one" -- it is an ordered list of opaque functions, and a rung that
63
+ * throws, rejects, or returns something that is not an array of answers is
64
+ * treated identically: exhausted, advance to the next rung with only the
65
+ * still-unanswered questions. `attemptsPerTransport` gives one rung its own
66
+ * retry budget before the ladder gives up on it and moves on.
67
+ */
68
+ /**
69
+ * Construction-time validation, in the same voice as `validateMechanics`
70
+ * (resolve.ts) and `validateMigrations` (src/db/schema.ts): every check
71
+ * that could make an invalid reader constructable runs ONCE, here, so a bad
72
+ * declaration fails loudly before a single `read()` call rather than
73
+ * surfacing as an answer silently outside the declared set three calls
74
+ * later. In particular: a reader that could ever return a key outside its
75
+ * own declared `answerKeys` must be unconstructable, which is why
76
+ * `safeDefault` membership is checked here rather than trusted.
77
+ */
78
+ function validateQuestions(questions) {
79
+ const seenIds = new Set();
80
+ for (const question of questions) {
81
+ const id = question?.id;
82
+ if (typeof id !== "string" || id.trim().length === 0) {
83
+ throw new Error(`createTurnReader: a question's 'id' must be a non-empty string, got ${JSON.stringify(id)}`);
84
+ }
85
+ if (seenIds.has(id)) {
86
+ throw new Error(`createTurnReader: duplicate question id '${id}'`);
87
+ }
88
+ seenIds.add(id);
89
+ const answerKeys = question.answerKeys;
90
+ if (!Array.isArray(answerKeys) || answerKeys.length === 0) {
91
+ throw new Error(`createTurnReader: question '${id}' must declare at least one answerKey`);
92
+ }
93
+ const seenKeys = new Set();
94
+ for (const key of answerKeys) {
95
+ if (typeof key !== "string" || key.length === 0) {
96
+ throw new Error(`createTurnReader: question '${id}' has an answerKey that is not a non-empty string: ${JSON.stringify(key)}`);
97
+ }
98
+ if (seenKeys.has(key)) {
99
+ throw new Error(`createTurnReader: question '${id}' declares duplicate answerKey '${key}'`);
100
+ }
101
+ seenKeys.add(key);
102
+ }
103
+ if (!answerKeys.includes(question.safeDefault)) {
104
+ throw new Error(`createTurnReader: question '${id}' declares safeDefault '${question.safeDefault}' which is not ` +
105
+ `one of its own answerKeys (${answerKeys.join(", ")})`);
106
+ }
107
+ }
108
+ }
109
+ function validateAttemptsPerTransport(attemptsPerTransport) {
110
+ if (!Number.isInteger(attemptsPerTransport) || attemptsPerTransport < 1) {
111
+ throw new Error(`createTurnReader: 'attemptsPerTransport' must be an integer >= 1, got ${JSON.stringify(attemptsPerTransport)}`);
112
+ }
113
+ }
114
+ /**
115
+ * Builds the reader a caller uses for the lifetime of a session -- the
116
+ * `createResolver({ mechanics })` idiom (resolve.ts) copied directly:
117
+ * capability injected at construction, engine owns the protocol and never
118
+ * learns what the injected things mean. `questions` and `transports` are
119
+ * parameters, not globals, for the identical reason resolve.ts's own doc
120
+ * comment gives: a registry would make behaviour depend on import order.
121
+ *
122
+ * An empty `transports` list is legal -- every `read()` call against it
123
+ * returns every question's safe default, which is the correct behaviour
124
+ * for a caller that has not registered any capability to answer with, not
125
+ * a special case this function needs to guard against.
126
+ */
127
+ export function createTurnReader(params) {
128
+ const attemptsPerTransport = params.attemptsPerTransport ?? 1;
129
+ validateQuestions(params.questions);
130
+ validateAttemptsPerTransport(attemptsPerTransport);
131
+ const questions = params.questions;
132
+ const transports = params.transports;
133
+ return {
134
+ read(sources) {
135
+ return runLadder(questions, transports, attemptsPerTransport, sources);
136
+ },
137
+ };
138
+ }
139
+ /**
140
+ * Whether `citation` cites a real source, non-empty, verbatim -- the
141
+ * citation rule's three conjuncts. All three must hold; the order below is
142
+ * only the order a REASON is reported in when more than one fails, and it
143
+ * runs cheapest-first: the quote must be a non-empty string, the source must
144
+ * be one that is actually in the request, and the quote must occur EXACTLY
145
+ * (`String.prototype.includes`, no case folding, no trimming, no fuzzy
146
+ * matching) inside that source's text. An offer that fails two conjuncts is
147
+ * rejected either way -- which reason it carries is a reporting detail, not
148
+ * a difference in whether it counts. Returns the first failing reason, or
149
+ * `null` when the citation is accepted -- see the module doc comment's
150
+ * rule-4 discussion for why this literal presence test does not become the
151
+ * pattern-matching-meaning it is built beside.
152
+ *
153
+ * Defensive against a citation that is missing entirely or missing a field
154
+ * -- a transport is caller-supplied code this module does not control at
155
+ * runtime, and a malformed citation must still be rejected mechanically
156
+ * rather than throwing out of this function and aborting the whole read.
157
+ */
158
+ function checkCitation(citation, sourcesById) {
159
+ const sourceId = citation?.sourceId;
160
+ const quote = citation?.quote;
161
+ if (typeof quote !== "string" || quote.length === 0) {
162
+ return "empty-quote";
163
+ }
164
+ if (typeof sourceId !== "string") {
165
+ return "unknown-source-id";
166
+ }
167
+ const source = sourcesById.get(sourceId);
168
+ if (!source) {
169
+ return "unknown-source-id";
170
+ }
171
+ if (!source.text.includes(quote)) {
172
+ return "quote-not-in-source";
173
+ }
174
+ return null;
175
+ }
176
+ /**
177
+ * Runs the fallback ladder for one `read()` call. `accepted` accumulates
178
+ * across rungs, keyed by `questionId` -- once a question is in this map its
179
+ * answer is FINAL for this read: the next rung's request omits it (rule 4,
180
+ * "questions already answered validly are NOT re-asked"), and any further
181
+ * offer for it from any rung -- including a non-compliant transport that
182
+ * answers a question it was not asked -- is rejected as `duplicate-answer`
183
+ * rather than silently overwriting the first accepted answer. That is what
184
+ * makes duplicate detection uniform across "two offers in one rung's
185
+ * response" and "a later rung re-offers an already-answered question":
186
+ * both are the same check, `accepted.has(questionId)`, evaluated at the
187
+ * moment each offer is processed.
188
+ */
189
+ async function runLadder(questions, transports, attemptsPerTransport, sources) {
190
+ const questionsById = new Map(questions.map((q) => [q.id, q]));
191
+ const sourcesById = new Map(sources.map((s) => [s.id, s]));
192
+ const accepted = new Map();
193
+ const rejectedByQuestion = new Map();
194
+ const unmatched = [];
195
+ function reject(reason, rung, offer) {
196
+ const row = { reason, rung, offer };
197
+ const question = questionsById.get(offer.questionId);
198
+ if (!question) {
199
+ unmatched.push(row);
200
+ return;
201
+ }
202
+ const existing = rejectedByQuestion.get(offer.questionId);
203
+ if (existing) {
204
+ existing.push(row);
205
+ }
206
+ else {
207
+ rejectedByQuestion.set(offer.questionId, [row]);
208
+ }
209
+ }
210
+ for (let rung = 0; rung < transports.length; rung++) {
211
+ const remaining = questions.filter((q) => !accepted.has(q.id));
212
+ if (remaining.length === 0)
213
+ break; // every question already answered -- nothing left for any further rung
214
+ const transport = transports[rung];
215
+ let answers = null;
216
+ for (let attempt = 0; attempt < attemptsPerTransport; attempt++) {
217
+ try {
218
+ const result = await transport({ questions: remaining, sources });
219
+ if (Array.isArray(result)) {
220
+ answers = result;
221
+ break;
222
+ }
223
+ // Unusable output -- treated identically to a throw: retry within
224
+ // this rung's budget, then fall through to the next rung.
225
+ }
226
+ catch {
227
+ // Threw or rejected -- retry within this rung's budget, then fall
228
+ // through to the next rung. The engine does not distinguish WHY a
229
+ // rung failed; it only advances.
230
+ }
231
+ }
232
+ if (answers === null)
233
+ continue; // rung exhausted; the next rung sees the same `remaining` set
234
+ for (const offer of answers) {
235
+ const question = questionsById.get(offer.questionId);
236
+ if (!question) {
237
+ reject("unknown-question", rung, offer);
238
+ continue;
239
+ }
240
+ if (accepted.has(offer.questionId)) {
241
+ reject("duplicate-answer", rung, offer);
242
+ continue;
243
+ }
244
+ if (!question.answerKeys.includes(offer.answerKey)) {
245
+ // Coercion to keys that ACTUALLY exist -- exact membership or
246
+ // nothing (rule 2). No fuzzy match, no case fold, no "nearest key".
247
+ reject("unknown-answer-key", rung, offer);
248
+ continue;
249
+ }
250
+ const citationProblem = checkCitation(offer.citation, sourcesById);
251
+ if (citationProblem) {
252
+ reject(citationProblem, rung, offer);
253
+ continue;
254
+ }
255
+ accepted.set(offer.questionId, {
256
+ answerKey: offer.answerKey,
257
+ rung,
258
+ citation: { sourceId: offer.citation.sourceId, quote: offer.citation.quote },
259
+ });
260
+ }
261
+ }
262
+ const answersOut = questions.map((question) => {
263
+ const rejected = rejectedByQuestion.get(question.id) ?? [];
264
+ const win = accepted.get(question.id);
265
+ if (win) {
266
+ return {
267
+ questionId: question.id,
268
+ answerKey: win.answerKey,
269
+ fromSafeDefault: false,
270
+ answeredByRung: win.rung,
271
+ citation: win.citation,
272
+ rejected,
273
+ };
274
+ }
275
+ // Every rung that could answer this question was exhausted (or none
276
+ // were ever registered) -- the caller's own declared safe direction,
277
+ // never the engine's guess (rule 3).
278
+ return {
279
+ questionId: question.id,
280
+ answerKey: question.safeDefault,
281
+ fromSafeDefault: true,
282
+ answeredByRung: null,
283
+ citation: null,
284
+ rejected,
285
+ };
286
+ });
287
+ return { answers: answersOut, unmatched };
288
+ }
@@ -2,11 +2,15 @@ import { z } from "zod";
2
2
  import * as characterTools from "../tools/character.js";
3
3
  import * as worldTools from "../tools/world.js";
4
4
  import * as inventoryTools from "../tools/inventory.js";
5
- import * as combatTools from "../tools/combat.js";
6
5
  import * as narrativeTools from "../tools/narrative.js";
7
6
  import { LIMITS } from "../utils/validation.js";
8
7
  import { ANNOTATIONS } from "../utils/tool-annotations.js";
9
8
  import { imageGenSchema, voiceSchema } from "../schemas/index.js";
9
+ // Multi-entity workflow tools over entity/property concepts -- characters,
10
+ // locations, inventory. `setup_combat_encounter` used to live here too, but
11
+ // it is the one tool in this file that reaches into combat (design §8, issue
12
+ // #17), so it moved to `src/rpg/register/batch.ts`'s `registerRpgBatchTools`
13
+ // and this file no longer imports combat at all.
10
14
  export function registerBatchTools(server) {
11
15
  // ============================================================================
12
16
  // BATCH CREATE NPCS - Create multiple NPCs at once
@@ -96,84 +100,6 @@ export function registerBatchTools(server) {
96
100
  };
97
101
  });
98
102
  // ============================================================================
99
- // SETUP COMBAT ENCOUNTER - Create NPCs, start combat with all participants
100
- // ============================================================================
101
- server.registerTool("setup_combat_encounter", {
102
- 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.",
103
- inputSchema: {
104
- gameId: z.string().describe("The game ID"),
105
- locationId: z.string().describe("Location where combat takes place"),
106
- enemies: z
107
- .array(z.object({
108
- name: z.string().min(1).max(LIMITS.NAME_MAX).describe("Enemy name"),
109
- attributes: z.record(z.string(), z.number()).optional(),
110
- status: z
111
- .object({
112
- health: z.number().optional(),
113
- maxHealth: z.number().optional(),
114
- })
115
- .optional(),
116
- }))
117
- .min(1)
118
- .max(10)
119
- .describe("Enemies to create and add to combat"),
120
- includePlayersAtLocation: z
121
- .boolean()
122
- .default(true)
123
- .describe("Auto-add player characters at this location"),
124
- },
125
- annotations: ANNOTATIONS.CREATE,
126
- }, async ({ gameId, locationId, enemies, includePlayersAtLocation = true }) => {
127
- // 1. Create enemy NPCs
128
- const createdEnemies = [];
129
- for (const enemy of enemies) {
130
- const character = characterTools.createCharacter({
131
- gameId,
132
- name: enemy.name,
133
- isPlayer: false,
134
- attributes: enemy.attributes,
135
- status: enemy.status,
136
- locationId,
137
- });
138
- createdEnemies.push(character);
139
- }
140
- // 2. Get players at location if needed
141
- const participantIds = [...createdEnemies.map((e) => e.id)];
142
- if (includePlayersAtLocation) {
143
- const playersAtLocation = characterTools.listCharacters(gameId, {
144
- isPlayer: true,
145
- locationId,
146
- });
147
- participantIds.push(...playersAtLocation.map((p) => p.id));
148
- }
149
- // 3. Start combat with all participants
150
- const combat = combatTools.startCombat({
151
- gameId,
152
- locationId,
153
- participantIds,
154
- });
155
- // 4. Log the encounter start
156
- narrativeTools.logEvent({
157
- gameId,
158
- eventType: "combat",
159
- content: `Combat begins! ${createdEnemies.length} enemies attack.`,
160
- metadata: {
161
- enemyIds: createdEnemies.map((e) => e.id),
162
- locationId,
163
- },
164
- });
165
- const result = {
166
- combat,
167
- createdEnemies: createdEnemies.map((e) => ({ id: e.id, name: e.name })),
168
- participantCount: participantIds.length,
169
- summary: `Combat started with ${participantIds.length} participants. ${createdEnemies.length} enemies created.`,
170
- };
171
- return {
172
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
173
- structuredContent: result,
174
- };
175
- });
176
- // ============================================================================
177
103
  // SCENE TRANSITION - Move characters, log event
178
104
  // ============================================================================
179
105
  server.registerTool("scene_transition", {
@@ -1,2 +1,11 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare const createGameListCallback: (suffix: string, nameFormatter: (g: {
3
+ id: string;
4
+ name: string;
5
+ }) => string) => () => Promise<{
6
+ resources: {
7
+ uri: string;
8
+ name: string;
9
+ }[];
10
+ }>;
2
11
  export declare function registerMcpResources(server: McpServer): void;