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.
Files changed (141) hide show
  1. package/README.md +101 -11
  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 +594 -10
  8. package/dist/http/server.js +25 -4
  9. package/dist/index.d.ts +69 -2
  10. package/dist/index.js +262 -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 +13 -6
  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 +108 -0
  59. package/dist/timeline/changes.js +169 -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 +181 -0
  67. package/dist/timeline/export.js +339 -0
  68. package/dist/timeline/irreversible.d.ts +87 -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 +86 -0
  83. package/dist/timeline/replay.js +126 -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 +264 -0
  88. package/dist/timeline/t.d.ts +80 -0
  89. package/dist/timeline/t.js +37 -0
  90. package/dist/tools/audio.js +13 -9
  91. package/dist/tools/constraint.d.ts +44 -80
  92. package/dist/tools/constraint.js +115 -124
  93. package/dist/tools/game.js +33 -1
  94. package/dist/tools/images.js +17 -10
  95. package/dist/tools/relationship.d.ts +83 -2
  96. package/dist/tools/relationship.js +139 -62
  97. package/dist/tools/resource.d.ts +33 -8
  98. package/dist/tools/resource.js +106 -153
  99. package/dist/tools/time.js +18 -3
  100. package/dist/types/index.d.ts +20 -2
  101. package/dist/utils/media-path.d.ts +52 -0
  102. package/dist/utils/media-path.js +106 -0
  103. package/dist/utils/output-schemas.d.ts +594 -3
  104. package/dist/utils/output-schemas.js +4 -1
  105. package/dist/utils/webui.d.ts +32 -0
  106. package/dist/utils/webui.js +54 -1
  107. package/package.json +25 -5
  108. package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
  109. package/dist/__tests__/engineVocabulary.test.js +0 -147
  110. package/dist/db/__tests__/connection.test.d.ts +0 -1
  111. package/dist/db/__tests__/connection.test.js +0 -72
  112. package/dist/db/__tests__/testDb.d.ts +0 -33
  113. package/dist/db/__tests__/testDb.js +0 -41
  114. package/dist/test-setup.d.ts +0 -1
  115. package/dist/test-setup.js +0 -13
  116. package/dist/tools/__tests__/audio.test.d.ts +0 -1
  117. package/dist/tools/__tests__/audio.test.js +0 -59
  118. package/dist/tools/__tests__/conserved.test.d.ts +0 -1
  119. package/dist/tools/__tests__/conserved.test.js +0 -488
  120. package/dist/tools/__tests__/constraint.test.d.ts +0 -1
  121. package/dist/tools/__tests__/constraint.test.js +0 -212
  122. package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
  123. package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
  124. package/dist/tools/__tests__/images.test.d.ts +0 -1
  125. package/dist/tools/__tests__/images.test.js +0 -59
  126. package/dist/tools/__tests__/relationship.test.d.ts +0 -1
  127. package/dist/tools/__tests__/relationship.test.js +0 -132
  128. package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
  129. package/dist/tools/__tests__/resource-constraints.test.js +0 -131
  130. package/dist/tools/__tests__/resource.test.d.ts +0 -1
  131. package/dist/tools/__tests__/resource.test.js +0 -190
  132. package/dist/tools/__tests__/time.test.d.ts +0 -1
  133. package/dist/tools/__tests__/time.test.js +0 -404
  134. package/dist/tools/__tests__/timers.test.d.ts +0 -1
  135. package/dist/tools/__tests__/timers.test.js +0 -426
  136. package/dist/tools/__tests__/world.test.d.ts +0 -1
  137. package/dist/tools/__tests__/world.test.js +0 -70
  138. package/dist/utils/__tests__/json.test.d.ts +0 -1
  139. package/dist/utils/__tests__/json.test.js +0 -55
  140. package/dist/utils/__tests__/validation.test.d.ts +0 -1
  141. package/dist/utils/__tests__/validation.test.js +0 -90
