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.
Files changed (38) hide show
  1. package/dist/bin/run-dmcp.js +16 -1
  2. package/dist/db/schema.js +62 -0
  3. package/dist/index.d.ts +7 -3
  4. package/dist/index.js +12 -1
  5. package/dist/mcp-server.d.ts +6 -1
  6. package/dist/mcp-server.js +11 -3
  7. package/dist/reader/preflight.d.ts +62 -0
  8. package/dist/reader/preflight.js +40 -0
  9. package/dist/reader/turnReader.d.ts +169 -13
  10. package/dist/reader/turnReader.js +232 -78
  11. package/dist/register/conditions.d.ts +16 -0
  12. package/dist/register/conditions.js +45 -0
  13. package/dist/register/reader.d.ts +2 -0
  14. package/dist/register/reader.js +132 -0
  15. package/dist/register/render.js +7 -2
  16. package/dist/register/resolve.js +8 -2
  17. package/dist/register/timeline.js +29 -2
  18. package/dist/rpg/server.d.ts +2 -0
  19. package/dist/schemas/index.d.ts +10 -10
  20. package/dist/timeline/constrained.d.ts +4 -5
  21. package/dist/timeline/constrained.js +31 -25
  22. package/dist/timeline/declared.d.ts +147 -0
  23. package/dist/timeline/declared.js +388 -0
  24. package/dist/timeline/registry.d.ts +42 -4
  25. package/dist/timeline/registry.js +62 -4
  26. package/dist/timeline/render.d.ts +3 -0
  27. package/dist/timeline/render.js +1 -1
  28. package/dist/timeline/replay.d.ts +15 -0
  29. package/dist/timeline/replay.js +33 -2
  30. package/dist/timeline/resolve.d.ts +60 -5
  31. package/dist/timeline/resolve.js +135 -5
  32. package/dist/timeline/schema.js +28 -0
  33. package/dist/timeline/turns.d.ts +60 -0
  34. package/dist/timeline/turns.js +93 -0
  35. package/dist/tools/constraint.js +10 -20
  36. package/dist/types/index.d.ts +3 -0
  37. package/dist/utils/output-schemas.d.ts +6 -6
  38. 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 (see that function's comment on why colliding with it
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" | "expectation-contradicted"
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
@@ -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: deref(change.entityId, refs),
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 precondition: every ref resolves, before the transaction opens.
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', ?, ?)`)
@@ -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
+ }
@@ -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
- const db = getDatabase();
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
@@ -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.8.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",