run-dmcp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -11
- package/dist/bin/run-dmcp.d.ts +2 -0
- package/dist/bin/run-dmcp.js +55 -0
- package/dist/db/connection.d.ts +32 -0
- package/dist/db/connection.js +38 -16
- package/dist/db/schema.d.ts +29 -1
- package/dist/db/schema.js +594 -10
- package/dist/http/server.js +25 -4
- package/dist/index.d.ts +69 -2
- package/dist/index.js +262 -92
- package/dist/mcp-server.d.ts +49 -0
- package/dist/mcp-server.js +127 -0
- package/dist/reader/turnReader.d.ts +185 -0
- package/dist/reader/turnReader.js +288 -0
- package/dist/register/batch.js +5 -79
- package/dist/register/mcp-resources.d.ts +9 -0
- package/dist/register/mcp-resources.js +16 -62
- package/dist/register/render.d.ts +17 -0
- package/dist/register/render.js +50 -0
- package/dist/register/resolve.d.ts +14 -0
- package/dist/register/resolve.js +102 -0
- package/dist/register/resources.js +13 -6
- package/dist/register/timeline.d.ts +2 -0
- package/dist/register/timeline.js +311 -0
- package/dist/rpg/index.d.ts +29 -0
- package/dist/rpg/index.js +55 -0
- package/dist/rpg/register/abilities.d.ts +2 -0
- package/dist/rpg/register/abilities.js +165 -0
- package/dist/rpg/register/batch.d.ts +2 -0
- package/dist/rpg/register/batch.js +92 -0
- package/dist/rpg/register/combat.d.ts +2 -0
- package/dist/rpg/register/combat.js +207 -0
- package/dist/rpg/register/mcp-prompts.d.ts +2 -0
- package/dist/rpg/register/mcp-prompts.js +684 -0
- package/dist/rpg/register/mcp-resources.d.ts +2 -0
- package/dist/rpg/register/mcp-resources.js +61 -0
- package/dist/rpg/register/quests.d.ts +2 -0
- package/dist/rpg/register/quests.js +118 -0
- package/dist/rpg/register/status.d.ts +2 -0
- package/dist/rpg/register/status.js +130 -0
- package/dist/rpg/register/tables.d.ts +2 -0
- package/dist/rpg/register/tables.js +146 -0
- package/dist/rpg/tools/ability.d.ts +48 -0
- package/dist/rpg/tools/ability.js +238 -0
- package/dist/rpg/tools/combat.d.ts +13 -0
- package/dist/rpg/tools/combat.js +195 -0
- package/dist/rpg/tools/dice.d.ts +23 -0
- package/dist/rpg/tools/dice.js +111 -0
- package/dist/rpg/tools/quest.d.ts +34 -0
- package/dist/rpg/tools/quest.js +164 -0
- package/dist/rpg/tools/status.d.ts +36 -0
- package/dist/rpg/tools/status.js +218 -0
- package/dist/rpg/tools/tables.d.ts +33 -0
- package/dist/rpg/tools/tables.js +209 -0
- package/dist/schemas/index.d.ts +12 -12
- package/dist/timeline/adjudication.d.ts +150 -0
- package/dist/timeline/adjudication.js +174 -0
- package/dist/timeline/changes.d.ts +108 -0
- package/dist/timeline/changes.js +169 -0
- package/dist/timeline/checkpoint.d.ts +69 -0
- package/dist/timeline/checkpoint.js +131 -0
- package/dist/timeline/clock.d.ts +89 -0
- package/dist/timeline/clock.js +173 -0
- package/dist/timeline/constrained.d.ts +220 -0
- package/dist/timeline/constrained.js +671 -0
- package/dist/timeline/export.d.ts +181 -0
- package/dist/timeline/export.js +339 -0
- package/dist/timeline/irreversible.d.ts +87 -0
- package/dist/timeline/irreversible.js +108 -0
- package/dist/timeline/kinds.d.ts +14 -0
- package/dist/timeline/kinds.js +22 -0
- package/dist/timeline/narration.d.ts +175 -0
- package/dist/timeline/narration.js +259 -0
- package/dist/timeline/projection.d.ts +97 -0
- package/dist/timeline/projection.js +330 -0
- package/dist/timeline/provenance.d.ts +66 -0
- package/dist/timeline/provenance.js +45 -0
- package/dist/timeline/registry.d.ts +95 -0
- package/dist/timeline/registry.js +124 -0
- package/dist/timeline/render.d.ts +121 -0
- package/dist/timeline/render.js +187 -0
- package/dist/timeline/replay.d.ts +86 -0
- package/dist/timeline/replay.js +126 -0
- package/dist/timeline/resolve.d.ts +262 -0
- package/dist/timeline/resolve.js +226 -0
- package/dist/timeline/schema.d.ts +13 -0
- package/dist/timeline/schema.js +264 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/audio.js +13 -9
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/game.js +33 -1
- package/dist/tools/images.js +17 -10
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +33 -8
- package/dist/tools/resource.js +106 -153
- package/dist/tools/time.js +18 -3
- package/dist/types/index.d.ts +20 -2
- package/dist/utils/media-path.d.ts +52 -0
- package/dist/utils/media-path.js +106 -0
- package/dist/utils/output-schemas.d.ts +594 -3
- package/dist/utils/output-schemas.js +4 -1
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +25 -5
- package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
- package/dist/__tests__/engineVocabulary.test.js +0 -147
- package/dist/db/__tests__/connection.test.d.ts +0 -1
- package/dist/db/__tests__/connection.test.js +0 -72
- package/dist/db/__tests__/testDb.d.ts +0 -33
- package/dist/db/__tests__/testDb.js +0 -41
- package/dist/test-setup.d.ts +0 -1
- package/dist/test-setup.js +0 -13
- package/dist/tools/__tests__/audio.test.d.ts +0 -1
- package/dist/tools/__tests__/audio.test.js +0 -59
- package/dist/tools/__tests__/conserved.test.d.ts +0 -1
- package/dist/tools/__tests__/conserved.test.js +0 -488
- package/dist/tools/__tests__/constraint.test.d.ts +0 -1
- package/dist/tools/__tests__/constraint.test.js +0 -212
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
- package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
- package/dist/tools/__tests__/images.test.d.ts +0 -1
- package/dist/tools/__tests__/images.test.js +0 -59
- package/dist/tools/__tests__/relationship.test.d.ts +0 -1
- package/dist/tools/__tests__/relationship.test.js +0 -132
- package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
- package/dist/tools/__tests__/resource-constraints.test.js +0 -131
- package/dist/tools/__tests__/resource.test.d.ts +0 -1
- package/dist/tools/__tests__/resource.test.js +0 -190
- package/dist/tools/__tests__/time.test.d.ts +0 -1
- package/dist/tools/__tests__/time.test.js +0 -404
- package/dist/tools/__tests__/timers.test.d.ts +0 -1
- package/dist/tools/__tests__/timers.test.js +0 -426
- package/dist/tools/__tests__/world.test.d.ts +0 -1
- package/dist/tools/__tests__/world.test.js +0 -70
- package/dist/utils/__tests__/json.test.d.ts +0 -1
- package/dist/utils/__tests__/json.test.js +0 -55
- package/dist/utils/__tests__/validation.test.d.ts +0 -1
- package/dist/utils/__tests__/validation.test.js +0 -90
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
|
}
|
package/dist/tools/time.js
CHANGED
|
@@ -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
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|