run-dmcp 0.8.0 → 0.10.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/dist/bin/run-dmcp.js +16 -1
- package/dist/db/schema.js +62 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +12 -1
- package/dist/mcp-server.d.ts +6 -1
- package/dist/mcp-server.js +11 -3
- package/dist/reader/preflight.d.ts +62 -0
- package/dist/reader/preflight.js +40 -0
- package/dist/reader/turnReader.d.ts +169 -13
- package/dist/reader/turnReader.js +232 -78
- package/dist/register/conditions.d.ts +16 -0
- package/dist/register/conditions.js +45 -0
- package/dist/register/reader.d.ts +2 -0
- package/dist/register/reader.js +132 -0
- package/dist/register/render.js +7 -2
- package/dist/register/resolve.js +8 -2
- package/dist/register/timeline.js +29 -2
- package/dist/rpg/server.d.ts +2 -0
- package/dist/schemas/index.d.ts +10 -10
- package/dist/timeline/constrained.d.ts +4 -5
- package/dist/timeline/constrained.js +31 -25
- package/dist/timeline/declared.d.ts +147 -0
- package/dist/timeline/declared.js +388 -0
- package/dist/timeline/registry.d.ts +42 -4
- package/dist/timeline/registry.js +62 -4
- package/dist/timeline/render.d.ts +3 -0
- package/dist/timeline/render.js +1 -1
- package/dist/timeline/replay.d.ts +15 -0
- package/dist/timeline/replay.js +33 -2
- package/dist/timeline/resolve.d.ts +60 -5
- package/dist/timeline/resolve.js +135 -5
- package/dist/timeline/schema.js +28 -0
- package/dist/timeline/turns.d.ts +60 -0
- package/dist/timeline/turns.js +93 -0
- package/dist/tools/constraint.js +10 -20
- package/dist/types/index.d.ts +3 -0
- package/dist/utils/output-schemas.d.ts +6 -6
- package/package.json +1 -1
|
@@ -92,8 +92,7 @@ import { type ValueTransition, type SetTransition, type CreatedEntity, type Dest
|
|
|
92
92
|
* mechanic's name, and the change count -- following
|
|
93
93
|
* `applyLiveWrite`'s (constrained.ts) `causes` discipline of never
|
|
94
94
|
* including a `row_id` key, which belongs to the projection triggers'
|
|
95
|
-
* own vocabulary
|
|
96
|
-
* would make `findOpenedByEventId`'s pick non-deterministic).
|
|
95
|
+
* own vocabulary.
|
|
97
96
|
* 7. Build the outcome's constraint AFTER the writes have landed --
|
|
98
97
|
* re-reading `currentStoryTime` inside the same transaction, after
|
|
99
98
|
* every change has been applied, so a `sequence`-axis game (whose `t`
|
|
@@ -125,6 +124,13 @@ export interface Proposal {
|
|
|
125
124
|
mechanic: string;
|
|
126
125
|
parameters?: Record<string, unknown>;
|
|
127
126
|
expects?: readonly Expectation[];
|
|
127
|
+
/** The principal proposing (issue #40): an entity id. When the game has
|
|
128
|
+
* declared any turn order, a proposal whose actor is not the one due at
|
|
129
|
+
* the current t -- including a t no declaration covers yet -- is refused
|
|
130
|
+
* `out-of-turn` before dispatch. Omitted, the
|
|
131
|
+
* proposal is the world's own (time passing, a scheduled consequence) and
|
|
132
|
+
* is not a turn. Recorded in the resolution's causes either way. */
|
|
133
|
+
actor?: string;
|
|
128
134
|
}
|
|
129
135
|
/**
|
|
130
136
|
* What a `Mechanic`'s `adjudicate` receives -- and ALL it receives. There is
|
|
@@ -204,13 +210,54 @@ export interface IntendedSet {
|
|
|
204
210
|
ref: string;
|
|
205
211
|
};
|
|
206
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* One constraint a `create` leg declares on the entity it makes (issue #42),
|
|
215
|
+
* applied by `resolve()` step 5 INSIDE the same transaction as the create --
|
|
216
|
+
* so a later leg of the same resolution is already held to it, and the
|
|
217
|
+
* whole resolution rolls back together if it is not (see
|
|
218
|
+
* assertCreateConstraintKeysValid and the `declareCreateConstraints` call in
|
|
219
|
+
* `resolveProposal` below). Vocabulary matches the existing constraint
|
|
220
|
+
* family exactly (declareBoundedConstraint/declareMonotonicConstraint/
|
|
221
|
+
* declareResolveOnlyConstraint, src/tools/constraint.ts) -- `conserved` is
|
|
222
|
+
* deliberately absent: it is a statement about several EXISTING entities
|
|
223
|
+
* summing to a total, not about one being created, so a single `create` leg
|
|
224
|
+
* has nothing to attach it to.
|
|
225
|
+
*
|
|
226
|
+
* The engine interprets neither `key` nor any bound below -- it only
|
|
227
|
+
* confirms, before any write, that `key` names a live column of the
|
|
228
|
+
* entity's own table (assertCreateConstraintKeysValid). `direction`'s
|
|
229
|
+
* vocabulary here ('up'/'down') is this field's own; declareCreateConstraints
|
|
230
|
+
* translates it to the registry's stored 'increasing'/'decreasing' the same
|
|
231
|
+
* way `entityKind` is translated to a table name -- a structural lookup, not
|
|
232
|
+
* an interpretation of what either word means (root CLAUDE.md hard rule 4).
|
|
233
|
+
*/
|
|
234
|
+
export type CreateConstraint = {
|
|
235
|
+
kind: "bounded";
|
|
236
|
+
key: string;
|
|
237
|
+
minValue: number | null;
|
|
238
|
+
maxValue: number | null;
|
|
239
|
+
} | {
|
|
240
|
+
kind: "resolve_only";
|
|
241
|
+
key: string;
|
|
242
|
+
} | {
|
|
243
|
+
kind: "monotonic";
|
|
244
|
+
key: string;
|
|
245
|
+
direction: "up" | "down";
|
|
246
|
+
};
|
|
207
247
|
/** One intended entity coming into existence (issue #34): a row in the
|
|
208
248
|
* projected table for `entityKind`, with `columns` restricted to that
|
|
209
249
|
* table's live columns and the game column filled by the engine from the
|
|
210
250
|
* proposal. `ref` is the label later legs of this same resolution use for
|
|
211
251
|
* it; the engine allocates the id and reports it in `Outcome.created`.
|
|
212
252
|
* The engine never learns what the entity is for or what it was made
|
|
213
|
-
* from -- "derived from" is the caller's to record.
|
|
253
|
+
* from -- "derived from" is the caller's to record.
|
|
254
|
+
*
|
|
255
|
+
* `constraints` (issue #42) is optional and, unlike `columns`, is never a
|
|
256
|
+
* column write -- it declares `resource_constraints` rows governing the
|
|
257
|
+
* entity this leg creates, the same rows declareBoundedConstraint/
|
|
258
|
+
* declareMonotonicConstraint/declareResolveOnlyConstraint would declare
|
|
259
|
+
* after the fact, but inside the resolution's own transaction instead of a
|
|
260
|
+
* second write path after `resolve()` returns. */
|
|
214
261
|
export interface IntendedCreate {
|
|
215
262
|
kind: "create";
|
|
216
263
|
ref: string;
|
|
@@ -218,6 +265,7 @@ export interface IntendedCreate {
|
|
|
218
265
|
columns: Readonly<Record<string, string | number | null | {
|
|
219
266
|
ref: string;
|
|
220
267
|
}>>;
|
|
268
|
+
constraints?: readonly CreateConstraint[];
|
|
221
269
|
}
|
|
222
270
|
/** One intended entity ending (issue #34): its live row deleted, its facts
|
|
223
271
|
* closed by the projection trigger. See `destroyProjectedEntity`
|
|
@@ -287,13 +335,20 @@ export interface Mechanic {
|
|
|
287
335
|
* under -- never a judgement about the proposal, the mechanic, or the
|
|
288
336
|
* world (see the module doc comment's "records decisions, does not make
|
|
289
337
|
* them" paragraph). */
|
|
290
|
-
export type ResolveRefusalReason = "unknown-mechanic" | "no-clock"
|
|
338
|
+
export type ResolveRefusalReason = "unknown-mechanic" | "no-clock"
|
|
339
|
+
/** The proposal named an `actor`, the game has declared a turn order, and
|
|
340
|
+
* that actor is not the one due at the current t (issue #40). Refused
|
|
341
|
+
* before dispatch. */
|
|
342
|
+
| "out-of-turn" | "expectation-contradicted"
|
|
291
343
|
/** A leg said `{ ref }` and no earlier `create` leg of the same
|
|
292
344
|
* resolution defined that ref (issue #34). Refused before any write. */
|
|
293
345
|
| "unresolved-ref"
|
|
294
346
|
/** Two `create` legs of one resolution chose the same `ref` (issue #34).
|
|
295
347
|
* Refused before any write. */
|
|
296
|
-
| "duplicate-ref"
|
|
348
|
+
| "duplicate-ref"
|
|
349
|
+
/** A `create` leg's `constraints` names a `key` that is not a live column
|
|
350
|
+
* of the entity it creates (issue #42). Refused before any write. */
|
|
351
|
+
| "invalid-constraint-key";
|
|
297
352
|
/**
|
|
298
353
|
* Refused before dispatch, before any write, or (never, by construction --
|
|
299
354
|
* see step 5 above) mid-apply. `reason` is the discriminant a caller
|
package/dist/timeline/resolve.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from "uuid";
|
|
2
2
|
import { getDatabase, withTransaction } from "../db/connection.js";
|
|
3
3
|
import { currentStoryTime } from "./clock.js";
|
|
4
|
+
import { dueAt, hasTurnOrder } from "./turns.js";
|
|
4
5
|
import { narrationConstraintAt, contradictions } from "./narration.js";
|
|
5
6
|
import { withAdjudicationOpen } from "./adjudication.js";
|
|
7
|
+
import { PROJECTED_TABLES, liveColumns } from "./projection.js";
|
|
8
|
+
import { insertConstraintRow, constraintsFor } from "./registry.js";
|
|
6
9
|
import { writeConstrainedValue, transferConstrainedValue, setProjectedValue, createProjectedEntity, destroyProjectedEntity, } from "./constrained.js";
|
|
7
10
|
/**
|
|
8
11
|
* Refused before dispatch, before any write, or (never, by construction --
|
|
@@ -100,6 +103,24 @@ function refsUsedBy(change) {
|
|
|
100
103
|
return [];
|
|
101
104
|
}
|
|
102
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* A write leg that supplies no `bounds`, to a key a `bounded` constraint
|
|
108
|
+
* governs on a resource, is held to that resource's own min/max -- exactly
|
|
109
|
+
* the bounds `update_resource_value` supplies for the same write
|
|
110
|
+
* (tools/resource.ts). Without this, a resolution could write straight past a
|
|
111
|
+
* bound the tool path refuses, because the choke point checks `bounded`
|
|
112
|
+
* against the bounds a writer hands it and this writer handed none. Found by
|
|
113
|
+
* the 2026-09-26 review, through a declared mechanic (#41) with no min/max;
|
|
114
|
+
* a hand-written mechanic had the same hole. Nothing changes for a key with
|
|
115
|
+
* no `bounded` constraint: supplying bounds there would start CLAMPING writes
|
|
116
|
+
* that land outside min/max today.
|
|
117
|
+
*/
|
|
118
|
+
function boundedResourceDefault(entityId, key) {
|
|
119
|
+
if (key !== "value" || !constraintsFor(entityId, key).some((c) => c.kind === "bounded"))
|
|
120
|
+
return undefined;
|
|
121
|
+
const row = getDatabase().prepare(`SELECT min_value, max_value FROM resources WHERE id = ?`).get(entityId);
|
|
122
|
+
return row ? { minValue: row.min_value, maxValue: row.max_value } : undefined;
|
|
123
|
+
}
|
|
103
124
|
/**
|
|
104
125
|
* Step 5's precondition (issue #34): every `{ ref }` names a `create` leg
|
|
105
126
|
* EARLIER in the list, and no two creates share a ref. Checked over the
|
|
@@ -126,6 +147,85 @@ function assertRefsResolvable(mechanicName, changes) {
|
|
|
126
147
|
}
|
|
127
148
|
});
|
|
128
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Step 5's second precondition (issue #42), checked in the same voice and
|
|
152
|
+
* at the same point as assertRefsResolvable above: every `constraints`
|
|
153
|
+
* entry on every `create` leg names a `key` that is a live column of the
|
|
154
|
+
* projected table for that leg's `entityKind` -- decidable from the schema
|
|
155
|
+
* alone, with no database write and no dependency on the entity actually
|
|
156
|
+
* existing yet, because the live column set is a property of the KIND, not
|
|
157
|
+
* of any one row of it. A bad key refuses with no transaction opened and no
|
|
158
|
+
* write attempted, naming the key.
|
|
159
|
+
*/
|
|
160
|
+
function assertCreateConstraintKeysValid(mechanicName, changes) {
|
|
161
|
+
const db = getDatabase();
|
|
162
|
+
changes.forEach((change, index) => {
|
|
163
|
+
if (change.kind !== "create" || !change.constraints || change.constraints.length === 0)
|
|
164
|
+
return;
|
|
165
|
+
const projected = PROJECTED_TABLES.find((p) => p.kind === change.entityKind);
|
|
166
|
+
// An entityKind with no projected table is createProjectedEntity's own
|
|
167
|
+
// refusal to make, inside the transaction -- nothing to validate a
|
|
168
|
+
// constraint key against here.
|
|
169
|
+
if (!projected)
|
|
170
|
+
return;
|
|
171
|
+
const cols = liveColumns(db, projected.table);
|
|
172
|
+
for (const constraint of change.constraints) {
|
|
173
|
+
if (!cols.includes(constraint.key)) {
|
|
174
|
+
throw new ResolveProtocolError("invalid-constraint-key", `resolve: leg ${index + 1} of mechanic '${mechanicName}' declares a '${constraint.kind}' constraint on key ` +
|
|
175
|
+
`'${constraint.key}' for the entity it creates under ref '${change.ref}', and '${constraint.key}' is not a ` +
|
|
176
|
+
`live column of '${projected.table}' -- refused before any write.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Translates every `CreateConstraint` (issue #42) a `create` leg declared
|
|
183
|
+
* into a `resource_constraints` row, through insertConstraintRow
|
|
184
|
+
* (registry.ts) -- the same primitive every declare*Constraint()
|
|
185
|
+
* (src/tools/constraint.ts) call now shares, so this is never a second
|
|
186
|
+
* write path for that table. Called from INSIDE the resolution's own
|
|
187
|
+
* transaction (resolveProposal below), immediately after the entity it
|
|
188
|
+
* governs is created, so `entityId` is real and `eventId` is the id the
|
|
189
|
+
* resolution's own `resolution.recorded` event will carry.
|
|
190
|
+
*/
|
|
191
|
+
function declareCreateConstraints(params) {
|
|
192
|
+
for (const constraint of params.constraints) {
|
|
193
|
+
if (constraint.kind === "bounded") {
|
|
194
|
+
insertConstraintRow({
|
|
195
|
+
gameId: params.gameId,
|
|
196
|
+
kind: "bounded",
|
|
197
|
+
resourceIds: [params.entityId],
|
|
198
|
+
factKey: constraint.key,
|
|
199
|
+
minValue: constraint.minValue,
|
|
200
|
+
maxValue: constraint.maxValue,
|
|
201
|
+
causedByEventId: params.eventId,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
else if (constraint.kind === "resolve_only") {
|
|
205
|
+
insertConstraintRow({
|
|
206
|
+
gameId: params.gameId,
|
|
207
|
+
kind: "resolve_only",
|
|
208
|
+
resourceIds: [params.entityId],
|
|
209
|
+
factKey: constraint.key,
|
|
210
|
+
causedByEventId: params.eventId,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
insertConstraintRow({
|
|
215
|
+
gameId: params.gameId,
|
|
216
|
+
kind: "monotonic",
|
|
217
|
+
resourceIds: [params.entityId],
|
|
218
|
+
factKey: constraint.key,
|
|
219
|
+
// This field's own vocabulary ('up'/'down') is translated to the
|
|
220
|
+
// registry's stored 'increasing'/'decreasing' -- a structural
|
|
221
|
+
// lookup, not an interpretation of what either word means (see
|
|
222
|
+
// CreateConstraint's own doc comment).
|
|
223
|
+
direction: constraint.direction === "up" ? "increasing" : "decreasing",
|
|
224
|
+
causedByEventId: params.eventId,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
129
229
|
function deref(value, refs) {
|
|
130
230
|
if (typeof value === "string")
|
|
131
231
|
return value;
|
|
@@ -145,15 +245,16 @@ function applyChange(change, gameId, refs) {
|
|
|
145
245
|
return { set: setProjectedValue({ entityId: deref(change.entityId, refs), key: change.key, value: derefValue(change.value, refs) }) };
|
|
146
246
|
}
|
|
147
247
|
if (change.kind === "write") {
|
|
248
|
+
const entityId = deref(change.entityId, refs);
|
|
148
249
|
return {
|
|
149
250
|
transitions: [
|
|
150
251
|
writeConstrainedValue({
|
|
151
|
-
entityId
|
|
252
|
+
entityId,
|
|
152
253
|
key: change.key,
|
|
153
254
|
mode: change.mode,
|
|
154
255
|
value: change.value,
|
|
155
256
|
reason: change.reason,
|
|
156
|
-
bounds: change.bounds,
|
|
257
|
+
bounds: change.bounds ?? boundedResourceDefault(entityId, change.key),
|
|
157
258
|
}),
|
|
158
259
|
],
|
|
159
260
|
};
|
|
@@ -212,6 +313,16 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
212
313
|
`something through the normal tools first, then propose again.`);
|
|
213
314
|
}
|
|
214
315
|
const t = preStory.t;
|
|
316
|
+
// 2b. Turns (issue #40): an actor who is not due is refused before the
|
|
317
|
+
// mechanic ever sees the proposal. No actor -> not a turn -> no check.
|
|
318
|
+
if (proposal.actor !== undefined && hasTurnOrder(gameId)) {
|
|
319
|
+
const due = dueAt({ gameId, t });
|
|
320
|
+
if (!due || due.principal !== proposal.actor) {
|
|
321
|
+
throw new ResolveProtocolError("out-of-turn", `resolve: '${proposal.actor}' proposed at t=${t}, but ` +
|
|
322
|
+
(due ? `'${due.principal}' is due at t=${t}` : `nobody is due at t=${t}`) +
|
|
323
|
+
` under this game's declared turn order. Ask who is due (due_at) or move to the next turn (advance_turn).`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
215
326
|
// 3. ONE query builds both the mechanic's read surface AND the inbound
|
|
216
327
|
// precondition check -- see the module doc comment's unification note on
|
|
217
328
|
// why this is the same structure read in two directions, not two
|
|
@@ -246,8 +357,10 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
246
357
|
});
|
|
247
358
|
const resolutionId = uuidv4();
|
|
248
359
|
const changes = adjudication.changes ?? [];
|
|
249
|
-
// 5's
|
|
360
|
+
// 5's preconditions: every ref resolves, and every create leg's declared
|
|
361
|
+
// constraint keys are live columns -- both before the transaction opens.
|
|
250
362
|
assertRefsResolvable(mechanicName, changes);
|
|
363
|
+
assertCreateConstraintKeysValid(mechanicName, changes);
|
|
251
364
|
// 5 & 6. Apply every intended change through the one choke point, and
|
|
252
365
|
// record one event -- both inside ONE transaction with the adjudication
|
|
253
366
|
// window nested inside it (adjudication.ts's own doc comment asks for
|
|
@@ -262,14 +375,31 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
262
375
|
const created = [];
|
|
263
376
|
const destroyed = [];
|
|
264
377
|
const refs = new Map();
|
|
378
|
+
// Generated here, before any change is applied, rather than after the
|
|
379
|
+
// loop below (as it used to be) -- issue #42's create-leg constraint
|
|
380
|
+
// declarations (below) need to stamp the SAME event id this
|
|
381
|
+
// resolution's own `resolution.recorded` event is about to carry, and
|
|
382
|
+
// that event cannot be written until every change has landed (step 7's
|
|
383
|
+
// t comes from AFTER the writes). uuidv4() needs nothing from the
|
|
384
|
+
// database, so generating it early costs nothing and lets both sides
|
|
385
|
+
// agree on one id.
|
|
386
|
+
const eventId = uuidv4();
|
|
265
387
|
for (const change of changes) {
|
|
266
388
|
const applied = applyChange(change, gameId, refs);
|
|
267
389
|
if ("transitions" in applied)
|
|
268
390
|
transitions.push(...applied.transitions);
|
|
269
391
|
else if ("set" in applied)
|
|
270
392
|
sets.push(applied.set);
|
|
271
|
-
else if ("created" in applied)
|
|
393
|
+
else if ("created" in applied) {
|
|
272
394
|
created.push(applied.created);
|
|
395
|
+
// Issue #42: a create leg's declared constraints are applied
|
|
396
|
+
// immediately, inside this same transaction, once the entity they
|
|
397
|
+
// govern has a real id -- so a later leg of this same resolution
|
|
398
|
+
// (still to come in this loop) is already held to them.
|
|
399
|
+
if (change.kind === "create" && change.constraints && change.constraints.length > 0) {
|
|
400
|
+
declareCreateConstraints({ gameId, entityId: applied.created.entityId, constraints: change.constraints, eventId });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
273
403
|
else
|
|
274
404
|
destroyed.push(applied.destroyed);
|
|
275
405
|
}
|
|
@@ -286,12 +416,12 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
286
416
|
// function has no business assuming that silently forever.
|
|
287
417
|
throw new Error(`resolve: game '${gameId}' lost its timeline clock mid-resolution -- cannot record the outcome event`);
|
|
288
418
|
}
|
|
289
|
-
const eventId = uuidv4();
|
|
290
419
|
const causes = {
|
|
291
420
|
source: "resolve",
|
|
292
421
|
resolution_id: resolutionId,
|
|
293
422
|
mechanic: mechanicName,
|
|
294
423
|
change_count: changes.length,
|
|
424
|
+
...(proposal.actor !== undefined ? { actor: proposal.actor } : {}),
|
|
295
425
|
};
|
|
296
426
|
getDatabase()
|
|
297
427
|
.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'resolution.recorded', ?, ?)`)
|
package/dist/timeline/schema.js
CHANGED
|
@@ -282,6 +282,34 @@ export function initializeTimelineSchema() {
|
|
|
282
282
|
BEGIN
|
|
283
283
|
SELECT RAISE(ABORT, 'timeline: an irreversible fact holds for key ''' || NEW.key || ''' on this entity as of its valid_from_t; a contradicting value is refused from that point onward');
|
|
284
284
|
END;
|
|
285
|
+
`);
|
|
286
|
+
// Issue #40: a game's declared turn order -- which entities act in what
|
|
287
|
+
// cycle, from a `from_t` on. Append-only like the timeline it answers for:
|
|
288
|
+
// `dueAt(t)` for a past t must give the same answer forever, so a
|
|
289
|
+
// declaration is never edited or removed, only superseded by a later one
|
|
290
|
+
// from a later `from_t` (turns.ts refuses anything else before insert).
|
|
291
|
+
db.exec(`
|
|
292
|
+
CREATE TABLE IF NOT EXISTS turn_orders (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
game_id TEXT NOT NULL,
|
|
295
|
+
from_t REAL NOT NULL,
|
|
296
|
+
principals TEXT NOT NULL,
|
|
297
|
+
declared_at TEXT NOT NULL,
|
|
298
|
+
UNIQUE (game_id, from_t)
|
|
299
|
+
);
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_turn_orders_game_from ON turn_orders(game_id, from_t);
|
|
301
|
+
DROP TRIGGER IF EXISTS timeline_turn_orders_immutable;
|
|
302
|
+
CREATE TRIGGER timeline_turn_orders_immutable
|
|
303
|
+
BEFORE UPDATE ON turn_orders
|
|
304
|
+
BEGIN
|
|
305
|
+
SELECT RAISE(ABORT, 'timeline: turn orders are append-only; a declaration is superseded, never edited');
|
|
306
|
+
END;
|
|
307
|
+
DROP TRIGGER IF EXISTS timeline_turn_orders_no_delete;
|
|
308
|
+
CREATE TRIGGER timeline_turn_orders_no_delete
|
|
309
|
+
BEFORE DELETE ON turn_orders
|
|
310
|
+
BEGIN
|
|
311
|
+
SELECT RAISE(ABORT, 'timeline: turn orders are append-only; a declaration is superseded, never deleted');
|
|
312
|
+
END;
|
|
285
313
|
`);
|
|
286
314
|
// Issue #2: the projection layer. Triggers first, so every write from
|
|
287
315
|
// here on appends by construction; reconciliation second, so it backfills
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type T } from "./t.js";
|
|
2
|
+
/**
|
|
3
|
+
* A principal is due to act (GitHub issue #40). The issue draws the line:
|
|
4
|
+
* "The engine records whose turn it is and refuses out-of-turn proposals if
|
|
5
|
+
* a game declares turns. It never runs the turn itself." So this module holds
|
|
6
|
+
* a DECLARATION -- which entities act, in what cycle, from which t -- and
|
|
7
|
+
* answers "who is due at t" for any t, past included. It never schedules,
|
|
8
|
+
* waits, notifies or calls anything: an MCP server answers calls, so
|
|
9
|
+
* whatever runs minds asks (pull), moves the clock with `advanceTurn`, and
|
|
10
|
+
* proposes as that principal. `resolve()` refuses a proposal whose `actor` is
|
|
11
|
+
* not the one due (resolve.ts, `out-of-turn`).
|
|
12
|
+
*
|
|
13
|
+
* WHY A COUNTER AXIS ONLY. A turn is a count of things that happened, which is
|
|
14
|
+
* exactly what `counter` means (t.ts). On `sequence` the engine's own writes
|
|
15
|
+
* advance t, so "one principal per t" would hand the turn over mid-action;
|
|
16
|
+
* on `elapsed` t is a measure, and turns-per-second is a policy this engine
|
|
17
|
+
* has no business guessing.
|
|
18
|
+
*
|
|
19
|
+
* WHY APPEND-ONLY. `dueAt(t)` for a past t is history, and this engine can
|
|
20
|
+
* always say what the world looked like at any point. A changed order is a
|
|
21
|
+
* NEW declaration from a later t; the old one keeps answering for the t it
|
|
22
|
+
* covered. The `turn_orders` triggers (schema.ts) make an edit
|
|
23
|
+
* unconstructable rather than discouraged.
|
|
24
|
+
*
|
|
25
|
+
* A principal is an entity of the game (DESIGN.md §8's admission criterion:
|
|
26
|
+
* it resolves to an entity the engine already has). The engine never learns
|
|
27
|
+
* what a principal is for.
|
|
28
|
+
*/
|
|
29
|
+
export interface TurnDue {
|
|
30
|
+
t: T;
|
|
31
|
+
principal: string;
|
|
32
|
+
/** 1-based: which pass through the declared cycle this t belongs to. */
|
|
33
|
+
round: number;
|
|
34
|
+
/** 0-based position in the cycle. */
|
|
35
|
+
position: number;
|
|
36
|
+
/** The declaration this answer comes from. */
|
|
37
|
+
fromT: T;
|
|
38
|
+
}
|
|
39
|
+
export declare function declareTurnOrder(params: {
|
|
40
|
+
gameId: string;
|
|
41
|
+
principals: readonly string[];
|
|
42
|
+
fromT: T;
|
|
43
|
+
}): {
|
|
44
|
+
fromT: T;
|
|
45
|
+
principals: string[];
|
|
46
|
+
};
|
|
47
|
+
/** Who is due at `t`, or `null`: no declaration covers `t`, or `t` is not a
|
|
48
|
+
* whole step on from the declaration's `fromT`. */
|
|
49
|
+
export declare function dueAt(params: {
|
|
50
|
+
gameId: string;
|
|
51
|
+
t: T;
|
|
52
|
+
}): TurnDue | null;
|
|
53
|
+
/** Moves the clock to the next turn -- one step on, or to the first
|
|
54
|
+
* declaration's `fromT` if the clock has not reached it -- and says who is
|
|
55
|
+
* due there. Moves time only; runs nothing. */
|
|
56
|
+
export declare function advanceTurn(params: {
|
|
57
|
+
gameId: string;
|
|
58
|
+
}): TurnDue;
|
|
59
|
+
/** Whether the game has declared any turn order at all. */
|
|
60
|
+
export declare function hasTurnOrder(gameId: string): boolean;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { getDatabase } from "../db/connection.js";
|
|
3
|
+
import { currentStoryTime, setStoryTime } from "./clock.js";
|
|
4
|
+
import { assertT } from "./t.js";
|
|
5
|
+
function declarationAt(gameId, t) {
|
|
6
|
+
return getDatabase()
|
|
7
|
+
.prepare(`SELECT from_t, principals FROM turn_orders WHERE game_id = ? AND from_t <= ? ORDER BY from_t DESC LIMIT 1`)
|
|
8
|
+
.get(gameId, t);
|
|
9
|
+
}
|
|
10
|
+
function latestDeclaration(gameId) {
|
|
11
|
+
return getDatabase()
|
|
12
|
+
.prepare(`SELECT from_t, principals FROM turn_orders WHERE game_id = ? ORDER BY from_t DESC LIMIT 1`)
|
|
13
|
+
.get(gameId);
|
|
14
|
+
}
|
|
15
|
+
export function declareTurnOrder(params) {
|
|
16
|
+
const { gameId, fromT } = params;
|
|
17
|
+
assertT(fromT);
|
|
18
|
+
const principals = [...params.principals];
|
|
19
|
+
const story = currentStoryTime(gameId);
|
|
20
|
+
if (!story || story.axis.kind !== "counter") {
|
|
21
|
+
throw new Error(`declareTurnOrder: game '${gameId}' must be on a counter axis -- a turn is a count of things that happened ` +
|
|
22
|
+
`(declare_time_axis with kind 'counter')`);
|
|
23
|
+
}
|
|
24
|
+
if (!Number.isInteger(fromT))
|
|
25
|
+
throw new Error(`declareTurnOrder: fromT must be an integer t on the counter axis, got ${fromT}`);
|
|
26
|
+
if (fromT < story.t) {
|
|
27
|
+
throw new Error(`declareTurnOrder: fromT ${fromT} is before the game's current t (${story.t}); turns already taken cannot be reordered`);
|
|
28
|
+
}
|
|
29
|
+
const latest = latestDeclaration(gameId);
|
|
30
|
+
// The current t may already be someone's turn (and a resolution may
|
|
31
|
+
// already record them acting in it); reassigning it would rewrite that.
|
|
32
|
+
if (latest && latest.from_t <= story.t && fromT <= story.t) {
|
|
33
|
+
throw new Error(`declareTurnOrder: t=${story.t} is already covered by the declaration from ${latest.from_t}; a new order can start at t=${story.t + 1} at the earliest`);
|
|
34
|
+
}
|
|
35
|
+
if (latest && fromT <= latest.from_t) {
|
|
36
|
+
throw new Error(`declareTurnOrder: fromT ${fromT} must come after the latest declaration's fromT (${latest.from_t})`);
|
|
37
|
+
}
|
|
38
|
+
if (principals.length === 0)
|
|
39
|
+
throw new Error(`declareTurnOrder: a turn order needs at least one principal`);
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const alive = getDatabase().prepare(`SELECT 1 FROM entities WHERE id = ? AND game_id = ? AND created_at_t <= ? AND (destroyed_at_t IS NULL OR destroyed_at_t > ?)`);
|
|
42
|
+
for (const principal of principals) {
|
|
43
|
+
if (typeof principal !== "string")
|
|
44
|
+
throw new Error(`declareTurnOrder: a principal must be an entity id, got ${JSON.stringify(principal)}`);
|
|
45
|
+
if (seen.has(principal))
|
|
46
|
+
throw new Error(`declareTurnOrder: principal '${principal}' appears twice in one cycle`);
|
|
47
|
+
seen.add(principal);
|
|
48
|
+
if (!alive.get(principal, gameId, fromT, fromT)) {
|
|
49
|
+
throw new Error(`declareTurnOrder: '${principal}' is not an entity of this game alive at t=${fromT}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
getDatabase()
|
|
53
|
+
.prepare(`INSERT INTO turn_orders (id, game_id, from_t, principals, declared_at) VALUES (?, ?, ?, ?, ?)`)
|
|
54
|
+
.run(uuidv4(), gameId, fromT, JSON.stringify(principals), new Date().toISOString());
|
|
55
|
+
return { fromT, principals };
|
|
56
|
+
}
|
|
57
|
+
/** Who is due at `t`, or `null`: no declaration covers `t`, or `t` is not a
|
|
58
|
+
* whole step on from the declaration's `fromT`. */
|
|
59
|
+
export function dueAt(params) {
|
|
60
|
+
const { gameId, t } = params;
|
|
61
|
+
assertT(t);
|
|
62
|
+
const declaration = declarationAt(gameId, t);
|
|
63
|
+
if (!declaration)
|
|
64
|
+
return null;
|
|
65
|
+
const offset = t - declaration.from_t;
|
|
66
|
+
if (!Number.isInteger(offset))
|
|
67
|
+
return null;
|
|
68
|
+
const principals = JSON.parse(declaration.principals);
|
|
69
|
+
const position = offset % principals.length;
|
|
70
|
+
return { t, principal: principals[position], round: Math.floor(offset / principals.length) + 1, position, fromT: declaration.from_t };
|
|
71
|
+
}
|
|
72
|
+
/** Moves the clock to the next turn -- one step on, or to the first
|
|
73
|
+
* declaration's `fromT` if the clock has not reached it -- and says who is
|
|
74
|
+
* due there. Moves time only; runs nothing. */
|
|
75
|
+
export function advanceTurn(params) {
|
|
76
|
+
const { gameId } = params;
|
|
77
|
+
const story = currentStoryTime(gameId);
|
|
78
|
+
const first = getDatabase()
|
|
79
|
+
.prepare(`SELECT from_t FROM turn_orders WHERE game_id = ? ORDER BY from_t ASC LIMIT 1`)
|
|
80
|
+
.get(gameId);
|
|
81
|
+
if (!story || !first)
|
|
82
|
+
throw new Error(`advanceTurn: game '${gameId}' has no turn order declared (declare_turn_order)`);
|
|
83
|
+
const next = story.t < first.from_t ? first.from_t : Math.floor(story.t) + 1;
|
|
84
|
+
const due = dueAt({ gameId, t: next });
|
|
85
|
+
if (!due)
|
|
86
|
+
throw new Error(`advanceTurn: nobody is due at t=${next}`);
|
|
87
|
+
setStoryTime({ gameId, t: next });
|
|
88
|
+
return due;
|
|
89
|
+
}
|
|
90
|
+
/** Whether the game has declared any turn order at all. */
|
|
91
|
+
export function hasTurnOrder(gameId) {
|
|
92
|
+
return getDatabase().prepare(`SELECT 1 FROM turn_orders WHERE game_id = ? LIMIT 1`).get(gameId) !== undefined;
|
|
93
|
+
}
|
package/dist/tools/constraint.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { v4 as uuidv4 } from "uuid";
|
|
2
1
|
import { getDatabase } from "../db/connection.js";
|
|
3
2
|
import { validateGameExists } from "./game.js";
|
|
4
3
|
import { getResource } from "./resource.js";
|
|
5
|
-
import { ConstraintViolationError, CONSERVED_SUM_EPSILON, constraintsFor, allConstraintsForEntity, rowToConstraint, } from "../timeline/registry.js";
|
|
4
|
+
import { ConstraintViolationError, CONSERVED_SUM_EPSILON, constraintsFor, allConstraintsForEntity, insertConstraintRow, rowToConstraint, } from "../timeline/registry.js";
|
|
6
5
|
// Re-exported so every existing importer (src/tools/resource.ts,
|
|
7
6
|
// src/register/resources.ts) keeps working unchanged -- these two now live
|
|
8
7
|
// in src/timeline/registry.js; see the paragraph below on why.
|
|
@@ -72,25 +71,16 @@ export { ConstraintViolationError, CONSERVED_SUM_EPSILON };
|
|
|
72
71
|
* adjudication window is open (src/timeline/adjudication.ts), not anything
|
|
73
72
|
* about the intended value itself.
|
|
74
73
|
*/
|
|
74
|
+
// The raw INSERT this used to perform directly now lives in
|
|
75
|
+
// insertConstraintRow (src/timeline/registry.js) -- see that module's own
|
|
76
|
+
// doc comment for why (issue #42: resolve() needs to write the identical
|
|
77
|
+
// row from inside its own transaction, and src/timeline/ cannot reach back
|
|
78
|
+
// into src/tools/ to get here without closing a cycle). This wrapper keeps
|
|
79
|
+
// every call site below unchanged; declared constraints from this file
|
|
80
|
+
// always carry a null causedByEventId, because none of these run inside a
|
|
81
|
+
// resolution.
|
|
75
82
|
function insertConstraint(gameId, kind, resourceIds, direction, total, factKey = "value") {
|
|
76
|
-
|
|
77
|
-
const id = uuidv4();
|
|
78
|
-
const now = new Date().toISOString();
|
|
79
|
-
db.prepare(`INSERT INTO resource_constraints (id, game_id, kind, direction, total, fact_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, gameId, kind, direction, total, factKey, now);
|
|
80
|
-
const memberStmt = db.prepare(`INSERT INTO resource_constraint_members (constraint_id, resource_id) VALUES (?, ?)`);
|
|
81
|
-
for (const resourceId of resourceIds) {
|
|
82
|
-
memberStmt.run(id, resourceId);
|
|
83
|
-
}
|
|
84
|
-
return {
|
|
85
|
-
id,
|
|
86
|
-
gameId,
|
|
87
|
-
kind,
|
|
88
|
-
resourceIds,
|
|
89
|
-
direction,
|
|
90
|
-
total,
|
|
91
|
-
factKey,
|
|
92
|
-
createdAt: now,
|
|
93
|
-
};
|
|
83
|
+
return insertConstraintRow({ gameId, kind, resourceIds, direction, total, factKey });
|
|
94
84
|
}
|
|
95
85
|
/**
|
|
96
86
|
* Declare a 'bounded' constraint: the resource's value must stay within its
|
package/dist/types/index.d.ts
CHANGED
|
@@ -512,6 +512,9 @@ export interface ResourceConstraint {
|
|
|
512
512
|
total: number | null;
|
|
513
513
|
factKey: string;
|
|
514
514
|
createdAt: string;
|
|
515
|
+
minValue: number | null;
|
|
516
|
+
maxValue: number | null;
|
|
517
|
+
causedByEventId: string | null;
|
|
515
518
|
}
|
|
516
519
|
export interface GameDateTime {
|
|
517
520
|
year: number;
|
|
@@ -255,14 +255,14 @@ export declare const characterOutputSchema: {
|
|
|
255
255
|
condition: z.ZodOptional<z.ZodString>;
|
|
256
256
|
glowOrEffects: z.ZodOptional<z.ZodString>;
|
|
257
257
|
}, "strip", z.ZodTypeAny, {
|
|
258
|
+
condition?: string | undefined;
|
|
258
259
|
size?: string | undefined;
|
|
259
260
|
material?: string | undefined;
|
|
260
|
-
condition?: string | undefined;
|
|
261
261
|
glowOrEffects?: string | undefined;
|
|
262
262
|
}, {
|
|
263
|
+
condition?: string | undefined;
|
|
263
264
|
size?: string | undefined;
|
|
264
265
|
material?: string | undefined;
|
|
265
|
-
condition?: string | undefined;
|
|
266
266
|
glowOrEffects?: string | undefined;
|
|
267
267
|
}>>;
|
|
268
268
|
pose: z.ZodOptional<z.ZodString>;
|
|
@@ -299,9 +299,9 @@ export declare const characterOutputSchema: {
|
|
|
299
299
|
vegetation?: string | undefined;
|
|
300
300
|
} | undefined;
|
|
301
301
|
objectDetails?: {
|
|
302
|
+
condition?: string | undefined;
|
|
302
303
|
size?: string | undefined;
|
|
303
304
|
material?: string | undefined;
|
|
304
|
-
condition?: string | undefined;
|
|
305
305
|
glowOrEffects?: string | undefined;
|
|
306
306
|
} | undefined;
|
|
307
307
|
pose?: string | undefined;
|
|
@@ -338,9 +338,9 @@ export declare const characterOutputSchema: {
|
|
|
338
338
|
vegetation?: string | undefined;
|
|
339
339
|
} | undefined;
|
|
340
340
|
objectDetails?: {
|
|
341
|
+
condition?: string | undefined;
|
|
341
342
|
size?: string | undefined;
|
|
342
343
|
material?: string | undefined;
|
|
343
|
-
condition?: string | undefined;
|
|
344
344
|
glowOrEffects?: string | undefined;
|
|
345
345
|
} | undefined;
|
|
346
346
|
pose?: string | undefined;
|
|
@@ -587,9 +587,9 @@ export declare const characterOutputSchema: {
|
|
|
587
587
|
vegetation?: string | undefined;
|
|
588
588
|
} | undefined;
|
|
589
589
|
objectDetails?: {
|
|
590
|
+
condition?: string | undefined;
|
|
590
591
|
size?: string | undefined;
|
|
591
592
|
material?: string | undefined;
|
|
592
|
-
condition?: string | undefined;
|
|
593
593
|
glowOrEffects?: string | undefined;
|
|
594
594
|
} | undefined;
|
|
595
595
|
pose?: string | undefined;
|
|
@@ -683,9 +683,9 @@ export declare const characterOutputSchema: {
|
|
|
683
683
|
vegetation?: string | undefined;
|
|
684
684
|
} | undefined;
|
|
685
685
|
objectDetails?: {
|
|
686
|
+
condition?: string | undefined;
|
|
686
687
|
size?: string | undefined;
|
|
687
688
|
material?: string | undefined;
|
|
688
|
-
condition?: string | undefined;
|
|
689
689
|
glowOrEffects?: string | undefined;
|
|
690
690
|
} | undefined;
|
|
691
691
|
pose?: string | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "run-dmcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "An MCP server for LLM-run interactive fiction where the server owns what is true - including when it was true. A continuation of DMCP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Derek Ferguson",
|