run-dmcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +76 -10
  2. package/dist/bin/run-dmcp.d.ts +2 -0
  3. package/dist/bin/run-dmcp.js +55 -0
  4. package/dist/db/connection.d.ts +32 -0
  5. package/dist/db/connection.js +38 -16
  6. package/dist/db/schema.d.ts +29 -1
  7. package/dist/db/schema.js +439 -7
  8. package/dist/http/server.js +3 -3
  9. package/dist/index.d.ts +36 -2
  10. package/dist/index.js +184 -92
  11. package/dist/mcp-server.d.ts +49 -0
  12. package/dist/mcp-server.js +127 -0
  13. package/dist/reader/turnReader.d.ts +185 -0
  14. package/dist/reader/turnReader.js +288 -0
  15. package/dist/register/batch.js +5 -79
  16. package/dist/register/mcp-resources.d.ts +9 -0
  17. package/dist/register/mcp-resources.js +16 -62
  18. package/dist/register/render.d.ts +17 -0
  19. package/dist/register/render.js +50 -0
  20. package/dist/register/resolve.d.ts +14 -0
  21. package/dist/register/resolve.js +102 -0
  22. package/dist/register/resources.js +11 -4
  23. package/dist/register/timeline.d.ts +2 -0
  24. package/dist/register/timeline.js +311 -0
  25. package/dist/rpg/index.d.ts +29 -0
  26. package/dist/rpg/index.js +55 -0
  27. package/dist/rpg/register/abilities.d.ts +2 -0
  28. package/dist/rpg/register/abilities.js +165 -0
  29. package/dist/rpg/register/batch.d.ts +2 -0
  30. package/dist/rpg/register/batch.js +92 -0
  31. package/dist/rpg/register/combat.d.ts +2 -0
  32. package/dist/rpg/register/combat.js +207 -0
  33. package/dist/rpg/register/mcp-prompts.d.ts +2 -0
  34. package/dist/rpg/register/mcp-prompts.js +684 -0
  35. package/dist/rpg/register/mcp-resources.d.ts +2 -0
  36. package/dist/rpg/register/mcp-resources.js +61 -0
  37. package/dist/rpg/register/quests.d.ts +2 -0
  38. package/dist/rpg/register/quests.js +118 -0
  39. package/dist/rpg/register/status.d.ts +2 -0
  40. package/dist/rpg/register/status.js +130 -0
  41. package/dist/rpg/register/tables.d.ts +2 -0
  42. package/dist/rpg/register/tables.js +146 -0
  43. package/dist/rpg/tools/ability.d.ts +48 -0
  44. package/dist/rpg/tools/ability.js +238 -0
  45. package/dist/rpg/tools/combat.d.ts +13 -0
  46. package/dist/rpg/tools/combat.js +195 -0
  47. package/dist/rpg/tools/dice.d.ts +23 -0
  48. package/dist/rpg/tools/dice.js +111 -0
  49. package/dist/rpg/tools/quest.d.ts +34 -0
  50. package/dist/rpg/tools/quest.js +164 -0
  51. package/dist/rpg/tools/status.d.ts +36 -0
  52. package/dist/rpg/tools/status.js +218 -0
  53. package/dist/rpg/tools/tables.d.ts +33 -0
  54. package/dist/rpg/tools/tables.js +209 -0
  55. package/dist/schemas/index.d.ts +12 -12
  56. package/dist/timeline/adjudication.d.ts +150 -0
  57. package/dist/timeline/adjudication.js +174 -0
  58. package/dist/timeline/changes.d.ts +100 -0
  59. package/dist/timeline/changes.js +161 -0
  60. package/dist/timeline/checkpoint.d.ts +69 -0
  61. package/dist/timeline/checkpoint.js +131 -0
  62. package/dist/timeline/clock.d.ts +89 -0
  63. package/dist/timeline/clock.js +173 -0
  64. package/dist/timeline/constrained.d.ts +220 -0
  65. package/dist/timeline/constrained.js +671 -0
  66. package/dist/timeline/export.d.ts +171 -0
  67. package/dist/timeline/export.js +329 -0
  68. package/dist/timeline/irreversible.d.ts +85 -0
  69. package/dist/timeline/irreversible.js +108 -0
  70. package/dist/timeline/kinds.d.ts +14 -0
  71. package/dist/timeline/kinds.js +22 -0
  72. package/dist/timeline/narration.d.ts +175 -0
  73. package/dist/timeline/narration.js +259 -0
  74. package/dist/timeline/projection.d.ts +97 -0
  75. package/dist/timeline/projection.js +330 -0
  76. package/dist/timeline/provenance.d.ts +66 -0
  77. package/dist/timeline/provenance.js +45 -0
  78. package/dist/timeline/registry.d.ts +95 -0
  79. package/dist/timeline/registry.js +124 -0
  80. package/dist/timeline/render.d.ts +121 -0
  81. package/dist/timeline/render.js +187 -0
  82. package/dist/timeline/replay.d.ts +64 -0
  83. package/dist/timeline/replay.js +104 -0
  84. package/dist/timeline/resolve.d.ts +262 -0
  85. package/dist/timeline/resolve.js +226 -0
  86. package/dist/timeline/schema.d.ts +13 -0
  87. package/dist/timeline/schema.js +262 -0
  88. package/dist/timeline/t.d.ts +80 -0
  89. package/dist/timeline/t.js +37 -0
  90. package/dist/tools/constraint.d.ts +44 -80
  91. package/dist/tools/constraint.js +115 -124
  92. package/dist/tools/relationship.d.ts +83 -2
  93. package/dist/tools/relationship.js +139 -62
  94. package/dist/tools/resource.d.ts +31 -6
  95. package/dist/tools/resource.js +106 -153
  96. package/dist/types/index.d.ts +19 -1
  97. package/dist/utils/output-schemas.d.ts +593 -2
  98. package/dist/utils/output-schemas.js +3 -0
  99. package/dist/utils/webui.d.ts +32 -0
  100. package/dist/utils/webui.js +54 -1
  101. package/package.json +20 -4
  102. package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
  103. package/dist/__tests__/engineVocabulary.test.js +0 -147
  104. package/dist/db/__tests__/connection.test.d.ts +0 -1
  105. package/dist/db/__tests__/connection.test.js +0 -72
  106. package/dist/db/__tests__/testDb.d.ts +0 -33
  107. package/dist/db/__tests__/testDb.js +0 -41
  108. package/dist/test-setup.d.ts +0 -1
  109. package/dist/test-setup.js +0 -13
  110. package/dist/tools/__tests__/audio.test.d.ts +0 -1
  111. package/dist/tools/__tests__/audio.test.js +0 -59
  112. package/dist/tools/__tests__/conserved.test.d.ts +0 -1
  113. package/dist/tools/__tests__/conserved.test.js +0 -488
  114. package/dist/tools/__tests__/constraint.test.d.ts +0 -1
  115. package/dist/tools/__tests__/constraint.test.js +0 -212
  116. package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
  117. package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
  118. package/dist/tools/__tests__/images.test.d.ts +0 -1
  119. package/dist/tools/__tests__/images.test.js +0 -59
  120. package/dist/tools/__tests__/relationship.test.d.ts +0 -1
  121. package/dist/tools/__tests__/relationship.test.js +0 -132
  122. package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
  123. package/dist/tools/__tests__/resource-constraints.test.js +0 -131
  124. package/dist/tools/__tests__/resource.test.d.ts +0 -1
  125. package/dist/tools/__tests__/resource.test.js +0 -190
  126. package/dist/tools/__tests__/time.test.d.ts +0 -1
  127. package/dist/tools/__tests__/time.test.js +0 -404
  128. package/dist/tools/__tests__/timers.test.d.ts +0 -1
  129. package/dist/tools/__tests__/timers.test.js +0 -426
  130. package/dist/tools/__tests__/world.test.d.ts +0 -1
  131. package/dist/tools/__tests__/world.test.js +0 -70
  132. package/dist/utils/__tests__/json.test.d.ts +0 -1
  133. package/dist/utils/__tests__/json.test.js +0 -55
  134. package/dist/utils/__tests__/validation.test.d.ts +0 -1
  135. package/dist/utils/__tests__/validation.test.js +0 -90
