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
@@ -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
  }
@@ -133,9 +133,24 @@ export function advanceTime(gameId, duration) {
133
133
  const consequenceFailures = [];
134
134
  for (const row of events) {
135
135
  const triggerTime = safeJsonParse(row.trigger_time, { year: 1, month: 1, day: 1, hour: 0, minute: 0 });
136
- // Check if event should trigger (trigger time is between previous and new time)
137
- if (compareDateTime(triggerTime, previousTime, calendarConfig) >= 0 &&
138
- compareDateTime(triggerTime, newTime, calendarConfig) <= 0) {
136
+ // Due if the trigger time has been reached -- deliberately NOT also gated
137
+ // on `triggerTime >= previousTime`.
138
+ //
139
+ // That lower bound looks like the right window ("did we cross it on THIS
140
+ // call?") and quietly broke the retry the catch block below promises. When
141
+ // a consequence throws, the transaction rolls back and the row stays
142
+ // pending, exactly as intended -- but the clock has already moved past
143
+ // triggerTime by then, because it is updated unconditionally above. On
144
+ // every subsequent call the lower bound therefore excluded the row, and
145
+ // the event sat pending forever with its consequence never applied. The
146
+ // rollback was correct and unreachable.
147
+ //
148
+ // Without the lower bound, "pending and past due" is the whole condition,
149
+ // which is the same retry semantics timers already have. The `triggered =
150
+ // 0` filter in the query above is what keeps this exactly-once: a
151
+ // successful event is marked (or, if recurring, rescheduled forward) in
152
+ // the same transaction as its consequence, so it cannot come back.
153
+ if (compareDateTime(triggerTime, newTime, calendarConfig) <= 0) {
139
154
  const eventId = row.id;
140
155
  const eventName = row.name;
141
156
  const recurring = row.recurring;
@@ -465,7 +465,7 @@ export interface Resource {
465
465
  id: string;
466
466
  gameId: string;
467
467
  ownerId: string | null;
468
- ownerType: "game" | "character";
468
+ ownerType: "game" | "character" | "faction" | "location";
469
469
  name: string;
470
470
  description: string;
471
471
  category: string | null;
@@ -483,7 +483,24 @@ export interface ResourceChange {
483
483
  reason: string | null;
484
484
  timestamp: string;
485
485
  }
486
- export type ConstraintKind = "bounded" | "monotonic" | "conserved";
486
+ /** `resolve_only` (design §5.3, §5.2a; issue #13) is the fourth row-based
487
+ * member: every DIRECT write to the fact key it governs is refused, so the
488
+ * value can move only through an adjudicating call (issue #10's resolver,
489
+ * which OPENS the window `src/timeline/adjudication.ts` defines and
490
+ * `assertConstraintsAllow` / the `timeline_facts_resolve_only` trigger both
491
+ * read). Unlike `bounded`/`monotonic`/`conserved`, it carries no shape of
492
+ * its own to check a value against -- `direction` and `total` are always
493
+ * null for it, exactly as they are for whichever of the OTHER three kinds a
494
+ * given row isn't. It is scoped per `factKey` like every member of this
495
+ * family (`resource_constraints.fact_key`, design §5.4 option (C)): a
496
+ * `resolve_only` constraint declared on one fact key of an entity never
497
+ * reaches a write to a different fact key of that same entity. */
498
+ export type ConstraintKind = "bounded" | "monotonic" | "conserved" | "resolve_only";
499
+ /** The five members of design §5.3's constraint family. `irreversible` is
500
+ * never a row in `resource_constraints` -- it is a flag on a fact -- so it is
501
+ * not a `ConstraintKind`, but it IS something a violation can be reported
502
+ * for. */
503
+ export type DeclaredConstraintKind = ConstraintKind | "irreversible";
487
504
  /** For 'monotonic': the only direction the value is allowed to move. */
488
505
  export type MonotonicDirection = "increasing" | "decreasing";
489
506
  export interface ResourceConstraint {
@@ -493,6 +510,7 @@ export interface ResourceConstraint {
493
510
  resourceIds: string[];
494
511
  direction: MonotonicDirection | null;
495
512
  total: number | null;
513
+ factKey: string;
496
514
  createdAt: string;
497
515
  }
498
516
  export interface GameDateTime {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The one place a path beneath a media root is composed or resolved.
3
+ *
4
+ * Media file paths are built from ids that arrive over the wire -- a game id,
5
+ * an entity id, an entity type -- and are then handed to `writeFileSync` and,
6
+ * worse, to a recursive `rmSync`. `join()` resolves `..` happily, so an id
7
+ * carrying one walks out of the data directory before the write. The rule this
8
+ * breaks is the engine's: it writes nothing to the consumer's machine that the
9
+ * consumer did not name.
10
+ *
11
+ * The guard is here, at the composition point, rather than at each call site,
12
+ * because a per-site check is one forgotten site away from being no check. Two
13
+ * things are asserted, and both are literal checks over characters -- never an
14
+ * attempt to read meaning out of a value (root CLAUDE.md hard rule 4):
15
+ *
16
+ * 1. Every path segment matches an allowlist. Every id the engine mints is a
17
+ * UUID and every entity type is an ASCII word, so the allowlist costs
18
+ * nothing the engine actually uses.
19
+ * 2. The resolved result is strictly beneath the resolved root -- a
20
+ * structural backstop that holds even for a path this module did not
21
+ * compose, such as one read back out of a row written before this guard
22
+ * existed.
23
+ *
24
+ * Rejection, never normalisation: an id containing `a/../b` is refused rather
25
+ * than quietly rewritten to `b`, because the rewrite would silently store one
26
+ * caller's media under a different id than the caller named.
27
+ */
28
+ export declare class MediaPathError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * Resolve a relative path against a media root, refusing anything that is not
33
+ * strictly beneath it. Use for paths read back from a row; `mediaFilePath` and
34
+ * `mediaDirPath` funnel through it too, so every media path in the codebase
35
+ * passes this check exactly once.
36
+ */
37
+ export declare function mediaPathWithin(root: string, relativePath: string): string;
38
+ /**
39
+ * Compose the path of a media file from segments and a leaf file name,
40
+ * returning both the relative path to store in the row and the full path to
41
+ * write to.
42
+ */
43
+ export declare function mediaFilePath(root: string, segments: string[], filename: string): {
44
+ relativePath: string;
45
+ fullPath: string;
46
+ };
47
+ /**
48
+ * Compose the path of a directory beneath a media root. The caller of this one
49
+ * is a recursive delete, so an empty segment list -- which would resolve to the
50
+ * media root itself -- is refused along with everything else.
51
+ */
52
+ export declare function mediaDirPath(root: string, segments: string[]): string;
@@ -0,0 +1,106 @@
1
+ import { isAbsolute, join, resolve, sep } from "path";
2
+ import { createLogger } from "./logger.js";
3
+ const log = createLogger("media-path");
4
+ /**
5
+ * The one place a path beneath a media root is composed or resolved.
6
+ *
7
+ * Media file paths are built from ids that arrive over the wire -- a game id,
8
+ * an entity id, an entity type -- and are then handed to `writeFileSync` and,
9
+ * worse, to a recursive `rmSync`. `join()` resolves `..` happily, so an id
10
+ * carrying one walks out of the data directory before the write. The rule this
11
+ * breaks is the engine's: it writes nothing to the consumer's machine that the
12
+ * consumer did not name.
13
+ *
14
+ * The guard is here, at the composition point, rather than at each call site,
15
+ * because a per-site check is one forgotten site away from being no check. Two
16
+ * things are asserted, and both are literal checks over characters -- never an
17
+ * attempt to read meaning out of a value (root CLAUDE.md hard rule 4):
18
+ *
19
+ * 1. Every path segment matches an allowlist. Every id the engine mints is a
20
+ * UUID and every entity type is an ASCII word, so the allowlist costs
21
+ * nothing the engine actually uses.
22
+ * 2. The resolved result is strictly beneath the resolved root -- a
23
+ * structural backstop that holds even for a path this module did not
24
+ * compose, such as one read back out of a row written before this guard
25
+ * existed.
26
+ *
27
+ * Rejection, never normalisation: an id containing `a/../b` is refused rather
28
+ * than quietly rewritten to `b`, because the rewrite would silently store one
29
+ * caller's media under a different id than the caller named.
30
+ */
31
+ export class MediaPathError extends Error {
32
+ constructor(message) {
33
+ super(message);
34
+ this.name = "MediaPathError";
35
+ }
36
+ }
37
+ /** Directory names: ids the engine mints, plus pluralised entity types. */
38
+ const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/;
39
+ /** Leaf file names: a safe stem, one dot, an alphanumeric extension. */
40
+ const SAFE_FILENAME = /^[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/;
41
+ function reject(what, value) {
42
+ log.warn("Refused a media path built from an unsafe value", { what, value });
43
+ throw new MediaPathError(`Unsafe ${what} for a media path: ${JSON.stringify(value)}. ` +
44
+ `Only letters, digits, underscore and hyphen are allowed; a path separator, ` +
45
+ `"." or ".." would escape the media directory.`);
46
+ }
47
+ function assertSafeSegment(value) {
48
+ if (typeof value !== "string" || !SAFE_SEGMENT.test(value)) {
49
+ reject("path segment", String(value));
50
+ }
51
+ }
52
+ /**
53
+ * Resolve a relative path against a media root, refusing anything that is not
54
+ * strictly beneath it. Use for paths read back from a row; `mediaFilePath` and
55
+ * `mediaDirPath` funnel through it too, so every media path in the codebase
56
+ * passes this check exactly once.
57
+ */
58
+ export function mediaPathWithin(root, relativePath) {
59
+ if (typeof relativePath !== "string" || relativePath.length === 0) {
60
+ reject("stored path", String(relativePath));
61
+ }
62
+ if (isAbsolute(relativePath)) {
63
+ reject("stored path", relativePath);
64
+ }
65
+ // Split on both separators regardless of platform: a stored path composed on
66
+ // one and read on another must not sneak a traversal past the check.
67
+ for (const segment of relativePath.split(/[\\/]/)) {
68
+ if (segment.length === 0 || segment === "." || segment === "..") {
69
+ reject("stored path", relativePath);
70
+ }
71
+ }
72
+ const rootResolved = resolve(root);
73
+ const fullPath = resolve(rootResolved, relativePath);
74
+ // Compare with the separator appended, so a sibling directory whose name
75
+ // merely starts with the root's (`<root>-elsewhere`) is not mistaken for a
76
+ // child of it.
77
+ if (!fullPath.startsWith(rootResolved + sep)) {
78
+ reject("stored path", relativePath);
79
+ }
80
+ return fullPath;
81
+ }
82
+ /**
83
+ * Compose the path of a media file from segments and a leaf file name,
84
+ * returning both the relative path to store in the row and the full path to
85
+ * write to.
86
+ */
87
+ export function mediaFilePath(root, segments, filename) {
88
+ segments.forEach(assertSafeSegment);
89
+ if (typeof filename !== "string" || !SAFE_FILENAME.test(filename)) {
90
+ reject("file name", String(filename));
91
+ }
92
+ const relativePath = join(...segments, filename);
93
+ return { relativePath, fullPath: mediaPathWithin(root, relativePath) };
94
+ }
95
+ /**
96
+ * Compose the path of a directory beneath a media root. The caller of this one
97
+ * is a recursive delete, so an empty segment list -- which would resolve to the
98
+ * media root itself -- is refused along with everything else.
99
+ */
100
+ export function mediaDirPath(root, segments) {
101
+ if (segments.length === 0) {
102
+ reject("path segment", "");
103
+ }
104
+ segments.forEach(assertSafeSegment);
105
+ return mediaPathWithin(root, join(...segments));
106
+ }