@@ -0,0 +1,262 @@
1
+ import { type T } from "./t.js";
2
+ import { type NarrationConstraint, type Contradiction } from "./narration.js";
3
+ import { type ValueTransition } from "./constrained.js";
4
+ /**
5
+ * The inbound half of authority (design §5.2a, GitHub issue #10): propose ->
6
+ * adjudicate -> outcome. The engine enforces the PROTOCOL -- resolution
7
+ * happens before narration, writes go through the audited path, declared
8
+ * constraints are checked -- WITHOUT knowing what any particular mechanic
9
+ * *means*. A caller registers its mechanics; the engine dispatches them by
10
+ * name and never learns what the name is for.
11
+ *
12
+ * REGISTRATION IS INJECTION AT CONSTRUCTION, never a global registry --
13
+ * modeled directly on `initializeSchema({ migrations })`
14
+ * (src/db/schema.ts's `SchemaMigration`), and for the identical reason that
15
+ * module states for itself: a registry would make behaviour depend on
16
+ * module import order and on import-time side effects, which is exactly the
17
+ * disease the library/application entry-point split (src/index.ts vs
18
+ * src/bin/run-dmcp.ts) cured. A parameter passed to `createResolver` cannot
19
+ * be "registered too late" -- it is either in the array a caller handed to
20
+ * the one call that matters, or it does not exist yet as far as this module
21
+ * is concerned.
22
+ *
23
+ * ONE CONSUMER, AND THAT IS FINE (design §5.2a, root CLAUDE.md hard rule
24
+ * 1). Core membership here is "generic, with at least one real caller," not
25
+ * "needed by every consumer" -- this protocol has exactly one caller today,
26
+ * and belongs in the core anyway because it is entirely generic: nothing
27
+ * below this line has any opinion about what a "mechanic" does, only about
28
+ * the shape every mechanic's dispatch and every mechanic's write must take.
29
+ *
30
+ * THE UNIFICATION THIS FILE DEPENDS ON: `narrationConstraintAt` (the
31
+ * OUTBOUND half of authority, design §5.2b) and this module's own inbound
32
+ * precondition check are the SAME STRUCTURE read in two directions. Every
33
+ * `resolve()` call builds exactly one `NarrationConstraint` at the game's
34
+ * current `t` and uses it for BOTH purposes -- as the input to
35
+ * `contradictions()` when checking a caller's declared `expects`, and as
36
+ * the mechanic's own read surface (`AdjudicationInput.constraint`). This is
37
+ * not merely convenient reuse: it is what makes it structurally impossible
38
+ * for the outbound contract and the inbound precondition to disagree about
39
+ * what contradicts what, because they are never two comparisons, only one.
40
+ *
41
+ * THE ENGINE RECORDS DECISIONS; IT DOES NOT MAKE THEM (root CLAUDE.md hard
42
+ * rule 2, design §5.5). This is the subtlest thing in this file, worth
43
+ * saying plainly: the decision to depend on an `Expectation` was the
44
+ * CALLER's, made when it built the `Proposal` -- not the engine's, and not
45
+ * a policy the engine holds an opinion about. When an expectation does not
46
+ * hold, `resolve()` is not passing judgement on the caller's mechanic or on
47
+ * the world; it is reporting, structurally, that a precondition the caller
48
+ * itself declared does not hold at the game's current `t`. `reason` on
49
+ * `ResolveProtocolError` names which rule of the ENGINE'S OWN PROTOCOL
50
+ * refused -- never a verdict about the proposal's merits, the mechanic's
51
+ * correctness, or which side (fact or claim) is "wrong." Design §5.2c's one
52
+ * hop of causality (the contradicted fact, its `validFromT`, the event that
53
+ * opened it -- already carried on every `ConstraintFact`) is what lets a
54
+ * caller answer that question for itself; the engine only hands over the
55
+ * evidence.
56
+ *
57
+ * WHAT `resolve()` ENFORCES, IN ORDER -- each numbered comment inline below
58
+ * names which of these it is:
59
+ * 1. Unknown mechanic -> refuse before anything happens. No window opens,
60
+ * nothing is written, no query beyond the map lookup itself runs.
61
+ * 2. The game must have a clock (`currentStoryTime`) -- a resolution with
62
+ * no `t` to attach itself to is refused, naming what is missing.
63
+ * 3. Declared expectations, checked BEFORE dispatch, by reusing
64
+ * `narrationConstraintAt`/`contradictions` from narration.ts rather
65
+ * than writing a second comparison (see the unification note above).
66
+ * Any contradiction refuses without ever calling the mechanic's
67
+ * `adjudicate`.
68
+ * 4. Dispatch. The mechanic returns intents; it receives no database
69
+ * handle anywhere in `AdjudicationInput` and therefore cannot write.
70
+ * 5. Apply. ONE `withTransaction`, with `withAdjudicationOpen` nested
71
+ * INSIDE it (adjudication.ts's own doc comment asks for exactly this
72
+ * nesting, so the window row rolls back with the writes it
73
+ * authorized). Every change goes through `writeConstrainedValue` /
74
+ * `transferConstrainedValue` -- the one choke point (root CLAUDE.md
75
+ * hard rule 7) -- never a direct write. A constraint violation
76
+ * anywhere in the list propagates out of the transaction untouched
77
+ * (never caught and re-labelled here) and rolls back EVERY change the
78
+ * transaction made, including ones that individually would have
79
+ * succeeded.
80
+ * 6. Record one `resolution.recorded` event, inside that SAME
81
+ * transaction, with `causes` carrying the resolution id, the
82
+ * mechanic's name, and the change count -- following
83
+ * `applyLiveWrite`'s (constrained.ts) `causes` discipline of never
84
+ * including a `row_id` key, which belongs to the projection triggers'
85
+ * own vocabulary (see that function's comment on why colliding with it
86
+ * would make `findOpenedByEventId`'s pick non-deterministic).
87
+ * 7. Build the outcome's constraint AFTER the writes have landed --
88
+ * re-reading `currentStoryTime` inside the same transaction, after
89
+ * every change has been applied, so a `sequence`-axis game (whose `t`
90
+ * advances once per write) is queried at the `t` its own writes
91
+ * actually produced, not the `t` the resolution merely started at.
92
+ * This is what makes "resolution precedes narration" a PROTOCOL
93
+ * property rather than a convention design §5.2c already names as a
94
+ * real asymmetry: a caller reading `outcome.constraint` is reading
95
+ * state that could only exist once every write in this resolution had
96
+ * already committed.
97
+ */
98
+ /** One fact a `Proposal` declares it depends on -- the caller's own
99
+ * precondition, verified before the mechanic it names is ever dispatched.
100
+ * No `t` field: the claim is always evaluated at the game's current story
101
+ * time, the same `t` the mechanic itself is dispatched at (see the module
102
+ * doc comment's unification note). */
103
+ export interface Expectation {
104
+ entityId: string;
105
+ key: string;
106
+ value: string | number;
107
+ }
108
+ /** What a caller asks the engine to resolve. `parameters` is opaque to the
109
+ * engine -- handed to the named mechanic verbatim, never inspected here,
110
+ * the same way `Claim` (narration.ts) carries no `text` field: this module
111
+ * compares declared facts against declared expectations, never the shape
112
+ * or meaning of a mechanic's own arguments. */
113
+ export interface Proposal {
114
+ gameId: string;
115
+ mechanic: string;
116
+ parameters?: Record<string, unknown>;
117
+ expects?: readonly Expectation[];
118
+ }
119
+ /**
120
+ * What a `Mechanic`'s `adjudicate` receives -- and ALL it receives. There is
121
+ * no database handle anywhere in this shape, which is the entire mechanism
122
+ * by which "every write goes through the audited path" is enforced: a
123
+ * mechanic that never sees a connection cannot open one of its own, so the
124
+ * only way its intent reaches storage at all is by returning `changes` for
125
+ * `resolve()` itself to apply (step 5 above) through the one choke point.
126
+ * `constraint` is the mechanic's read surface -- the world at `t`, exactly
127
+ * as `narrationConstraintAt` (narration.ts) would serialize it for the
128
+ * outbound half of authority; a mechanic that needs to know what currently
129
+ * holds reads it from here, never from a query of its own.
130
+ */
131
+ export interface AdjudicationInput {
132
+ gameId: string;
133
+ mechanic: string;
134
+ t: T;
135
+ parameters: Record<string, unknown>;
136
+ constraint: NarrationConstraint;
137
+ }
138
+ /** One intended write to a single fact key -- the generic shape
139
+ * `writeConstrainedValue` (constrained.ts) already takes, carried here so a
140
+ * mechanic can express "change this value" without ever calling that
141
+ * function itself. */
142
+ export interface IntendedWrite {
143
+ kind: "write";
144
+ entityId: string;
145
+ key: string;
146
+ mode: "delta" | "set";
147
+ value: number;
148
+ reason?: string | null;
149
+ bounds?: {
150
+ minValue: number | null;
151
+ maxValue: number | null;
152
+ };
153
+ }
154
+ /** One intended two-leg transfer between conserved members -- the generic
155
+ * shape `transferConstrainedValue` (constrained.ts) already takes. */
156
+ export interface IntendedTransfer {
157
+ kind: "transfer";
158
+ fromEntityId: string;
159
+ toEntityId: string;
160
+ key: string;
161
+ amount: number;
162
+ reason?: string | null;
163
+ fromBounds?: {
164
+ minValue: number | null;
165
+ maxValue: number | null;
166
+ };
167
+ toBounds?: {
168
+ minValue: number | null;
169
+ maxValue: number | null;
170
+ };
171
+ }
172
+ export type IntendedChange = IntendedWrite | IntendedTransfer;
173
+ /**
174
+ * What a mechanic returns. `changes` are intents, not writes -- `resolve()`
175
+ * applies every one of them through the one choke point (step 5); the
176
+ * mechanic itself performs none of them. `result` is opaque to the engine,
177
+ * carried to the `Outcome` verbatim and never inspected -- the same
178
+ * discipline `Proposal.parameters` observes for the inbound side. There is
179
+ * no severity field, no `ok`/`valid`/`success` anywhere in this shape (root
180
+ * CLAUDE.md hard rule 2): a mechanic reports what happened, and whether
181
+ * that counts as a win, a loss, or nothing at all is a question this engine
182
+ * has no opinion about and no field to hold one in.
183
+ */
184
+ export interface Adjudication {
185
+ changes?: readonly IntendedChange[];
186
+ result?: Record<string, unknown>;
187
+ description?: string;
188
+ }
189
+ /**
190
+ * The recorded result of a completed resolution. `constraint` is reachable
191
+ * ONLY by way of a completed `resolve()` call -- there is no function that
192
+ * hands back "the constraint a resolution would produce" without actually
193
+ * running one -- which is what makes "resolution precedes narration" true
194
+ * of the API's shape, not merely of how this module happens to be
195
+ * implemented today.
196
+ */
197
+ export interface Outcome {
198
+ resolutionId: string;
199
+ gameId: string;
200
+ mechanic: string;
201
+ t: T;
202
+ result: Record<string, unknown>;
203
+ transitions: ValueTransition[];
204
+ constraint: NarrationConstraint;
205
+ eventId: string;
206
+ }
207
+ /** A mechanic a caller registers at construction (`createResolver`). `name`
208
+ * is a token the engine dispatches on and stores in exactly one place
209
+ * (the internal name -> mechanic map) -- it is never parsed, matched
210
+ * against a pattern, or read for meaning anywhere in this module (root
211
+ * CLAUDE.md hard rule 4). */
212
+ export interface Mechanic {
213
+ /** A name the engine dispatches on and never interprets. */
214
+ name: string;
215
+ adjudicate(input: AdjudicationInput): Adjudication;
216
+ }
217
+ /** Which rule of the engine's OWN PROTOCOL a `resolve()` call refused
218
+ * under -- never a judgement about the proposal, the mechanic, or the
219
+ * world (see the module doc comment's "records decisions, does not make
220
+ * them" paragraph). */
221
+ export type ResolveRefusalReason = "unknown-mechanic" | "no-clock" | "expectation-contradicted";
222
+ /**
223
+ * Refused before dispatch, before any write, or (never, by construction --
224
+ * see step 5 above) mid-apply. `reason` is the discriminant a caller
225
+ * switches on; `contradictions` is populated ONLY for
226
+ * `"expectation-contradicted"`, mirroring `ConstraintViolationError`'s own
227
+ * `contradictedFact` (registry.ts), which is likewise set only for its one
228
+ * relevant `constraintKind` rather than carrying a fourth sentinel value for
229
+ * every other reason.
230
+ *
231
+ * Deliberately NOT what a constraint violation during apply (step 5) throws
232
+ * -- that is a `ConstraintViolationError` (registry.ts), propagated
233
+ * completely untouched (see the module doc comment). Wrapping it here would
234
+ * blur the one distinction a caller actually needs: a `ResolveProtocolError`
235
+ * means the PROTOCOL refused before anything was attempted; a
236
+ * `ConstraintViolationError` out of `resolve()` means the protocol was
237
+ * followed and the WORLD refused, mid-attempt, and everything already
238
+ * rolled back.
239
+ */
240
+ export declare class ResolveProtocolError extends Error {
241
+ readonly reason: ResolveRefusalReason;
242
+ readonly contradictions?: readonly Contradiction[] | undefined;
243
+ constructor(reason: ResolveRefusalReason, message: string, contradictions?: readonly Contradiction[] | undefined);
244
+ }
245
+ export interface Resolver {
246
+ resolve(proposal: Proposal): Outcome;
247
+ /** The registered names. The engine holds them; it never reads meaning
248
+ * into them. */
249
+ mechanics(): string[];
250
+ }
251
+ /**
252
+ * Builds the resolver a caller uses for the lifetime of its process.
253
+ * `mechanics` is a parameter, not a global -- see the module doc comment on
254
+ * why that is load-bearing rather than a style choice. An engine
255
+ * constructed with an empty (or omitted) mechanics list is legal: every
256
+ * `resolve()` call against it refuses with `"unknown-mechanic"`, which is
257
+ * the correct behaviour for a caller that has not registered anything, not
258
+ * a special case this function needs to guard against.
259
+ */
260
+ export declare function createResolver(params: {
261
+ mechanics: readonly Mechanic[];
262
+ }): Resolver;
@@ -0,0 +1,226 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase, withTransaction } from "../db/connection.js";
3
+ import { currentStoryTime } from "./clock.js";
4
+ import { narrationConstraintAt, contradictions } from "./narration.js";
5
+ import { withAdjudicationOpen } from "./adjudication.js";
6
+ import { writeConstrainedValue, transferConstrainedValue } from "./constrained.js";
7
+ /**
8
+ * Refused before dispatch, before any write, or (never, by construction --
9
+ * see step 5 above) mid-apply. `reason` is the discriminant a caller
10
+ * switches on; `contradictions` is populated ONLY for
11
+ * `"expectation-contradicted"`, mirroring `ConstraintViolationError`'s own
12
+ * `contradictedFact` (registry.ts), which is likewise set only for its one
13
+ * relevant `constraintKind` rather than carrying a fourth sentinel value for
14
+ * every other reason.
15
+ *
16
+ * Deliberately NOT what a constraint violation during apply (step 5) throws
17
+ * -- that is a `ConstraintViolationError` (registry.ts), propagated
18
+ * completely untouched (see the module doc comment). Wrapping it here would
19
+ * blur the one distinction a caller actually needs: a `ResolveProtocolError`
20
+ * means the PROTOCOL refused before anything was attempted; a
21
+ * `ConstraintViolationError` out of `resolve()` means the protocol was
22
+ * followed and the WORLD refused, mid-attempt, and everything already
23
+ * rolled back.
24
+ */
25
+ export class ResolveProtocolError extends Error {
26
+ reason;
27
+ contradictions;
28
+ constructor(reason, message, contradictions) {
29
+ super(message);
30
+ this.reason = reason;
31
+ this.contradictions = contradictions;
32
+ this.name = "ResolveProtocolError";
33
+ }
34
+ }
35
+ /**
36
+ * Validates a mechanic list the exact way `validateMigrations`
37
+ * (src/db/schema.ts) validates a migration list -- same three checks, same
38
+ * error voice: a non-empty string name, no duplicate name, a real
39
+ * `adjudicate` function. Run once, at construction, so a bad registration
40
+ * fails loudly before a single `resolve()` call rather than surfacing as a
41
+ * confusing "undefined is not a function" three calls later.
42
+ */
43
+ function validateMechanics(mechanics) {
44
+ const seen = new Set();
45
+ for (const mechanic of mechanics) {
46
+ const name = mechanic?.name;
47
+ if (typeof name !== "string" || name.trim().length === 0) {
48
+ throw new Error(`Invalid mechanic: 'name' must be a non-empty string, got ${JSON.stringify(name)}`);
49
+ }
50
+ if (seen.has(name)) {
51
+ throw new Error(`Duplicate mechanic name: '${name}'`);
52
+ }
53
+ seen.add(name);
54
+ if (typeof mechanic.adjudicate !== "function") {
55
+ throw new Error(`Mechanic '${name}' has no 'adjudicate' function`);
56
+ }
57
+ }
58
+ }
59
+ /**
60
+ * Builds the resolver a caller uses for the lifetime of its process.
61
+ * `mechanics` is a parameter, not a global -- see the module doc comment on
62
+ * why that is load-bearing rather than a style choice. An engine
63
+ * constructed with an empty (or omitted) mechanics list is legal: every
64
+ * `resolve()` call against it refuses with `"unknown-mechanic"`, which is
65
+ * the correct behaviour for a caller that has not registered anything, not
66
+ * a special case this function needs to guard against.
67
+ */
68
+ export function createResolver(params) {
69
+ validateMechanics(params.mechanics);
70
+ const byName = new Map();
71
+ for (const mechanic of params.mechanics) {
72
+ byName.set(mechanic.name, mechanic);
73
+ }
74
+ return {
75
+ resolve(proposal) {
76
+ return resolveProposal(byName, proposal);
77
+ },
78
+ mechanics() {
79
+ return [...byName.keys()];
80
+ },
81
+ };
82
+ }
83
+ function applyChange(change) {
84
+ if (change.kind === "write") {
85
+ return [
86
+ writeConstrainedValue({
87
+ entityId: change.entityId,
88
+ key: change.key,
89
+ mode: change.mode,
90
+ value: change.value,
91
+ reason: change.reason,
92
+ bounds: change.bounds,
93
+ }),
94
+ ];
95
+ }
96
+ if (change.kind === "transfer") {
97
+ const { from, to } = transferConstrainedValue({
98
+ fromEntityId: change.fromEntityId,
99
+ toEntityId: change.toEntityId,
100
+ key: change.key,
101
+ amount: change.amount,
102
+ reason: change.reason,
103
+ fromBounds: change.fromBounds,
104
+ toBounds: change.toBounds,
105
+ });
106
+ return [from, to];
107
+ }
108
+ // Unreachable through the exported types (`IntendedChange` is an
109
+ // exhaustive discriminated union), but a mechanic is caller-supplied code
110
+ // this module does not control at runtime -- a malformed `kind` from
111
+ // outside the type system must still fail loudly rather than silently
112
+ // apply nothing.
113
+ throw new Error(`resolve: an intended change carried an unrecognized kind '${change.kind}'`);
114
+ }
115
+ function resolveProposal(mechanicsByName, proposal) {
116
+ const { gameId, mechanic: mechanicName } = proposal;
117
+ // 1. Unknown mechanic -- refuse before anything happens. No window opens,
118
+ // nothing is written, and the only work done so far is a map lookup.
119
+ const mechanic = mechanicsByName.get(mechanicName);
120
+ if (!mechanic) {
121
+ const registered = [...mechanicsByName.keys()];
122
+ throw new ResolveProtocolError("unknown-mechanic", `resolve: '${mechanicName}' is not a mechanic registered with this resolver. ` +
123
+ (registered.length > 0
124
+ ? `Registered: ${registered.join(", ")}.`
125
+ : `This resolver has no mechanics registered at all.`) +
126
+ ` The engine dispatches a mechanic by name and never learns what the name means -- register ` +
127
+ `'${mechanicName}' at construction (createResolver) before proposing it.`);
128
+ }
129
+ // 2. The game must have a clock -- a resolution with no t to attach
130
+ // itself to is refused, naming what is missing rather than guessing at a
131
+ // default.
132
+ const preStory = currentStoryTime(gameId);
133
+ if (!preStory) {
134
+ throw new ResolveProtocolError("no-clock", `resolve: game '${gameId}' has no timeline clock yet -- nothing has been declared or written for ` +
135
+ `it, so this resolution has no t to attach to. Declare a time axis (declare_time_axis) or write ` +
136
+ `something through the normal tools first, then propose again.`);
137
+ }
138
+ const t = preStory.t;
139
+ // 3. ONE query builds both the mechanic's read surface AND the inbound
140
+ // precondition check -- see the module doc comment's unification note on
141
+ // why this is the same structure read in two directions, not two
142
+ // independently-maintained comparisons that could drift apart.
143
+ const constraint = narrationConstraintAt({ gameId, t });
144
+ const expectations = proposal.expects ?? [];
145
+ if (expectations.length > 0) {
146
+ const claims = expectations.map((expectation) => ({
147
+ entityId: expectation.entityId,
148
+ key: expectation.key,
149
+ value: expectation.value,
150
+ t,
151
+ }));
152
+ const found = contradictions(constraint, claims);
153
+ if (found.length > 0) {
154
+ throw new ResolveProtocolError("expectation-contradicted", `resolve: ${found.length} declared expectation(s) for mechanic '${mechanicName}' do not hold at ` +
155
+ `t=${t}; refused before dispatch. The caller declared these preconditions when it built the ` +
156
+ `proposal -- the engine only reports that they do not hold, carrying one hop of causality per ` +
157
+ `contradiction (design §5.2c) so a caller can tell whether the fact is wrong or the claim is.`, found);
158
+ }
159
+ }
160
+ // 4. Dispatch. The mechanic sees gameId/mechanic/t/parameters/constraint
161
+ // and NOTHING else -- no database handle exists anywhere in
162
+ // AdjudicationInput, so the only way its intent can reach storage is by
163
+ // returning `changes` for step 5 to apply.
164
+ const adjudication = mechanic.adjudicate({
165
+ gameId,
166
+ mechanic: mechanicName,
167
+ t,
168
+ parameters: proposal.parameters ?? {},
169
+ constraint,
170
+ });
171
+ const resolutionId = uuidv4();
172
+ const changes = adjudication.changes ?? [];
173
+ // 5 & 6. Apply every intended change through the one choke point, and
174
+ // record one event -- both inside ONE transaction with the adjudication
175
+ // window nested inside it (adjudication.ts's own doc comment asks for
176
+ // exactly this order), so a violation anywhere rolls back every write,
177
+ // the window row, AND the event together. Nothing here catches a
178
+ // constraint violation -- it propagates out of withTransaction untouched,
179
+ // by design (see the module doc comment on why a ConstraintViolationError
180
+ // is never relabelled as a ResolveProtocolError).
181
+ const applied = withTransaction(() => withAdjudicationOpen(gameId, () => {
182
+ const transitions = [];
183
+ for (const change of changes) {
184
+ transitions.push(...applyChange(change));
185
+ }
186
+ // Re-read the clock AFTER every write has landed, inside this same
187
+ // transaction -- a sequence-axis game advances its own t once per
188
+ // write (projection.ts), so the t this resolution's writes actually
189
+ // produced can be later than the t it started at. The event this
190
+ // resolution records, and the constraint the caller receives back,
191
+ // both belong at THAT t, not at the one captured before dispatch.
192
+ const postStory = currentStoryTime(gameId);
193
+ if (!postStory) {
194
+ // Cannot happen in practice -- entities/facts/events are
195
+ // append-only and nothing deletes a timeline_clock row -- but this
196
+ // function has no business assuming that silently forever.
197
+ throw new Error(`resolve: game '${gameId}' lost its timeline clock mid-resolution -- cannot record the outcome event`);
198
+ }
199
+ const eventId = uuidv4();
200
+ const causes = {
201
+ source: "resolve",
202
+ resolution_id: resolutionId,
203
+ mechanic: mechanicName,
204
+ change_count: changes.length,
205
+ };
206
+ getDatabase()
207
+ .prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'resolution.recorded', ?, ?)`)
208
+ .run(eventId, gameId, postStory.t, adjudication.description ?? null, JSON.stringify(causes));
209
+ return { transitions, eventId, postT: postStory.t };
210
+ }));
211
+ // 7. The outcome's constraint, built AFTER the writes landed and the
212
+ // transaction holding them has already committed -- reachable only from a
213
+ // completed resolution, which is what makes "resolution precedes
214
+ // narration" a protocol property rather than a convention (§5.2c).
215
+ const postConstraint = narrationConstraintAt({ gameId, t: applied.postT });
216
+ return {
217
+ resolutionId,
218
+ gameId,
219
+ mechanic: mechanicName,
220
+ t,
221
+ result: adjudication.result ?? {},
222
+ transitions: applied.transitions,
223
+ constraint: postConstraint,
224
+ eventId: applied.eventId,
225
+ };
226
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The timeline substrate (design §5.1): `entities`, `facts`, `events`, and
3
+ * the per-game `timeline_clock` that tells a `sequence` axis what its next
4
+ * ordinal is. Strictly additive -- nothing here is read by any existing
5
+ * tool yet, and every statement is idempotent so calling this at every
6
+ * startup (the project's only migration mechanism; see root CLAUDE.md) is
7
+ * safe against both a fresh database and one that predates the timeline.
8
+ *
9
+ * What makes recorded `t` actually immutable is the trigger block at the
10
+ * bottom of this function, not application discipline -- see the comment
11
+ * there.
12
+ */
13
+ export declare function initializeTimelineSchema(): void;