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
@@ -1,6 +1,29 @@
1
1
  import { v4 as uuidv4 } from "uuid";
2
2
  import { getDatabase, withTransaction } from "../db/connection.js";
3
3
  import { validateGameExists } from "./game.js";
4
+ import { writeConstrainedValue, valueHistory } from "../timeline/constrained.js";
5
+ /**
6
+ * Maps a timeline `ValueTransition` (src/timeline/constrained.ts) onto the
7
+ * public `RelationshipChange` shape every existing caller of
8
+ * modifyRelationship()/updateRelationshipValue()/getRelationshipHistory()
9
+ * already expects. Mirrors transitionToResourceChange() in
10
+ * src/tools/resource.ts exactly, minus `delta` -- RelationshipChange has no
11
+ * `delta` field (src/types/index.ts) and never has, so none is invented
12
+ * here. `id` prefers the annotation event's id, falling back to the fact id
13
+ * for a transition no constrained write annotated; `timestamp` prefers the
14
+ * wall-clock `at` the choke point stamped, falling back to the timeline
15
+ * coordinate `t` for a transition with no wall-clock moment to report.
16
+ */
17
+ function transitionToRelationshipChange(transition) {
18
+ return {
19
+ id: transition.eventId ?? transition.factId ?? "",
20
+ relationshipId: transition.entityId,
21
+ previousValue: transition.previousValue,
22
+ newValue: transition.newValue,
23
+ reason: transition.reason,
24
+ timestamp: transition.at ?? String(transition.t),
25
+ };
26
+ }
4
27
  export function createRelationship(params) {
5
28
  // Validate game exists to prevent orphaned records
6
29
  validateGameExists(params.gameId);
@@ -73,6 +96,19 @@ export function getRelationshipBetween(gameId, sourceId, targetId, relationshipT
73
96
  updatedAt: row.updated_at,
74
97
  };
75
98
  }
99
+ /**
100
+ * Metadata updater that also accepts a new value, written by a single
101
+ * direct UPDATE -- deliberately NOT routed through writeConstrainedValue()
102
+ * (src/timeline/constrained.ts). Its value write therefore lands as an
103
+ * UNANNOTATED transition in valueHistory()/getRelationshipHistory() below
104
+ * (reason: null, eventId: null) rather than one a constrained write
105
+ * stamped -- which is still strictly MORE history than the old
106
+ * relationship_history table ever held for this path, since nothing wrote
107
+ * there for a plain updateRelationship() call either. Mirrors
108
+ * updateResource() in src/tools/resource.ts, whose own value-adjacent write
109
+ * (re-clamping on a bounds change) is likewise a direct column write, not a
110
+ * choke-point one.
111
+ */
76
112
  export function updateRelationship(id, updates) {
77
113
  const db = getDatabase();
78
114
  const current = getRelationship(id);
@@ -97,25 +133,34 @@ export function updateRelationship(id, updates) {
97
133
  updatedAt: now,
98
134
  };
99
135
  }
