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,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;
|