@@ -0,0 +1,671 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase, withTransaction } from "../db/connection.js";
3
+ import { assertT, compareT } from "./t.js";
4
+ import { currentStoryTime } from "./clock.js";
5
+ import { PROJECTED_TABLES, liveColumns } from "./projection.js";
6
+ import { constraintsFor, conservedConstraintFor, ConstraintViolationError, CONSERVED_SUM_EPSILON } from "./registry.js";
7
+ import { irreversibleFactFor } from "./irreversible.js";
8
+ import { adjudicationOpen } from "./adjudication.js";
9
+ /**
10
+ * A1: resolves `entityId` to the live table its `key` column lives in, and
11
+ * confirms that column actually exists -- the generic replacement for
12
+ * hardcoding "this is a resource, its column is `resources.value`" that
13
+ * makes this choke point work for `(entityId, factKey)` in general rather
14
+ * than one hand-picked pair.
15
+ *
16
+ * Reuses `PROJECTED_TABLES`/`liveColumns` (projection.ts) rather than
17
+ * carrying a second table/column registry -- the same "one owner for the
18
+ * column list" rule `checkpoint.ts`'s `timelineDivergences` already follows,
19
+ * for the same reason: a second copy could silently drift from what the
20
+ * projection triggers actually project, and this function would then
21
+ * validate against a set of columns nothing else agrees with.
22
+ *
23
+ * Throws, naming the entity and key, rather than returning undefined --
24
+ * every caller of this function is about to either read or write a live
25
+ * column, and a silent "couldn't resolve" would surface many statements
26
+ * later as a confusing SQL error against a nonexistent column instead of a
27
+ * clear one here.
28
+ */
29
+ function resolveProjection(entityId, key) {
30
+ const db = getDatabase();
31
+ const entity = db.prepare(`SELECT game_id, kind FROM entities WHERE id = ?`).get(entityId);
32
+ if (!entity) {
33
+ throw new Error(`timeline: cannot resolve a constrained write -- entity '${entityId}' does not exist`);
34
+ }
35
+ // Cannot happen in practice: PROJECTED_TABLES has exactly one row per
36
+ // ENTITY_KINDS member today, and entities.kind is FK-constrained to
37
+ // entity_kinds (schema.ts). Guarded anyway rather than asserted, because
38
+ // this function has no business trusting that invariant silently forever.
39
+ const projected = PROJECTED_TABLES.find((p) => p.kind === entity.kind);
40
+ if (!projected) {
41
+ throw new Error(`timeline: entity '${entityId}' has kind '${entity.kind}', which has no projected table -- ` +
42
+ `there is no live column to write a constrained value through`);
43
+ }
44
+ const cols = liveColumns(db, projected.table);
45
+ if (!cols.includes(key)) {
46
+ throw new Error(`timeline: '${key}' is not a live column of '${projected.table}' -- entity '${entityId}' ` +
47
+ `(kind '${entity.kind}') has no fact key by that name`);
48
+ }
49
+ return { entityId, gameId: entity.game_id, table: projected.table, key };
50
+ }
51
+ /**
52
+ * Reads the current live value of a resolved (entityId, key). Throws rather
53
+ * than returning a sentinel on either failure: a missing row means the
54
+ * entity was destroyed since `resolveProjection` confirmed it in the
55
+ * timeline (a real race, however narrow); a NULL column means there is no
56
+ * value to move from, and "say what is, never what is absent" (hard rule 3)
57
+ * means this function will not invent a zero to paper over that -- the
58
+ * caller asked for a NUMBER, and an absent one is a caller error to report,
59
+ * not a caller error to guess past.
60
+ */
61
+ function readLiveValue(db, resolved) {
62
+ const row = db
63
+ .prepare(`SELECT ${resolved.key} AS value FROM ${resolved.table} WHERE id = ?`)
64
+ .get(resolved.entityId);
65
+ if (!row) {
66
+ throw new Error(`timeline: no live row in '${resolved.table}' for entity '${resolved.entityId}' -- ` +
67
+ `it may have been destroyed since it was last confirmed to exist`);
68
+ }
69
+ if (row.value === null) {
70
+ throw new Error(`timeline: '${resolved.key}' is NULL on entity '${resolved.entityId}' -- a constrained numeric ` +
71
+ `write needs an existing numeric value to move from`);
72
+ }
73
+ return row.value;
74
+ }
75
+ function clamp(value, minValue, maxValue) {
76
+ let result = value;
77
+ if (minValue !== null)
78
+ result = Math.max(result, minValue);
79
+ if (maxValue !== null)
80
+ result = Math.min(result, maxValue);
81
+ return result;
82
+ }
83
+ /**
84
+ * A2: the single site where the declared constraint family (design §5.3) --
85
+ * `monotonic`, `bounded`, `conserved`, and (issue #13) `resolve_only` -- is
86
+ * evaluated against an intended change. Every one of `checkResourceConstraints()`
87
+ * and `checkBoundedAndMonotonicConstraints()`'s (formerly src/tools/constraint.ts)
88
+ * rules lives here now and ONLY here -- grep the tree for
89
+ * `constraint.direction ===` or `constraint.kind === "bounded"` and this file
90
+ * is the only hit outside a test.
91
+ *
92
+ * Reads via `constraintsFor(entityId, key)` (registry.ts), not
93
+ * `allConstraintsForEntity` -- this is the behavioral point of Phase 3 step
94
+ * 1's key-scoping: a `monotonic` (or, now, `resolve_only`) constraint
95
+ * declared on one fact key must never reach a write to a different key on
96
+ * the same entity, even though every constraint declared through today's
97
+ * `declare*()` functions other than `declareResolveOnlyConstraint` happens
98
+ * to govern `'value'`.
99
+ *
100
+ * `bounds` is optional. When a caller has no bounds to check against (e.g.
101
+ * `updateResource`'s own guard below, which is not itself moving a value
102
+ * against declared min/max -- it is refusing a conserved reclamp), the
103
+ * `bounded` branch is simply skipped rather than crashing on a missing
104
+ * object. `monotonic` and `conserved` need no bounds and are always
105
+ * evaluated when applicable.
106
+ *
107
+ * MESSAGE PRESERVATION: every string thrown below is copied verbatim from
108
+ * `checkResourceConstraints`/`checkBoundedAndMonotonicConstraints` as they
109
+ * stood before this module existed -- `conserved.test.ts` asserts against
110
+ * the conserved-rejection text by regex, and nothing here is worth rewording
111
+ * away from wording a real caller may already be matching on.
112
+ */
113
+ export function assertConstraintsAllow(params) {
114
+ const { entityId, key, previousValue, intendedValue, bounds, conservedMemberWrite, context } = params;
115
+ const constraints = constraintsFor(entityId, key);
116
+ for (const constraint of constraints) {
117
+ if (constraint.kind === "monotonic") {
118
+ if (constraint.direction === "increasing" && intendedValue < previousValue) {
119
+ throw new ConstraintViolationError("monotonic", entityId, `Resource '${entityId}' is constrained to never decrease; rejected change from ${previousValue} to ${intendedValue}.`);
120
+ }
121
+ if (constraint.direction === "decreasing" && intendedValue > previousValue) {
122
+ throw new ConstraintViolationError("monotonic", entityId, `Resource '${entityId}' is constrained to never increase; rejected change from ${previousValue} to ${intendedValue}.`);
123
+ }
124
+ }
125
+ if (constraint.kind === "bounded" && bounds) {
126
+ if (bounds.minValue !== null && intendedValue < bounds.minValue) {
127
+ throw new ConstraintViolationError("bounded", entityId, `Resource '${entityId}' is bounded-constrained (min ${bounds.minValue}); rejected value ${intendedValue} instead of clamping.`);
128
+ }
129
+ if (bounds.maxValue !== null && intendedValue > bounds.maxValue) {
130
+ throw new ConstraintViolationError("bounded", entityId, `Resource '${entityId}' is bounded-constrained (max ${bounds.maxValue}); rejected value ${intendedValue} instead of clamping.`);
131
+ }
132
+ }
133
+ // 'resolve_only' (design §5.3, §5.2a; issue #13): every direct write to
134
+ // this (entityId, key) is refused, full stop -- unconditionally, not
135
+ // only when `conservedMemberWrite === "reject"`. That parameter exists
136
+ // to disambiguate 'conserved' membership (a direct single-resource
137
+ // write is ambiguous; transferConstrainedValue's two-leg write is not),
138
+ // and it has no bearing on 'resolve_only': neither writeConstrainedValue
139
+ // NOR transferConstrainedValue IS the adjudicating call, so both are
140
+ // "direct" from resolve_only's point of view and both are checked here,
141
+ // which is why this branch runs regardless of `conservedMemberWrite`.
142
+ // `adjudicationOpen()` (src/timeline/adjudication.ts) is the ONLY
143
+ // question this branch asks; see that module's doc comment for why it
144
+ // and the `timeline_facts_resolve_only` trigger (src/db/schema.ts) are
145
+ // two readers of one row of truth rather than two independent checks
146
+ // that merely agree today.
147
+ //
148
+ // THIS DOES NOT DUPLICATE THE TRIGGER. Design decision #7 / hard rule 7:
149
+ // a constrained numeric value changes through this module and nowhere
150
+ // else, and all four `ConstraintKind` members are evaluated here -- this
151
+ // is the ONE JS-level check for 'resolve_only', giving a caller a typed,
152
+ // reviewable `ConstraintViolationError` instead of an opaque SQLite
153
+ // `RAISE(ABORT)`. `timeline_facts_resolve_only` is the backstop that
154
+ // makes bypassing THIS module (a raw `UPDATE resources SET value = ...`
155
+ // that never calls writeConstrainedValue at all) unconstructable rather
156
+ // than merely inconvenient -- exactly the relationship
157
+ // `translateIrreversibleFailure` below describes for 'irreversible',
158
+ // except 'irreversible' has no JS-level check at all (its trigger is the
159
+ // only enforcement, translated after the fact) while 'resolve_only' is
160
+ // enforced at BOTH layers because, unlike a contradicted fact, "is a
161
+ // window open" is cheap to ask before ever touching the database.
162
+ if (constraint.kind === "resolve_only" && !adjudicationOpen()) {
163
+ throw new ConstraintViolationError("resolve_only", entityId, `Resource '${entityId}' is resolve_only-constrained for key '${key}'; direct writes are refused. ` +
164
+ `This value can only change through the adjudicating call that opens the resolution window.`);
165
+ }
166
+ }
167
+ if (conservedMemberWrite === "reject") {
168
+ const conserved = constraints.find((c) => c.kind === "conserved");
169
+ if (conserved) {
170
+ const suffix = context ??
171
+ `and cannot be written directly via update_resource_value -- a single-resource write is ambiguous about where ` +
172
+ `the counterpart delta should come from, and could silently break the set's total. Use transfer_resource_value ` +
173
+ `to move value between two members of this set atomically instead.`;
174
+ throw new ConstraintViolationError("conserved", entityId, `Resource '${entityId}' is a member of a 'conserved' constraint (id '${conserved.id}', total ${conserved.total}) ${suffix}`);
175
+ }
176
+ }
177
+ }
178
+ /**
179
+ * Rejects (never clamps) a transfer leg that would push an entity's key
180
+ * outside `bounds`. Ported from `assertWithinBoundsForTransfer`
181
+ * (src/tools/resource.ts) with the same reasoning: clamping one side of a
182
+ * transfer would apply an uneven delta and silently create or destroy value,
183
+ * so any bound violation on either side rejects the whole transfer instead
184
+ * -- regardless of whether a `bounded` constraint is separately declared.
185
+ * `bounds` here is the resource's own plain minValue/maxValue, which this
186
+ * check always enforces once supplied; declared `bounded` constraints are a
187
+ * SEPARATE, opt-in check (`assertConstraintsAllow` above).
188
+ */
189
+ function assertWithinBoundsForTransfer(entityId, label, bounds, intendedValue, role) {
190
+ if (!bounds)
191
+ return;
192
+ if (bounds.minValue !== null && intendedValue < bounds.minValue) {
193
+ throw new ConstraintViolationError("conserved", entityId, `Transfer rejected: '${label}' (${entityId}) would go below its minimum value ` +
194
+ `(${bounds.minValue}) as the ${role} of this transfer. transfer_resource_value never clamps -- ` +
195
+ `clamping one side of a transfer would apply an uneven delta and silently create or destroy value. ` +
196
+ `Choose a smaller amount.`);
197
+ }
198
+ if (bounds.maxValue !== null && intendedValue > bounds.maxValue) {
199
+ throw new ConstraintViolationError("conserved", entityId, `Transfer rejected: '${label}' (${entityId}) would exceed its maximum value ` +
200
+ `(${bounds.maxValue}) as the ${role} of this transfer. transfer_resource_value never clamps -- ` +
201
+ `clamping one side of a transfer would apply an uneven delta and silently create or destroy value. ` +
202
+ `Choose a smaller amount.`);
203
+ }
204
+ }
205
+ /**
206
+ * Performs one live column write plus its one annotation event, and returns
207
+ * the resulting `ValueTransition`. Shared by `writeConstrainedValue` (one
208
+ * leg) and `transferConstrainedValue` (two legs, same transaction) so the
209
+ * "how do we find what fact this opened, and what do we tell `events`"
210
+ * logic is written exactly once.
211
+ *
212
+ * MUST be called from inside a `withTransaction()` -- it performs the
213
+ * `UPDATE` (which fires the generated `_au` projection trigger,
214
+ * projection.ts, closing the old fact and opening the new one INSIDE that
215
+ * same statement) and the annotation-event `INSERT` as two separate
216
+ * statements that need to land together or not at all.
217
+ *
218
+ * Column and table names are interpolated, never bound -- `resolved.table`
219
+ * and `resolved.key` were validated by `resolveProjection` against
220
+ * `pragma_table_info`/`PROJECTED_TABLES`, this codebase's own vocabulary,
221
+ * never a caller-supplied string. See projection.ts's doc comments for the
222
+ * fuller statement of the same rule.
223
+ */
224
+ function applyLiveWrite(params) {
225
+ const db = getDatabase();
226
+ const { entityId, gameId, table, key, previousValue, newValue, reason } = params;
227
+ db.prepare(`UPDATE ${table} SET ${key} = ? WHERE id = ?`).run(newValue, entityId);
228
+ let factId = null;
229
+ let t;
230
+ if (newValue === previousValue) {
231
+ // The projection trigger's INSERT is guarded `WHERE NEW.<col> IS NOT
232
+ // (SELECT value FROM facts ...)` (projection.ts) -- an unchanged value
233
+ // opens no new fact. Detected here from the value comparison, not by
234
+ // inspecting rows, because inspecting rows can't tell "nothing opened"
235
+ // apart from "something opened and was immediately superseded" without
236
+ // extra bookkeeping this choke point has no reason to carry.
237
+ const story = currentStoryTime(gameId);
238
+ if (!story) {
239
+ throw new Error(`timeline: game '${gameId}' has no timeline clock -- cannot record a no-op constrained write with no t to attach it to`);
240
+ }
241
+ t = story.t;
242
+ }
243
+ else {
244
+ // The fact the trigger just opened: the currently-open interval for
245
+ // this (entityId, key). In a correctly functioning system this row's
246
+ // valid_from_t already equals "the game's clock now" -- it was opened
247
+ // by the UPDATE just above -- so there is no separate clock read to
248
+ // reconcile it against.
249
+ const openFact = db
250
+ .prepare(`SELECT id, valid_from_t FROM facts WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL
251
+ ORDER BY valid_from_t DESC, id DESC LIMIT 1`)
252
+ .get(entityId, key);
253
+ if (openFact) {
254
+ factId = openFact.id;
255
+ t = openFact.valid_from_t;
256
+ }
257
+ else {
258
+ // Only reachable if newValue is NULL (the trigger's INSERT is also
259
+ // guarded `WHERE NEW.<col> IS NOT NULL`) -- not a case a numeric
260
+ // constrained write produces, but this function is not itself the
261
+ // place to assume that; fall back to the clock rather than throw.
262
+ const story = currentStoryTime(gameId);
263
+ if (!story) {
264
+ throw new Error(`timeline: game '${gameId}' has no timeline clock to attach this write to`);
265
+ }
266
+ t = story.t;
267
+ }
268
+ }
269
+ assertT(t);
270
+ const at = new Date().toISOString();
271
+ const delta = newValue - previousValue;
272
+ const eventId = uuidv4();
273
+ // `causes` deliberately does NOT carry a `row_id` key. irreversible.ts's
274
+ // `findOpenedByEventId` matches `json_extract(causes, '$.row_id')` to
275
+ // attach design §5.2c's one hop of provenance, picking the first event at
276
+ // a given `t` by a random hex id when more than one matches. `row_id` is
277
+ // the PROJECTION triggers' own token for "the live row this projection
278
+ // event was generated from" (projection.ts) -- an annotation event this
279
+ // choke point writes is not a projection event, and if it carried
280
+ // `row_id` too, a projection event and an annotation event sharing one
281
+ // `t` (which a constrained write's own UPDATE produces: the `_au`
282
+ // trigger's `<kind>.updated` event and this `value.changed` event both
283
+ // land at the same `t`) would make `findOpenedByEventId`'s pick
284
+ // non-deterministic. `entity_id` is the accurate key for what this event
285
+ // is about anyway, so using it instead of `row_id` is both the honest
286
+ // name and the one that can never collide with that lookup.
287
+ const causes = JSON.stringify({
288
+ source: "constrained_write",
289
+ entity_id: entityId,
290
+ key,
291
+ fact_id: factId,
292
+ previous_value: previousValue,
293
+ new_value: newValue,
294
+ delta,
295
+ at,
296
+ });
297
+ db.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'value.changed', ?, ?)`).run(eventId, gameId, t, reason, causes);
298
+ return { entityId, key, previousValue, newValue, delta, reason, t, factId, eventId, at };
299
+ }
300
+ /**
301
+ * The TEXT form SQLite's projection triggers would have produced for
302
+ * `attemptedValue` had this write actually landed in `table.key` --
303
+ * reproduced by asking SQLite itself, not by formatting the number in JS.
304
+ *
305
+ * This exists because of a measured trap (checkpoint.ts's doc comment,
306
+ * timeline-architecture.md): a bound JS number carries no column affinity of
307
+ * its own, so `String(100)` is `"100"`, but SQLite's own
308
+ * `CAST(100 AS REAL)` -- what actually happens when 100 is stored into a
309
+ * REAL-affinity column like `resources.value` -- renders as TEXT
310
+ * `"100.0"`. Comparing the JS-formatted string against an irreversible
311
+ * fact's stored value would then silently never match a REAL column, which
312
+ * is every constrained numeric column this choke point writes today.
313
+ *
314
+ * `CAST(x AS <type-name>)` uses the same 5-rule algorithm SQLite uses to
315
+ * derive column affinity from a declared type name (SQLite's own
316
+ * documentation for CAST expressions says so explicitly), so casting
317
+ * through the column's OWN declared type -- read from `pragma_table_info`,
318
+ * exactly where `resolveProjection`/`liveColumns` already get their column
319
+ * vocabulary from, never a caller-supplied string -- reproduces the
320
+ * identical conversion `CAST(NEW.<col> AS TEXT)` (projection.ts) applies to
321
+ * a value that actually lands in that column. `NUMERIC` is the fallback for
322
+ * a column with no declared type; SQLite treats undeclared/empty type names
323
+ * as BLOB affinity for real columns, but every live column this function is
324
+ * ever called for is numeric by construction (`readLiveValue` already
325
+ * required one to get this far), so NUMERIC -- not BLOB -- is the honest
326
+ * default here.
327
+ */
328
+ function castedTextForm(db, table, key, attemptedValue) {
329
+ const info = db.prepare(`SELECT type FROM pragma_table_info(?) WHERE name = ?`).get(table, key);
330
+ const declaredType = info?.type && info.type.length > 0 ? info.type : "NUMERIC";
331
+ const row = db.prepare(`SELECT CAST(CAST(? AS ${declaredType}) AS TEXT) AS text_form`).get(attemptedValue);
332
+ return row.text_form;
333
+ }
334
+ /**
335
+ * Design decision #7 / §5.2c: translates a write that failed inside
336
+ * `withTransaction` into a typed `ConstraintViolationError` carrying one hop
337
+ * of causality -- but ONLY when it can show, structurally, that an
338
+ * irreversible fact is why. `timeline_facts_irreversible` (the BEFORE
339
+ * INSERT trigger on `facts`, schema.ts) remains the only thing that decided
340
+ * to refuse the write; by the time this function runs, `withTransaction`
341
+ * has already rolled the whole attempt back, and nothing here can change
342
+ * that outcome -- it can only name it.
343
+ *
344
+ * Called with one attempt for `writeConstrainedValue`'s single leg, two for
345
+ * `transferConstrainedValue`'s two legs. A transfer's legs write different
346
+ * entities under the same key, and after rollback neither leg's UPDATE is
347
+ * visible any more -- there is no way to ask "which leg's row changed" from
348
+ * the live tables, only "does either leg's entity carry an irreversible
349
+ * fact that disagrees with what that leg tried to assert," which is exactly
350
+ * what `irreversibleFactFor` (irreversible.ts) can answer without touching
351
+ * the failed error at all.
352
+ *
353
+ * Deliberately does NOT inspect `err`'s message or SQLite error code --
354
+ * hard rule 4 (never pattern-match meaning) applies to this codebase's own
355
+ * generated text too, and a check against rows this module itself wrote
356
+ * keeps working even if the trigger's wording ever changes. If no attempt's
357
+ * entity/key carries an irreversible fact that disagrees with what was
358
+ * written, `err` is rethrown completely untouched -- losing an unrelated
359
+ * failure (the conserved-sum invariant check, a fault in some other
360
+ * trigger, anything that isn't this rejection) inside a translation layer
361
+ * would be far worse than leaving it untranslated.
362
+ */
363
+ function translateIrreversibleFailure(db, attempts, err) {
364
+ for (const attempt of attempts) {
365
+ const fact = irreversibleFactFor(attempt.entityId, attempt.key);
366
+ if (!fact)
367
+ continue;
368
+ const attemptedText = castedTextForm(db, attempt.table, attempt.key, attempt.attemptedValue);
369
+ if (fact.value !== attemptedText) {
370
+ throw new ConstraintViolationError("irreversible", attempt.entityId, `Resource '${attempt.entityId}' has an irreversible fact for key '${attempt.key}': value '${fact.value}' ` +
371
+ `holds as of t=${fact.validFromT}` +
372
+ (fact.openedByEventId !== null
373
+ ? ` (opened by event '${fact.openedByEventId}')`
374
+ : ` (no event is recorded for when this was opened)`) +
375
+ `. The attempted value '${attemptedText}' contradicts it and is refused.`, fact);
376
+ }
377
+ }
378
+ throw err;
379
+ }
380
+ /**
381
+ * A3: the only way a constrained numeric value changes. Resolve, check,
382
+ * clamp, write -- in that order, and the order is load-bearing: clamping
383
+ * BEFORE the constraint check would let a declared `bounded` constraint's
384
+ * rejection be silently satisfied by the very clamp it exists to prevent
385
+ * (an intended value of 150 against a [0, 100] bound would arrive at the
386
+ * check already clamped to 100, and a `bounded` constraint's whole point is
387
+ * to refuse 150, not to see 100). Clamping AFTER means the constraint check
388
+ * always sees the caller's actual, unclamped intent.
389
+ */
390
+ export function writeConstrainedValue(params) {
391
+ const resolved = resolveProjection(params.entityId, params.key);
392
+ const db = getDatabase();
393
+ const previousValue = readLiveValue(db, resolved);
394
+ const intendedValue = params.mode === "delta" ? previousValue + params.value : params.value;
395
+ assertConstraintsAllow({
396
+ entityId: params.entityId,
397
+ key: params.key,
398
+ previousValue,
399
+ intendedValue,
400
+ bounds: params.bounds,
401
+ conservedMemberWrite: "reject",
402
+ context: params.context,
403
+ });
404
+ const newValue = params.bounds ? clamp(intendedValue, params.bounds.minValue, params.bounds.maxValue) : intendedValue;
405
+ const reason = params.reason ?? null;
406
+ try {
407
+ return withTransaction(() => applyLiveWrite({
408
+ entityId: params.entityId,
409
+ gameId: resolved.gameId,
410
+ table: resolved.table,
411
+ key: params.key,
412
+ previousValue,
413
+ newValue,
414
+ reason,
415
+ }));
416
+ }
417
+ catch (err) {
418
+ translateIrreversibleFailure(db, [{ entityId: params.entityId, key: params.key, table: resolved.table, attemptedValue: newValue }], err);
419
+ }
420
+ }
421
+ /**
422
+ * A4: the counterpart-carrying two-leg write for conserved sets. Ported from
423
+ * `transferResourceValue` (src/tools/resource.ts) minus its argument
424
+ * validation (self-transfer, non-finite, negative, not-found), which stays
425
+ * in resource.ts because it is about resource IDENTITY, not about the
426
+ * constraint family this module owns -- see the doc comment there.
427
+ *
428
+ * Checks run BEFORE the transaction (membership, then bounded/monotonic on
429
+ * both legs, then the never-clamp bounds rejection), exactly as
430
+ * `transferResourceValue` ordered them -- a transfer that is going to be
431
+ * rejected should never touch either live row. Both legs' writes, plus the
432
+ * defense-in-depth sum re-verification, land in ONE `withTransaction()`.
433
+ */
434
+ export function transferConstrainedValue(params) {
435
+ const { fromEntityId, toEntityId, key, amount } = params;
436
+ const fromLabel = params.fromLabel ?? fromEntityId;
437
+ const toLabel = params.toLabel ?? toEntityId;
438
+ const reason = params.reason ?? null;
439
+ const fromResolved = resolveProjection(fromEntityId, key);
440
+ const toResolved = resolveProjection(toEntityId, key);
441
+ const db = getDatabase();
442
+ const fromConstraint = conservedConstraintFor(fromEntityId, key);
443
+ const toConstraint = conservedConstraintFor(toEntityId, key);
444
+ if (!fromConstraint || !toConstraint || fromConstraint.id !== toConstraint.id) {
445
+ const details = [];
446
+ if (!fromConstraint)
447
+ details.push(`'${fromLabel}' (${fromEntityId}) is not a member of any 'conserved' constraint.`);
448
+ if (!toConstraint)
449
+ details.push(`'${toLabel}' (${toEntityId}) is not a member of any 'conserved' constraint.`);
450
+ if (fromConstraint && toConstraint && fromConstraint.id !== toConstraint.id) {
451
+ details.push(`They belong to different 'conserved' constraints ('${fromConstraint.id}' and '${toConstraint.id}').`);
452
+ }
453
+ throw new ConstraintViolationError("conserved", fromEntityId, `transfer_resource_value requires fromResourceId and toResourceId to both be members of the same declared ` +
454
+ `'conserved' constraint -- moving value between resources outside a shared conserved set would change ` +
455
+ `each side's total independently, which is what update_resource_value is for. ${details.join(" ")}`);
456
+ }
457
+ const fromPrev = readLiveValue(db, fromResolved);
458
+ const toPrev = readLiveValue(db, toResolved);
459
+ const fromIntended = fromPrev - amount;
460
+ const toIntended = toPrev + amount;
461
+ // Bounded/monotonic constraints, if separately declared, apply during a
462
+ // transfer exactly as they do during a direct write -- "allow" here means
463
+ // only "the conserved-member ambiguity does not apply to this write",
464
+ // never "skip the rest of the family".
465
+ assertConstraintsAllow({
466
+ entityId: fromEntityId,
467
+ key,
468
+ previousValue: fromPrev,
469
+ intendedValue: fromIntended,
470
+ bounds: params.fromBounds,
471
+ conservedMemberWrite: "allow",
472
+ });
473
+ assertConstraintsAllow({
474
+ entityId: toEntityId,
475
+ key,
476
+ previousValue: toPrev,
477
+ intendedValue: toIntended,
478
+ bounds: params.toBounds,
479
+ conservedMemberWrite: "allow",
480
+ });
481
+ assertWithinBoundsForTransfer(fromEntityId, fromLabel, params.fromBounds, fromIntended, "source");
482
+ assertWithinBoundsForTransfer(toEntityId, toLabel, params.toBounds, toIntended, "destination");
483
+ const constraintId = fromConstraint.id;
484
+ const declaredTotal = fromConstraint.total ?? 0;
485
+ const memberIds = fromConstraint.resourceIds;
486
+ try {
487
+ return withTransaction(() => {
488
+ const from = applyLiveWrite({
489
+ entityId: fromEntityId,
490
+ gameId: fromResolved.gameId,
491
+ table: fromResolved.table,
492
+ key,
493
+ previousValue: fromPrev,
494
+ newValue: fromIntended,
495
+ reason,
496
+ });
497
+ const to = applyLiveWrite({
498
+ entityId: toEntityId,
499
+ gameId: toResolved.gameId,
500
+ table: toResolved.table,
501
+ key,
502
+ previousValue: toPrev,
503
+ newValue: toIntended,
504
+ reason,
505
+ });
506
+ // Defense in depth: re-read every member of the set (inside this same
507
+ // transaction, so this sees the writes above) generically -- through
508
+ // resolveProjection's table resolution, never through a src/tools/
509
+ // resource accessor -- and assert it still sums to the declared total.
510
+ // The primary guarantee is structural (an equal and opposite delta,
511
+ // above); this turns any future bug in this function, or a schema
512
+ // change that opens another write path around it, into a loud rollback
513
+ // instead of a silently wrong total.
514
+ const currentSum = memberIds.reduce((sum, id) => {
515
+ const memberResolved = resolveProjection(id, key);
516
+ const row = db.prepare(`SELECT ${key} AS value FROM ${memberResolved.table} WHERE id = ?`).get(id);
517
+ return sum + (row?.value ?? 0);
518
+ }, 0);
519
+ if (Math.abs(currentSum - declaredTotal) > CONSERVED_SUM_EPSILON) {
520
+ throw new Error(`Invariant check failed after transfer: conserved constraint '${constraintId}' members now sum to ` +
521
+ `${currentSum}, expected ${declaredTotal}. Rolling back.`);
522
+ }
523
+ return { from, to };
524
+ });
525
+ }
526
+ catch (err) {
527
+ translateIrreversibleFailure(db, [
528
+ { entityId: fromEntityId, key, table: fromResolved.table, attemptedValue: fromIntended },
529
+ { entityId: toEntityId, key, table: toResolved.table, attemptedValue: toIntended },
530
+ ], err);
531
+ }
532
+ }
533
+ /**
534
+ * A5: the payoff -- `valueHistory` is built ENTIRELY from the timeline
535
+ * (`facts` and `events`), with no `resource_history` in sight, because by
536
+ * this point in the merge there is no `resource_history` writer left to
537
+ * read from.
538
+ *
539
+ * Two sources, both scoped to `(entityId, key)`:
540
+ *
541
+ * 1. every FACT TRANSITION: consecutive facts, ordered `(valid_from_t,
542
+ * rowid)`, paired so each fact after the first supplies a
543
+ * previousValue/newValue/delta. The first fact is the value's
544
+ * CREATION, not a change -- `createResource` writes no history row
545
+ * today, and this function must not invent one, so the pairing loop
546
+ * starts at index 1, never 0.
547
+ * 2. every `"value.changed"` annotation event whose `causes.$.fact_id` is
548
+ * JSON null -- a constrained write that changed nothing. A no-op write
549
+ * opens no fact (see `applyLiveWrite`), so its annotation event is the
550
+ * ONLY record of it; without this second source, a zero-amount
551
+ * transfer or a zero-delta update would silently vanish from history,
552
+ * which is exactly what conserved.test.ts's "logged even though
553
+ * nothing moved" assertions were written against.
554
+ *
555
+ * Fact transitions are joined to their annotation (if any) by
556
+ * `json_extract(causes, '$.fact_id') = facts.id` -- an EXACT, unique link,
557
+ * because `applyLiveWrite` recorded the fact id at write time. This is
558
+ * deliberately stronger than irreversible.ts's `findOpenedByEventId`, which
559
+ * has to approximate the same relationship via `(at_t, row_id)` because the
560
+ * projection triggers that write `row_id` have no fact id to record at the
561
+ * point they fire (issue #2 predates this module). Here, recording the real
562
+ * id costs nothing extra and removes the approximation entirely.
563
+ *
564
+ * `json_valid(causes)` guards every extraction, matching irreversible.ts's
565
+ * `CASE WHEN json_valid(causes) THEN causes END` idiom for the same reason
566
+ * given there: `events.causes` has no CHECK constraint, timeline import
567
+ * (export.ts) carries it through verbatim by design, and SQLite's
568
+ * `json_extract` raises for the WHOLE query -- not just the offending row --
569
+ * when it meets a value that isn't JSON. A provenance hop must never be able
570
+ * to fail the query it annotates.
571
+ *
572
+ * Rows with no matching annotation (a direct column write, a bounds
573
+ * re-clamp, a startup reconciliation) come back with `reason: null`,
574
+ * `eventId: null`, `at: null` -- MORE history than `resource_history` ever
575
+ * held, because that table only ever got a row when
576
+ * `updateResourceValue`/`transferResourceValue` themselves wrote one.
577
+ *
578
+ * Ordered newest-first by `(t, rowid)` descending, matching
579
+ * `resource_history`'s old `ORDER BY timestamp DESC` in spirit -- but by the
580
+ * timeline's own axis, `t`, not by wall-clock time, because an unannotated
581
+ * transition has no wall-clock stamp to sort by and `t` is the one ordering
582
+ * this whole codebase agrees on (t.ts). `rowid` is the tiebreak for the rare
583
+ * case two rows share a `t`, mirroring `changes.ts`'s own tiebreak
584
+ * discipline. `limit` applies after ordering, never before.
585
+ */
586
+ export function valueHistory(entityId, key, limit) {
587
+ const db = getDatabase();
588
+ const factRows = db
589
+ .prepare(`SELECT
590
+ f.id AS factId,
591
+ f.value AS value,
592
+ f.valid_from_t AS t,
593
+ f.rowid AS rid,
594
+ e.id AS eventId,
595
+ e.description AS reason,
596
+ json_extract(CASE WHEN json_valid(e.causes) THEN e.causes END, '$.at') AS at
597
+ FROM facts f
598
+ LEFT JOIN events e
599
+ ON e.kind = 'value.changed'
600
+ AND json_extract(CASE WHEN json_valid(e.causes) THEN e.causes END, '$.fact_id') = f.id
601
+ WHERE f.entity_id = ? AND f.key = ?
602
+ ORDER BY f.valid_from_t ASC, f.rowid ASC`)
603
+ .all(entityId, key);
604
+ const ranked = [];
605
+ for (let i = 1; i < factRows.length; i++) {
606
+ const prev = factRows[i - 1];
607
+ const cur = factRows[i];
608
+ assertT(cur.t);
609
+ const previousValue = Number(prev.value);
610
+ const newValue = Number(cur.value);
611
+ ranked.push({
612
+ t: cur.t,
613
+ rid: cur.rid,
614
+ transition: {
615
+ entityId,
616
+ key,
617
+ previousValue,
618
+ newValue,
619
+ delta: newValue - previousValue,
620
+ reason: cur.reason,
621
+ t: cur.t,
622
+ factId: cur.factId,
623
+ eventId: cur.eventId,
624
+ at: cur.at,
625
+ },
626
+ });
627
+ }
628
+ const noOpRows = db
629
+ .prepare(`SELECT
630
+ id AS eventId,
631
+ description AS reason,
632
+ at_t AS t,
633
+ json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.previous_value') AS previousValue,
634
+ json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.new_value') AS newValue,
635
+ json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.delta') AS delta,
636
+ json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.at') AS at,
637
+ rowid AS rid
638
+ FROM events
639
+ WHERE kind = 'value.changed'
640
+ AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.entity_id') = ?
641
+ AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.key') = ?
642
+ AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.fact_id') IS NULL`)
643
+ .all(entityId, key);
644
+ for (const row of noOpRows) {
645
+ assertT(row.t);
646
+ ranked.push({
647
+ t: row.t,
648
+ rid: row.rid,
649
+ transition: {
650
+ entityId,
651
+ key,
652
+ previousValue: row.previousValue,
653
+ newValue: row.newValue,
654
+ delta: row.delta,
655
+ reason: row.reason,
656
+ t: row.t,
657
+ factId: null,
658
+ eventId: row.eventId,
659
+ at: row.at,
660
+ },
661
+ });
662
+ }
663
+ ranked.sort((a, b) => {
664
+ const byT = -compareT(a.t, b.t);
665
+ if (byT !== 0)
666
+ return byT;
667
+ return b.rid - a.rid;
668
+ });
669
+ const limited = limit !== undefined ? ranked.slice(0, limit) : ranked;
670
+ return limited.map((r) => r.transition);
671
+ }