100
- function logRelationshipChange(relationshipId, previousValue, newValue, reason) {
101
- const db = getDatabase();
102
- const id = uuidv4();
103
- const now = new Date().toISOString();
104
- db.prepare(`
105
- INSERT INTO relationship_history (id, relationship_id, previous_value, new_value, reason, timestamp)
106
- VALUES (?, ?, ?, ?, ?, ?)
107
- `).run(id, relationshipId, previousValue, newValue, reason, now);
108
- return {
109
- id,
110
- relationshipId,
111
- previousValue,
112
- newValue,
113
- reason,
114
- timestamp: now,
115
- };
116
- }
136
+ /**
137
+ * Modify a relationship's value by a delta, with optional bounds. Delegates
138
+ * the value write entirely to writeConstrainedValue() (src/timeline/
139
+ * constrained.ts) -- the one choke point every constrained numeric fact key
140
+ * writes through, mirroring updateResourceValue() in src/tools/resource.ts.
141
+ * The resolve/check/clamp/write/annotate sequence, and the atomicity of the
142
+ * write and its audit trail, all live there now.
143
+ *
144
+ * Logs unconditionally, including for a zero delta -- exactly as this
145
+ * function always has. The choke point itself already handles a no-op
146
+ * write: it opens no new fact (there is nothing to version when the value
147
+ * doesn't move), but it still writes the annotation event, and
148
+ * valueHistory()/getRelationshipHistory() below surface that event as a
149
+ * history row regardless. So a zero-delta call still produces a row with no
150
+ * extra code here -- see conserved.test.ts's "logged even though nothing
151
+ * moved" assertions on the resource side of the same mechanism.
152
+ *
153
+ * `updated_at` gets its own UPDATE, in the same withTransaction() as the
154
+ * value write, for the same reason updateRelationshipValue() below writes
155
+ * its metadata separately: the choke point writes ONE column -- the
156
+ * constrained fact key -- and nothing else, because a writer that also
157
+ * touched neighbouring columns would be making policy about them. This
158
+ * function's `updated_at` bump is that policy, so it stays here, where it
159
+ * always was. Dropping it would be a silent regression: listRelationships()
160
+ * orders by `updated_at DESC`, so a relationship modified through this path
161
+ * would stop sorting as recently touched.
162
+ */
117
163
  export function modifyRelationship(params) {
118
- const db = getDatabase();
119
164
  const relationship = getRelationship(params.relationshipId);
120
165
  if (!relationship)
121
166
  return null;
@@ -123,27 +168,23 @@ export function modifyRelationship(params) {
123
168
  if (params.minValue !== undefined && params.maxValue !== undefined && params.minValue > params.maxValue) {
124
169
  throw new Error(`Invalid bounds: minValue (${params.minValue}) cannot be greater than maxValue (${params.maxValue})`);
125
170
  }
126
- const previousValue = relationship.value;
127
- let newValue = previousValue + params.delta;
128
- // Apply bounds (clamp to min first, then max to ensure max takes precedence)
129
- if (params.minValue !== undefined) {
130
- newValue = Math.max(newValue, params.minValue);
131
- }
132
- if (params.maxValue !== undefined) {
133
- newValue = Math.min(newValue, params.maxValue);
134
- }
135
171
  const now = new Date().toISOString();
136
- // The value update and its history row must land together -- otherwise a
137
- // failure between the two leaves a changed relationship value with no
138
- // audit trail explaining why it changed.
139
- const change = withTransaction(() => {
140
- db.prepare(`UPDATE relationships SET value = ?, updated_at = ? WHERE id = ?`)
141
- .run(newValue, now, params.relationshipId);
142
- return logRelationshipChange(params.relationshipId, previousValue, newValue, params.reason || null);
172
+ const transition = withTransaction(() => {
173
+ getDatabase()
174
+ .prepare(`UPDATE relationships SET updated_at = ? WHERE id = ?`)
175
+ .run(now, params.relationshipId);
176
+ return writeConstrainedValue({
177
+ entityId: params.relationshipId,
178
+ key: "value",
179
+ mode: "delta",
180
+ value: params.delta,
181
+ reason: params.reason || null,
182
+ bounds: { minValue: params.minValue ?? null, maxValue: params.maxValue ?? null },
183
+ });
143
184
  });
144
185
  return {
145
- relationship: { ...relationship, value: newValue, updatedAt: now },
146
- change,
186
+ relationship: { ...relationship, value: transition.newValue, updatedAt: now },
187
+ change: transitionToRelationshipChange(transition),
147
188
  };
148
189
  }
149
190
  export function deleteRelationship(id) {
@@ -192,27 +233,53 @@ export function listRelationships(gameId, filter) {
192
233
  updatedAt: row.updated_at,
193
234
  }));
194
235
  }
236
+ /**
237
+ * Every recorded change to a relationship's value, newest first. Built
238
+ * entirely from the timeline (valueHistory() in src/timeline/
239
+ * constrained.ts) -- there is no `relationship_history` table backing this
240
+ * any more (design §5.4 option (C); see the freeze trigger in
241
+ * src/db/schema.ts). Mirrors getResourceHistory() in src/tools/resource.ts
242
+ * exactly, and is a strict superset of what `relationship_history` ever
243
+ * held for the same reason: it also surfaces transitions no constrained
244
+ * write annotated (e.g. updateRelationship()'s direct column write above),
245
+ * which the old table simply never recorded.
246
+ */
195
247
  export function getRelationshipHistory(relationshipId, limit) {
196
- const db = getDatabase();
197
- let query = `SELECT * FROM relationship_history WHERE relationship_id = ? ORDER BY timestamp DESC`;
198
- const params = [relationshipId];
199
- if (limit) {
200
- query += ` LIMIT ?`;
201
- params.push(limit);
202
- }
203
- const rows = db.prepare(query).all(...params);
204
- return rows.map(row => ({
205
- id: row.id,
206
- relationshipId: row.relationship_id,
207
- previousValue: row.previous_value,
208
- newValue: row.new_value,
209
- reason: row.reason,
210
- timestamp: row.timestamp,
211
- }));
248
+ return valueHistory(relationshipId, "value", limit).map(transitionToRelationshipChange);
212
249
  }
213
250
  /**
214
- * Update a relationship value with optional metadata changes. Supports both direct set and delta modes.
215
- * Logs to history when value changes.
251
+ * Update a relationship value with optional metadata changes. Supports both
252
+ * direct set and delta modes. Logs to history when the value changes; no
253
+ * history row (and `change: null`) when it doesn't -- that "no-op means no
254
+ * row" contract is this function's own, distinct from modifyRelationship()
255
+ * above, which always logs (see its doc comment). The choke point itself
256
+ * ALWAYS leaves a trace of a write, including a no-op one (an annotation
257
+ * event with no fact behind it -- see applyLiveWrite() in
258
+ * src/timeline/constrained.ts), so writeConstrainedValue() below is called
259
+ * ONLY when newValue !== previousValue -- routing a genuine no-op through it
260
+ * would put a row in getRelationshipHistory() that this contract says must
261
+ * not appear.
262
+ *
263
+ * `newValue` is computed and clamped here, BEFORE the choke-point call, for
264
+ * two reasons: first, to make that changed/unchanged decision at all;
265
+ * second, so the value handed to writeConstrainedValue() (mode: "set") is
266
+ * already the caller's real intent -- the choke point's own clamp against
267
+ * the same `bounds` is then a no-op on an already-clamped number, and its
268
+ * constraint check (assertConstraintsAllow(), src/timeline/constrained.ts)
269
+ * still sees a real, meaningful intended value rather than a raw delta.
270
+ *
271
+ * The metadata columns (relationship_type, label, notes, updated_at) are
272
+ * written by their own UPDATE, separate from the value write below, but
273
+ * both run inside one withTransaction() so the whole call is still one
274
+ * atomic unit -- exactly as before, just as two statements against
275
+ * `relationships` instead of one. That is a real, visible consequence of
276
+ * routing the value column through the one choke point every constrained
277
+ * write goes through: each statement fires the table's own projection
278
+ * trigger (projection.ts) independently, so a value-changing call now
279
+ * advances the timeline's `t` twice and logs two `relationship.updated`
280
+ * events instead of one. One write path for "what did this value used to
281
+ * be" is worth that -- see constrained.ts's own header comment on why a
282
+ * second write path is the failure this project keeps rediscovering.
216
283
  */
217
284
  export function updateRelationshipValue(params) {
218
285
  const db = getDatabase();
@@ -242,19 +309,29 @@ export function updateRelationshipValue(params) {
242
309
  const newType = params.relationshipType ?? relationship.relationshipType;
243
310
  const newLabel = params.label !== undefined ? params.label : relationship.label;
244
311
  const newNotes = params.notes ?? relationship.notes;
245
- // The value/metadata update and its (conditional) history row must land
246
- // together, for the same reason as modifyRelationship() above.
312
+ const bounds = { minValue: params.minValue ?? null, maxValue: params.maxValue ?? null };
313
+ // The metadata update and the (conditional) value write must land
314
+ // together, for the same reason as modifyRelationship() above -- a
315
+ // failure between the two must never leave a changed value with no
316
+ // metadata update applied, or vice versa.
247
317
  const change = withTransaction(() => {
248
318
  db.prepare(`
249
319
  UPDATE relationships
250
- SET relationship_type = ?, value = ?, label = ?, notes = ?, updated_at = ?
320
+ SET relationship_type = ?, label = ?, notes = ?, updated_at = ?
251
321
  WHERE id = ?
252
- `).run(newType, newValue, newLabel, newNotes, now, params.relationshipId);
253
- // Log to history if value changed
254
- if (newValue !== previousValue) {
255
- return logRelationshipChange(params.relationshipId, previousValue, newValue, params.reason || null);
322
+ `).run(newType, newLabel, newNotes, now, params.relationshipId);
323
+ if (newValue === previousValue) {
324
+ return null;
256
325
  }
257
- return null;
326
+ const transition = writeConstrainedValue({
327
+ entityId: params.relationshipId,
328
+ key: "value",
329
+ mode: "set",
330
+ value: newValue,
331
+ reason: params.reason || null,
332
+ bounds,
333
+ });
334
+ return transitionToRelationshipChange(transition);
258
335
  });
259
336
  return {
260
337
  relationship: {
@@ -27,6 +27,14 @@ export declare function listResources(gameId: string, filter?: {
27
27
  /**
28
28
  * Update a resource's value - either by delta or absolute set.
29
29
  * Use mode: "delta" to add/subtract, mode: "set" to set an absolute value.
30
+ *
31
+ * Delegates entirely to writeConstrainedValue() (src/timeline/constrained.ts)
32
+ * -- the resolve/check/clamp/write/annotate sequence, and the atomicity of
33
+ * the write and its audit trail, all live there now. This function's own job
34
+ * is narrower than it used to be: translate the resource-shaped call into
35
+ * the generic (entityId, factKey) one, and translate the generic
36
+ * ValueTransition result back into the Resource/ResourceChange shapes every
37
+ * existing caller already expects.
30
38
  */
31
39
  export declare function updateResourceValue(params: {
32
40
  resourceId: string;
@@ -40,9 +48,9 @@ export declare function updateResourceValue(params: {
40
48
  /**
41
49
  * Move `amount` from one resource to another, atomically. This is the ONLY
42
50
  * write path for a resource that is a member of a declared 'conserved'
43
- * constraint -- checkResourceConstraints() (constraint.ts) rejects
44
- * update_resource_value against such a resource specifically because a
45
- * single-resource write can't express where the counterpart delta comes
51
+ * constraint -- assertConstraintsAllow() (src/timeline/constrained.ts)
52
+ * rejects update_resource_value against such a resource specifically because
53
+ * a single-resource write can't express where the counterpart delta comes
46
54
  * from. This function is that counterpart-carrying write.
47
55
  *
48
56
  * WHY AN EXPLICIT TRANSFER TOOL, NOT A BALANCED MULTI-RESOURCE WRITE:
@@ -69,15 +77,22 @@ export declare function updateResourceValue(params: {
69
77
  * be members of the SAME declared 'conserved' constraint. This is not a
70
78
  * general "move value between any two resources" tool -- for anything not
71
79
  * under a 'conserved' constraint, update_resource_value remains the right
72
- * tool (see checkResourceConstraints()). Keeping the two write paths
73
- * mutually exclusive per resource means which one to use is never
74
- * ambiguous.
80
+ * tool (see assertConstraintsAllow() in src/timeline/constrained.ts). Keeping
81
+ * the two write paths mutually exclusive per resource means which one to use
82
+ * is never ambiguous.
75
83
  *
76
84
  * Never clamps. Clamping one side of a transfer would apply an uneven delta
77
85
  * -- the source would lose less (or the destination gain less) than the
78
86
  * other side moved by, silently creating or destroying value -- so any
79
87
  * bound violation on either side rejects the whole transfer instead,
80
88
  * regardless of whether a 'bounded' constraint is separately declared.
89
+ *
90
+ * Keeps its own argument validation (self-transfer, non-finite, negative,
91
+ * not-found) -- that is about resource IDENTITY, not about the declared
92
+ * constraint family, so it stays here rather than moving into
93
+ * transferConstrainedValue() (src/timeline/constrained.ts), which delegates
94
+ * the actual membership check, the bounded/monotonic checks, the never-clamp
95
+ * bounds rejection, and both atomic writes.
81
96
  */
82
97
  export declare function transferResourceValue(params: {
83
98
  fromResourceId: string;
@@ -90,4 +105,14 @@ export declare function transferResourceValue(params: {
90
105
  fromChange: ResourceChange;
91
106
  toChange: ResourceChange;
92
107
  };
108
+ /**
109
+ * Every recorded change to a resource's value, newest first. Built entirely
110
+ * from the timeline (valueHistory() in src/timeline/constrained.ts) -- there
111
+ * is no `resource_history` table backing this any more (design §5.4 option
112
+ * (C); see the freeze trigger in src/db/schema.ts). This is a strict
113
+ * superset of what `resource_history` ever held: it also surfaces
114
+ * transitions no constrained write annotated (a direct column write, a
115
+ * bounds re-clamp, a startup reconciliation), which the old table simply
116
+ * never recorded.
117
+ */
93
118
  export declare function getResourceHistory(resourceId: string, limit?: number): ResourceChange[];
@@ -1,7 +1,8 @@
1
1
  import { v4 as uuidv4 } from "uuid";
2
- import { getDatabase, withTransaction } from "../db/connection.js";
2
+ import { getDatabase } from "../db/connection.js";
3
3
  import { validateGameExists } from "./game.js";
4
- import { checkResourceConstraints, checkBoundedAndMonotonicConstraints, getConservedConstraintFor, ConstraintViolationError, CONSERVED_SUM_EPSILON, } from "./constraint.js";
4
+ import { ConstraintViolationError, conservedConstraintFor } from "../timeline/registry.js";
5
+ import { assertConstraintsAllow, writeConstrainedValue, transferConstrainedValue, valueHistory, } from "../timeline/constrained.js";
5
6
  function clampValue(value, minValue, maxValue) {
6
7
  let result = value;
7
8
  if (minValue !== null)
@@ -11,24 +12,27 @@ function clampValue(value, minValue, maxValue) {
11
12
  return result;
12
13
  }
13
14
  /**
14
- * Reject (never clamp) a transfer leg that would push `resource` outside
15
- * its own minValue/maxValue. Used only by transferResourceValue() -- see
16
- * the doc comment there for why a transfer never clamps, even when the
17
- * resource has no separately-declared 'bounded' constraint.
15
+ * Maps a timeline `ValueTransition` (src/timeline/constrained.ts) onto the
16
+ * public `ResourceChange` shape every existing caller of
17
+ * updateResourceValue()/transferResourceValue()/getResourceHistory() already
18
+ * expects. `id` prefers the annotation event's id, falling back to the fact
19
+ * id for a transition no constrained write annotated (a direct column
20
+ * write, a bounds re-clamp, a startup reconciliation) -- either is a real,
21
+ * unique identifier for the row, and a transition can never lack both.
22
+ * `timestamp` prefers the wall-clock `at` the choke point stamped; an
23
+ * unannotated transition has no wall-clock moment to report, so its
24
+ * timeline coordinate `t` is the honest answer instead of inventing one.
18
25
  */
19
- function assertWithinBoundsForTransfer(resource, intendedValue, role) {
20
- if (resource.minValue !== null && intendedValue < resource.minValue) {
21
- throw new ConstraintViolationError("conserved", resource.id, `Transfer rejected: '${resource.name}' (${resource.id}) would go below its minimum value ` +
22
- `(${resource.minValue}) as the ${role} of this transfer. transfer_resource_value never clamps -- ` +
23
- `clamping one side of a transfer would apply an uneven delta and silently create or destroy value. ` +
24
- `Choose a smaller amount.`);
25
- }
26
- if (resource.maxValue !== null && intendedValue > resource.maxValue) {
27
- throw new ConstraintViolationError("conserved", resource.id, `Transfer rejected: '${resource.name}' (${resource.id}) would exceed its maximum value ` +
28
- `(${resource.maxValue}) as the ${role} of this transfer. transfer_resource_value never clamps -- ` +
29
- `clamping one side of a transfer would apply an uneven delta and silently create or destroy value. ` +
30
- `Choose a smaller amount.`);
31
- }
26
+ function transitionToResourceChange(transition) {
27
+ return {
28
+ id: transition.eventId ?? transition.factId ?? "",
29
+ resourceId: transition.entityId,
30
+ previousValue: transition.previousValue,
31
+ newValue: transition.newValue,
32
+ delta: transition.delta,
33
+ reason: transition.reason,
34
+ timestamp: transition.at ?? String(transition.t),
35
+ };
32
36
  }
33
37
  export function createResource(params) {
34
38
  // Validate game exists to prevent orphaned records
@@ -98,14 +102,28 @@ export function updateResource(id, updates) {
98
102
  // its own -- silently, with no counterpart adjustment -- which is exactly
99
103
  // the kind of isolated write that breaks the set's total. Reject instead;
100
104
  // the caller can still change bounds that don't affect the current value.
105
+ //
106
+ // Routed through assertConstraintsAllow() (src/timeline/constrained.ts) --
107
+ // the single site the whole constraint family (monotonic/bounded/conserved)
108
+ // is evaluated -- rather than throwing directly, so this is not a second
109
+ // place the 'conserved' rule is written down. `context` carries this
110
+ // guard's own explanation so the thrown message still names update_resource
111
+ // (what this caller actually did), not a generic mention of
112
+ // update_resource_value. No `bounds` is passed: this call is not itself
113
+ // testing this resource's own min/max against a declared 'bounded'
114
+ // constraint (updateResourceValue does that); it exists only to refuse an
115
+ // isolated conserved-member value change.
101
116
  if (newValue !== current.value) {
102
- const conserved = getConservedConstraintFor(id);
103
- if (conserved) {
104
- throw new ConstraintViolationError("conserved", id, `Resource '${id}' is a member of a 'conserved' constraint (id '${conserved.id}', total ${conserved.total}) ` +
105
- `and its value cannot be changed by update_resource, including indirectly by narrowing minValue/maxValue ` +
117
+ assertConstraintsAllow({
118
+ entityId: id,
119
+ key: "value",
120
+ previousValue: current.value,
121
+ intendedValue: newValue,
122
+ conservedMemberWrite: "reject",
123
+ context: `and its value cannot be changed by update_resource, including indirectly by narrowing minValue/maxValue ` +
106
124
  `so the current value would be reclamped. Rejected instead of silently changing the value -- use ` +
107
- `transfer_resource_value if the value itself needs to move, or choose bounds that don't affect the current value.`);
108
- }
125
+ `transfer_resource_value if the value itself needs to move, or choose bounds that don't affect the current value.`,
126
+ });
109
127
  }
110
128
  const stmt = db.prepare(`
111
129
  UPDATE resources
@@ -129,7 +147,12 @@ export function deleteResource(id) {
129
147
  // CASCADE on resource_constraint_members, would silently remove it from
130
148
  // the set rather than raise any error). Reject instead: the caller must
131
149
  // remove_resource_constraint first if the set itself is being redefined.
132
- const conserved = getConservedConstraintFor(id);
150
+ //
151
+ // This is not a value-change check -- deleting a resource doesn't move
152
+ // any number -- so it reads the registry directly (conservedConstraintFor,
153
+ // key-scoped to 'value') rather than going through assertConstraintsAllow,
154
+ // which exists to evaluate an INTENDED value change.
155
+ const conserved = conservedConstraintFor(id, "value");
133
156
  if (conserved) {
134
157
  throw new ConstraintViolationError("conserved", id, `Resource '${id}' is a member of a 'conserved' constraint (id '${conserved.id}', total ${conserved.total}) ` +
135
158
  `and cannot be deleted while that constraint exists -- deleting it would shrink the set below its declared ` +
@@ -174,66 +197,41 @@ export function listResources(gameId, filter) {
174
197
  createdAt: row.created_at,
175
198
  }));
176
199
  }
177
- function logChange(resourceId, previousValue, newValue, reason) {
178
- const db = getDatabase();
179
- const id = uuidv4();
180
- const now = new Date().toISOString();
181
- const delta = newValue - previousValue;
182
- const stmt = db.prepare(`
183
- INSERT INTO resource_history (id, resource_id, previous_value, new_value, delta, reason, timestamp)
184
- VALUES (?, ?, ?, ?, ?, ?, ?)
185
- `);
186
- stmt.run(id, resourceId, previousValue, newValue, delta, reason, now);
187
- return {
188
- id,
189
- resourceId,
190
- previousValue,
191
- newValue,
192
- delta,
193
- reason,
194
- timestamp: now,
195
- };
196
- }
197
200
  /**
198
201
  * Update a resource's value - either by delta or absolute set.
199
202
  * Use mode: "delta" to add/subtract, mode: "set" to set an absolute value.
203
+ *
204
+ * Delegates entirely to writeConstrainedValue() (src/timeline/constrained.ts)
205
+ * -- the resolve/check/clamp/write/annotate sequence, and the atomicity of
206
+ * the write and its audit trail, all live there now. This function's own job
207
+ * is narrower than it used to be: translate the resource-shaped call into
208
+ * the generic (entityId, factKey) one, and translate the generic
209
+ * ValueTransition result back into the Resource/ResourceChange shapes every
210
+ * existing caller already expects.
200
211
  */
201
212
  export function updateResourceValue(params) {
202
213
  const resource = getResource(params.resourceId);
203
214
  if (!resource)
204
215
  return null;
205
- const previousValue = resource.value;
206
- const intendedValue = params.mode === "delta" ? previousValue + params.value : params.value;
207
- // Opt-in constraint enforcement: throws ConstraintViolationError and
208
- // leaves the row untouched if this resource has a declared 'bounded' or
209
- // 'monotonic' constraint that `intendedValue` would violate. Resources
210
- // with no declared constraint are unaffected by this call and fall
211
- // through to the existing clamp behavior below, unchanged.
212
- checkResourceConstraints(params.resourceId, previousValue, intendedValue, {
213
- minValue: resource.minValue,
214
- maxValue: resource.maxValue,
215
- });
216
- const newValue = clampValue(intendedValue, resource.minValue, resource.maxValue);
217
- // The value update and its resource_history row must land together --
218
- // otherwise a failure between the two leaves a changed value with no
219
- // audit trail explaining why it changed.
220
- const change = withTransaction(() => {
221
- const db = getDatabase();
222
- const stmt = db.prepare(`UPDATE resources SET value = ? WHERE id = ?`);
223
- stmt.run(newValue, params.resourceId);
224
- return logChange(params.resourceId, previousValue, newValue, params.reason || null);
216
+ const transition = writeConstrainedValue({
217
+ entityId: params.resourceId,
218
+ key: "value",
219
+ mode: params.mode,
220
+ value: params.value,
221
+ reason: params.reason ?? null,
222
+ bounds: { minValue: resource.minValue, maxValue: resource.maxValue },
225
223
  });
226
224
  return {
227
- resource: { ...resource, value: newValue },
228
- change,
225
+ resource: { ...resource, value: transition.newValue },
226
+ change: transitionToResourceChange(transition),
229
227
  };
230
228
  }
231
229
  /**
232
230
  * Move `amount` from one resource to another, atomically. This is the ONLY
233
231
  * write path for a resource that is a member of a declared 'conserved'
234
- * constraint -- checkResourceConstraints() (constraint.ts) rejects
235
- * update_resource_value against such a resource specifically because a
236
- * single-resource write can't express where the counterpart delta comes
232
+ * constraint -- assertConstraintsAllow() (src/timeline/constrained.ts)
233
+ * rejects update_resource_value against such a resource specifically because
234
+ * a single-resource write can't express where the counterpart delta comes
237
235
  * from. This function is that counterpart-carrying write.
238
236
  *
239
237
  * WHY AN EXPLICIT TRANSFER TOOL, NOT A BALANCED MULTI-RESOURCE WRITE:
@@ -260,15 +258,22 @@ export function updateResourceValue(params) {
260
258
  * be members of the SAME declared 'conserved' constraint. This is not a
261
259
  * general "move value between any two resources" tool -- for anything not
262
260
  * under a 'conserved' constraint, update_resource_value remains the right
263
- * tool (see checkResourceConstraints()). Keeping the two write paths
264
- * mutually exclusive per resource means which one to use is never
265
- * ambiguous.
261
+ * tool (see assertConstraintsAllow() in src/timeline/constrained.ts). Keeping
262
+ * the two write paths mutually exclusive per resource means which one to use
263
+ * is never ambiguous.
266
264
  *
267
265
  * Never clamps. Clamping one side of a transfer would apply an uneven delta
268
266
  * -- the source would lose less (or the destination gain less) than the
269
267
  * other side moved by, silently creating or destroying value -- so any
270
268
  * bound violation on either side rejects the whole transfer instead,
271
269
  * regardless of whether a 'bounded' constraint is separately declared.
270
+ *
271
+ * Keeps its own argument validation (self-transfer, non-finite, negative,
272
+ * not-found) -- that is about resource IDENTITY, not about the declared
273
+ * constraint family, so it stays here rather than moving into
274
+ * transferConstrainedValue() (src/timeline/constrained.ts), which delegates
275
+ * the actual membership check, the bounded/monotonic checks, the never-clamp
276
+ * bounds rejection, and both atomic writes.
272
277
  */
273
278
  export function transferResourceValue(params) {
274
279
  if (params.fromResourceId === params.toResourceId) {
@@ -289,86 +294,34 @@ export function transferResourceValue(params) {
289
294
  if (!to) {
290
295
  throw new Error(`Resource '${params.toResourceId}' not found.`);
291
296
  }
292
- const fromConstraint = getConservedConstraintFor(from.id);
293
- const toConstraint = getConservedConstraintFor(to.id);
294
- if (!fromConstraint || !toConstraint || fromConstraint.id !== toConstraint.id) {
295
- const details = [];
296
- if (!fromConstraint)
297
- details.push(`'${from.name}' (${from.id}) is not a member of any 'conserved' constraint.`);
298
- if (!toConstraint)
299
- details.push(`'${to.name}' (${to.id}) is not a member of any 'conserved' constraint.`);
300
- if (fromConstraint && toConstraint && fromConstraint.id !== toConstraint.id) {
301
- details.push(`They belong to different 'conserved' constraints ('${fromConstraint.id}' and '${toConstraint.id}').`);
302
- }
303
- throw new ConstraintViolationError("conserved", from.id, `transfer_resource_value requires fromResourceId and toResourceId to both be members of the same declared ` +
304
- `'conserved' constraint -- moving value between resources outside a shared conserved set would change ` +
305
- `each side's total independently, which is what update_resource_value is for. ${details.join(" ")}`);
306
- }
307
- const fromPrev = from.value;
308
- const toPrev = to.value;
309
- const fromIntended = fromPrev - params.amount;
310
- const toIntended = toPrev + params.amount;
311
- // Bounded/monotonic constraints, if separately declared, apply during a
312
- // transfer exactly as they do during a direct write.
313
- checkBoundedAndMonotonicConstraints(from.id, fromPrev, fromIntended, {
314
- minValue: from.minValue,
315
- maxValue: from.maxValue,
316
- });
317
- checkBoundedAndMonotonicConstraints(to.id, toPrev, toIntended, {
318
- minValue: to.minValue,
319
- maxValue: to.maxValue,
320
- });
321
- // Never clamp (see doc comment above): reject outright if either side's
322
- // own minValue/maxValue would be violated, even without a declared
323
- // 'bounded' constraint.
324
- assertWithinBoundsForTransfer(from, fromIntended, "source");
325
- assertWithinBoundsForTransfer(to, toIntended, "destination");
326
- const reason = params.reason || null;
327
- const constraintId = fromConstraint.id;
328
- const declaredTotal = fromConstraint.total ?? 0;
329
- const memberIds = fromConstraint.resourceIds;
330
- return withTransaction(() => {
331
- const db = getDatabase();
332
- db.prepare(`UPDATE resources SET value = ? WHERE id = ?`).run(fromIntended, from.id);
333
- db.prepare(`UPDATE resources SET value = ? WHERE id = ?`).run(toIntended, to.id);
334
- const fromChange = logChange(from.id, fromPrev, fromIntended, reason);
335
- const toChange = logChange(to.id, toPrev, toIntended, reason);
336
- // Defense in depth: re-read every member of the set (inside this same
337
- // transaction, so this sees the writes above) and assert it still sums
338
- // to the declared total. The primary guarantee is structural (an equal
339
- // and opposite delta, above) -- this turns any future bug in this
340
- // function, or a schema change that opens another write path around it,
341
- // into a loud rollback instead of a silently wrong total.
342
- const currentSum = memberIds.reduce((sum, id) => sum + (getResource(id)?.value ?? 0), 0);
343
- if (Math.abs(currentSum - declaredTotal) > CONSERVED_SUM_EPSILON) {
344
- throw new Error(`Invariant check failed after transfer: conserved constraint '${constraintId}' members now sum to ` +
345
- `${currentSum}, expected ${declaredTotal}. Rolling back.`);
346
- }
347
- return {
348
- from: { ...from, value: fromIntended },
349
- to: { ...to, value: toIntended },
350
- fromChange,
351
- toChange,
352
- };
297
+ const { from: fromTransition, to: toTransition } = transferConstrainedValue({
298
+ fromEntityId: from.id,
299
+ toEntityId: to.id,
300
+ key: "value",
301
+ amount: params.amount,
302
+ reason: params.reason ?? null,
303
+ fromBounds: { minValue: from.minValue, maxValue: from.maxValue },
304
+ toBounds: { minValue: to.minValue, maxValue: to.maxValue },
305
+ fromLabel: from.name,
306
+ toLabel: to.name,
353
307
  });
308
+ return {
309
+ from: { ...from, value: fromTransition.newValue },
310
+ to: { ...to, value: toTransition.newValue },
311
+ fromChange: transitionToResourceChange(fromTransition),
312
+ toChange: transitionToResourceChange(toTransition),
313
+ };
354
314
  }
315
+ /**
316
+ * Every recorded change to a resource's value, newest first. Built entirely
317
+ * from the timeline (valueHistory() in src/timeline/constrained.ts) -- there
318
+ * is no `resource_history` table backing this any more (design §5.4 option
319
+ * (C); see the freeze trigger in src/db/schema.ts). This is a strict
320
+ * superset of what `resource_history` ever held: it also surfaces
321
+ * transitions no constrained write annotated (a direct column write, a
322
+ * bounds re-clamp, a startup reconciliation), which the old table simply
323
+ * never recorded.
324
+ */
355
325
  export function getResourceHistory(resourceId, limit) {
356
- const db = getDatabase();
357
- let query = `SELECT * FROM resource_history WHERE resource_id = ? ORDER BY timestamp DESC`;
358
- const params = [resourceId];
359
- if (limit !== undefined) {
360
- query += ` LIMIT ?`;
361
- params.push(limit);
362
- }
363
- const stmt = db.prepare(query);
364
- const rows = stmt.all(...params);
365
- return rows.map((row) => ({
366
- id: row.id,
367
- resourceId: row.resource_id,
368
- previousValue: row.previous_value,
369
- newValue: row.new_value,
370
- delta: row.delta,
371
- reason: row.reason,
372
- timestamp: row.timestamp,
373
- }));
326
+ return valueHistory(resourceId, "value", limit).map(transitionToResourceChange);
374
327
  }