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.
- package/README.md +76 -10
- package/dist/bin/run-dmcp.d.ts +2 -0
- package/dist/bin/run-dmcp.js +55 -0
- package/dist/db/connection.d.ts +32 -0
- package/dist/db/connection.js +38 -16
- package/dist/db/schema.d.ts +29 -1
- package/dist/db/schema.js +439 -7
- package/dist/http/server.js +3 -3
- package/dist/index.d.ts +36 -2
- package/dist/index.js +184 -92
- package/dist/mcp-server.d.ts +49 -0
- package/dist/mcp-server.js +127 -0
- package/dist/reader/turnReader.d.ts +185 -0
- package/dist/reader/turnReader.js +288 -0
- package/dist/register/batch.js +5 -79
- package/dist/register/mcp-resources.d.ts +9 -0
- package/dist/register/mcp-resources.js +16 -62
- package/dist/register/render.d.ts +17 -0
- package/dist/register/render.js +50 -0
- package/dist/register/resolve.d.ts +14 -0
- package/dist/register/resolve.js +102 -0
- package/dist/register/resources.js +11 -4
- package/dist/register/timeline.d.ts +2 -0
- package/dist/register/timeline.js +311 -0
- package/dist/rpg/index.d.ts +29 -0
- package/dist/rpg/index.js +55 -0
- package/dist/rpg/register/abilities.d.ts +2 -0
- package/dist/rpg/register/abilities.js +165 -0
- package/dist/rpg/register/batch.d.ts +2 -0
- package/dist/rpg/register/batch.js +92 -0
- package/dist/rpg/register/combat.d.ts +2 -0
- package/dist/rpg/register/combat.js +207 -0
- package/dist/rpg/register/mcp-prompts.d.ts +2 -0
- package/dist/rpg/register/mcp-prompts.js +684 -0
- package/dist/rpg/register/mcp-resources.d.ts +2 -0
- package/dist/rpg/register/mcp-resources.js +61 -0
- package/dist/rpg/register/quests.d.ts +2 -0
- package/dist/rpg/register/quests.js +118 -0
- package/dist/rpg/register/status.d.ts +2 -0
- package/dist/rpg/register/status.js +130 -0
- package/dist/rpg/register/tables.d.ts +2 -0
- package/dist/rpg/register/tables.js +146 -0
- package/dist/rpg/tools/ability.d.ts +48 -0
- package/dist/rpg/tools/ability.js +238 -0
- package/dist/rpg/tools/combat.d.ts +13 -0
- package/dist/rpg/tools/combat.js +195 -0
- package/dist/rpg/tools/dice.d.ts +23 -0
- package/dist/rpg/tools/dice.js +111 -0
- package/dist/rpg/tools/quest.d.ts +34 -0
- package/dist/rpg/tools/quest.js +164 -0
- package/dist/rpg/tools/status.d.ts +36 -0
- package/dist/rpg/tools/status.js +218 -0
- package/dist/rpg/tools/tables.d.ts +33 -0
- package/dist/rpg/tools/tables.js +209 -0
- package/dist/schemas/index.d.ts +12 -12
- package/dist/timeline/adjudication.d.ts +150 -0
- package/dist/timeline/adjudication.js +174 -0
- package/dist/timeline/changes.d.ts +100 -0
- package/dist/timeline/changes.js +161 -0
- package/dist/timeline/checkpoint.d.ts +69 -0
- package/dist/timeline/checkpoint.js +131 -0
- package/dist/timeline/clock.d.ts +89 -0
- package/dist/timeline/clock.js +173 -0
- package/dist/timeline/constrained.d.ts +220 -0
- package/dist/timeline/constrained.js +671 -0
- package/dist/timeline/export.d.ts +171 -0
- package/dist/timeline/export.js +329 -0
- package/dist/timeline/irreversible.d.ts +85 -0
- package/dist/timeline/irreversible.js +108 -0
- package/dist/timeline/kinds.d.ts +14 -0
- package/dist/timeline/kinds.js +22 -0
- package/dist/timeline/narration.d.ts +175 -0
- package/dist/timeline/narration.js +259 -0
- package/dist/timeline/projection.d.ts +97 -0
- package/dist/timeline/projection.js +330 -0
- package/dist/timeline/provenance.d.ts +66 -0
- package/dist/timeline/provenance.js +45 -0
- package/dist/timeline/registry.d.ts +95 -0
- package/dist/timeline/registry.js +124 -0
- package/dist/timeline/render.d.ts +121 -0
- package/dist/timeline/render.js +187 -0
- package/dist/timeline/replay.d.ts +64 -0
- package/dist/timeline/replay.js +104 -0
- package/dist/timeline/resolve.d.ts +262 -0
- package/dist/timeline/resolve.js +226 -0
- package/dist/timeline/schema.d.ts +13 -0
- package/dist/timeline/schema.js +262 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +31 -6
- package/dist/tools/resource.js +106 -153
- package/dist/types/index.d.ts +19 -1
- package/dist/utils/output-schemas.d.ts +593 -2
- package/dist/utils/output-schemas.js +3 -0
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +20 -4
- package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
- package/dist/__tests__/engineVocabulary.test.js +0 -147
- package/dist/db/__tests__/connection.test.d.ts +0 -1
- package/dist/db/__tests__/connection.test.js +0 -72
- package/dist/db/__tests__/testDb.d.ts +0 -33
- package/dist/db/__tests__/testDb.js +0 -41
- package/dist/test-setup.d.ts +0 -1
- package/dist/test-setup.js +0 -13
- package/dist/tools/__tests__/audio.test.d.ts +0 -1
- package/dist/tools/__tests__/audio.test.js +0 -59
- package/dist/tools/__tests__/conserved.test.d.ts +0 -1
- package/dist/tools/__tests__/conserved.test.js +0 -488
- package/dist/tools/__tests__/constraint.test.d.ts +0 -1
- package/dist/tools/__tests__/constraint.test.js +0 -212
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
- package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
- package/dist/tools/__tests__/images.test.d.ts +0 -1
- package/dist/tools/__tests__/images.test.js +0 -59
- package/dist/tools/__tests__/relationship.test.d.ts +0 -1
- package/dist/tools/__tests__/relationship.test.js +0 -132
- package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
- package/dist/tools/__tests__/resource-constraints.test.js +0 -131
- package/dist/tools/__tests__/resource.test.d.ts +0 -1
- package/dist/tools/__tests__/resource.test.js +0 -190
- package/dist/tools/__tests__/time.test.d.ts +0 -1
- package/dist/tools/__tests__/time.test.js +0 -404
- package/dist/tools/__tests__/timers.test.d.ts +0 -1
- package/dist/tools/__tests__/timers.test.js +0 -426
- package/dist/tools/__tests__/world.test.d.ts +0 -1
- package/dist/tools/__tests__/world.test.js +0 -70
- package/dist/utils/__tests__/json.test.d.ts +0 -1
- package/dist/utils/__tests__/json.test.js +0 -55
- package/dist/utils/__tests__/validation.test.d.ts +0 -1
- package/dist/utils/__tests__/validation.test.js +0 -90
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
246
|
-
//
|
|
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 = ?,
|
|
320
|
+
SET relationship_type = ?, label = ?, notes = ?, updated_at = ?
|
|
251
321
|
WHERE id = ?
|
|
252
|
-
`).run(newType,
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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: {
|
package/dist/tools/resource.d.ts
CHANGED
|
@@ -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 --
|
|
44
|
-
* update_resource_value against such a resource specifically because
|
|
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
|
|
73
|
-
* mutually exclusive per resource means which one to use
|
|
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[];
|
package/dist/tools/resource.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from "uuid";
|
|
2
|
-
import { getDatabase
|
|
2
|
+
import { getDatabase } from "../db/connection.js";
|
|
3
3
|
import { validateGameExists } from "./game.js";
|
|
4
|
-
import {
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 --
|
|
235
|
-
* update_resource_value against such a resource specifically because
|
|
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
|
|
264
|
-
* mutually exclusive per resource means which one to use
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
}
|