run-dmcp 0.6.0 → 0.8.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/index.d.ts +2 -1
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.js +1 -1
- package/dist/timeline/constrained.d.ts +110 -0
- package/dist/timeline/constrained.js +220 -1
- package/dist/timeline/resolve.d.ts +83 -8
- package/dist/timeline/resolve.js +110 -17
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { timelineDivergences } from "./timeline/checkpoint.js";
|
|
|
14
14
|
export type { Divergence } from "./timeline/checkpoint.js";
|
|
15
15
|
export { declareIrreversible, irreversibleFactFor, listIrreversibleFacts, } from "./timeline/irreversible.js";
|
|
16
16
|
export type { IrreversibleFact } from "./timeline/irreversible.js";
|
|
17
|
+
export type { SetTransition, CreatedEntity, DestroyedEntity } from "./timeline/constrained.js";
|
|
17
18
|
export { openingEventId } from "./timeline/provenance.js";
|
|
18
19
|
export type { FactProvenance } from "./timeline/provenance.js";
|
|
19
20
|
export { narrationConstraintAt, contradictions, NARRATION_CONSTRAINT_FORMAT_VERSION, } from "./timeline/narration.js";
|
|
@@ -22,7 +23,7 @@ export { writeConstrainedValue, transferConstrainedValue, valueHistory, } from "
|
|
|
22
23
|
export type { ValueTransition } from "./timeline/constrained.js";
|
|
23
24
|
export { ConstraintViolationError, constraintsFor, conservedConstraintFor } from "./timeline/registry.js";
|
|
24
25
|
export { createResolver, ResolveProtocolError } from "./timeline/resolve.js";
|
|
25
|
-
export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
|
|
26
|
+
export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, IntendedSet, IntendedCreate, IntendedDestroy, EntityRef, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
|
|
26
27
|
export { createStateRenderer } from "./timeline/render.js";
|
|
27
28
|
export type { RenderVocabulary, VocabularyEntry, StateRenderer, RenderedState, RenderedNoun, UnnamedFact, } from "./timeline/render.js";
|
|
28
29
|
export { createTurnReader } from "./reader/turnReader.js";
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare const SERVER_NAME = "dmcp";
|
|
|
6
6
|
* the published package version by src/__tests__/serverVersion.test.ts --
|
|
7
7
|
* this said "0.3.0" for the whole of 0.4.0, because a release bumps
|
|
8
8
|
* package.json and nothing was watching this. */
|
|
9
|
-
export declare const SERVER_VERSION = "0.
|
|
9
|
+
export declare const SERVER_VERSION = "0.8.0";
|
|
10
10
|
/**
|
|
11
11
|
* Build an MCP server with every CORE tool, resource and prompt this engine
|
|
12
12
|
* serves -- entities, facts, events, the timeline, and the entity/property
|
package/dist/mcp-server.js
CHANGED
|
@@ -45,7 +45,7 @@ export const SERVER_NAME = "dmcp";
|
|
|
45
45
|
* the published package version by src/__tests__/serverVersion.test.ts --
|
|
46
46
|
* this said "0.3.0" for the whole of 0.4.0, because a release bumps
|
|
47
47
|
* package.json and nothing was watching this. */
|
|
48
|
-
export const SERVER_VERSION = "0.
|
|
48
|
+
export const SERVER_VERSION = "0.8.0";
|
|
49
49
|
/**
|
|
50
50
|
* Build an MCP server with every CORE tool, resource and prompt this engine
|
|
51
51
|
* serves -- entities, facts, events, the timeline, and the entity/property
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type T } from "./t.js";
|
|
2
|
+
import type { EntityKind } from "./kinds.js";
|
|
2
3
|
/**
|
|
3
4
|
* The choke point (design §5.4 option (C), Phase 3 step 2): the ONE place a
|
|
4
5
|
* constrained numeric fact key is written, and the ONE place `resources`'
|
|
@@ -218,3 +219,112 @@ export declare function transferConstrainedValue(params: {
|
|
|
218
219
|
* discipline. `limit` applies after ordering, never before.
|
|
219
220
|
*/
|
|
220
221
|
export declare function valueHistory(entityId: string, key: string, limit?: number): ValueTransition[];
|
|
222
|
+
/**
|
|
223
|
+
* One non-numeric column set, as `resolve()`'s `set` intent applies it (issue
|
|
224
|
+
* #32). A row, never a verdict: what the column held, what it holds now, and
|
|
225
|
+
* the fact the write opened -- `null` when the value did not move, because an
|
|
226
|
+
* unchanged value opens no new interval (projection.ts's update trigger).
|
|
227
|
+
*/
|
|
228
|
+
export interface SetTransition {
|
|
229
|
+
entityId: string;
|
|
230
|
+
key: string;
|
|
231
|
+
previousValue: string | number | null;
|
|
232
|
+
newValue: string | number | null;
|
|
233
|
+
t: T;
|
|
234
|
+
factId: string | null;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Sets one live column of an entity's projected table to `value` (issue #32).
|
|
238
|
+
* The engine stores what it is handed and never learns what the column means;
|
|
239
|
+
* the projection triggers version it exactly as they version every other
|
|
240
|
+
* column write, so nothing here touches `facts` directly.
|
|
241
|
+
*
|
|
242
|
+
* The same choke point as numeric writes, and the same checks in the same
|
|
243
|
+
* voice: the entity and column are resolved against `PROJECTED_TABLES` /
|
|
244
|
+
* `liveColumns` (never a caller-supplied table name); the column that places
|
|
245
|
+
* the entity in its game is refused; a key carrying a numeric constraint is
|
|
246
|
+
* refused, because that value changes by a write where the constraint is
|
|
247
|
+
* evaluated; `resolve_only` asks the one question it always asks, whether an
|
|
248
|
+
* adjudication window is open; and a contradicted irreversible fact is
|
|
249
|
+
* translated into a typed `ConstraintViolationError` with the one hop
|
|
250
|
+
* attached, by `translateIrreversibleFailure`, never by reading the trigger's
|
|
251
|
+
* message.
|
|
252
|
+
*
|
|
253
|
+
* Not exported from the library: `resolve()` is its only caller, so a
|
|
254
|
+
* non-numeric consequence reaches storage through a resolution or not at all.
|
|
255
|
+
*/
|
|
256
|
+
export declare function setProjectedValue(params: {
|
|
257
|
+
entityId: string;
|
|
258
|
+
key: string;
|
|
259
|
+
value: string | number | null;
|
|
260
|
+
}): SetTransition;
|
|
261
|
+
/**
|
|
262
|
+
* One entity a resolution brought into existence (issue #34). A row, never
|
|
263
|
+
* a verdict: which kind, the id the engine allocated, where on the timeline
|
|
264
|
+
* it landed, and the `<kind>.created` event the projection trigger wrote --
|
|
265
|
+
* design §5.2c's one hop, recorded rather than derived later.
|
|
266
|
+
*/
|
|
267
|
+
export interface CreatedEntity {
|
|
268
|
+
entityId: string;
|
|
269
|
+
entityKind: EntityKind;
|
|
270
|
+
t: T;
|
|
271
|
+
eventId: string | null;
|
|
272
|
+
}
|
|
273
|
+
/** One entity a resolution ended (issue #34): the `<kind>.destroyed` event
|
|
274
|
+
* and the `t` its facts were closed at. */
|
|
275
|
+
export interface DestroyedEntity {
|
|
276
|
+
entityId: string;
|
|
277
|
+
entityKind: EntityKind;
|
|
278
|
+
t: T;
|
|
279
|
+
eventId: string | null;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Inserts one live row into the projected table for `entityKind` (issue
|
|
283
|
+
* #34) -- the choke point's door for bringing an entity into existence
|
|
284
|
+
* inside a resolution. The projection insert trigger (projection.ts) does
|
|
285
|
+
* every piece of timeline work: the `entities` row, one open fact per
|
|
286
|
+
* non-NULL column, the `<kind>.created` event, the provenance stamp. Nothing
|
|
287
|
+
* here writes `facts` or `events` directly.
|
|
288
|
+
*
|
|
289
|
+
* What it refuses, before any write: a kind with no projected table; the
|
|
290
|
+
* `game` kind (a resolution belongs to a game and cannot create one); a
|
|
291
|
+
* caller-supplied `id` (the engine allocates it, because a mechanic has no
|
|
292
|
+
* database handle and could not know a free one); the column that places
|
|
293
|
+
* the entity in its game (filled from the proposal, never the mechanic);
|
|
294
|
+
* and any column the table's `liveColumns()` does not report. Column names
|
|
295
|
+
* are interpolated only after that check -- they are this codebase's own
|
|
296
|
+
* `pragma_table_info` vocabulary by then, never a caller's string. Values
|
|
297
|
+
* are bound. A column the table itself requires and the caller omitted is
|
|
298
|
+
* SQLite's refusal, not this function's: the engine never learns what a
|
|
299
|
+
* column means, including whether it is optional.
|
|
300
|
+
*
|
|
301
|
+
* Not exported from the library: `resolve()` is its only caller, so an
|
|
302
|
+
* entity comes into being through a resolution or through the tool layer,
|
|
303
|
+
* and there is no third way.
|
|
304
|
+
*/
|
|
305
|
+
export declare function createProjectedEntity(params: {
|
|
306
|
+
gameId: string;
|
|
307
|
+
entityKind: EntityKind;
|
|
308
|
+
columns: Readonly<Record<string, string | number | null>>;
|
|
309
|
+
}): CreatedEntity;
|
|
310
|
+
/**
|
|
311
|
+
* Deletes one entity's live row (issue #34) -- the choke point's door for
|
|
312
|
+
* ending an entity inside a resolution. The projection delete trigger closes
|
|
313
|
+
* every open fact, sets `destroyed_at_t` and writes `<kind>.destroyed`.
|
|
314
|
+
*
|
|
315
|
+
* Refused, before any write: an entity that does not exist, or is already
|
|
316
|
+
* destroyed, naming it; the `game` kind; and -- the conservative reading
|
|
317
|
+
* #34 asked to be pinned -- an entity carrying an irreversible fact, as a
|
|
318
|
+
* typed `ConstraintViolationError` with the one hop attached: destroying the
|
|
319
|
+
* entity would close that fact, and a fact declared to hold thereafter is
|
|
320
|
+
* not ended by removing what it is about. A `resolve_only` fact does not
|
|
321
|
+
* refuse a destroy: closing an interval is not a write of a new value
|
|
322
|
+
* (src/db/schema.ts's own note on `timeline_facts_resolve_only`), and inside
|
|
323
|
+
* a resolution the window is open, which is all resolve_only asks.
|
|
324
|
+
*
|
|
325
|
+
* Not exported from the library, for the reason `createProjectedEntity`
|
|
326
|
+
* gives.
|
|
327
|
+
*/
|
|
328
|
+
export declare function destroyProjectedEntity(params: {
|
|
329
|
+
entityId: string;
|
|
330
|
+
}): DestroyedEntity;
|
|
@@ -4,7 +4,7 @@ import { assertT, compareT } from "./t.js";
|
|
|
4
4
|
import { currentStoryTime } from "./clock.js";
|
|
5
5
|
import { PROJECTED_TABLES, liveColumns } from "./projection.js";
|
|
6
6
|
import { constraintsFor, conservedConstraintFor, ConstraintViolationError, CONSERVED_SUM_EPSILON } from "./registry.js";
|
|
7
|
-
import { irreversibleFactFor } from "./irreversible.js";
|
|
7
|
+
import { irreversibleFactFor, listIrreversibleFacts } from "./irreversible.js";
|
|
8
8
|
import { adjudicationOpen } from "./adjudication.js";
|
|
9
9
|
/**
|
|
10
10
|
* A1: resolves `entityId` to the live table its `key` column lives in, and
|
|
@@ -669,3 +669,222 @@ export function valueHistory(entityId, key, limit) {
|
|
|
669
669
|
const limited = limit !== undefined ? ranked.slice(0, limit) : ranked;
|
|
670
670
|
return limited.map((r) => r.transition);
|
|
671
671
|
}
|
|
672
|
+
/** The numeric members of the constraint family. A key carrying one of them
|
|
673
|
+
* changes by a write (`writeConstrainedValue`), where the constraint is
|
|
674
|
+
* evaluated; a `set` would step round it. */
|
|
675
|
+
const NUMERIC_CONSTRAINT_KINDS = new Set(["monotonic", "bounded", "conserved"]);
|
|
676
|
+
function openFactId(db, entityId, key) {
|
|
677
|
+
const row = db
|
|
678
|
+
.prepare(`SELECT id FROM facts WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL ORDER BY valid_from_t DESC, id DESC LIMIT 1`)
|
|
679
|
+
.get(entityId, key);
|
|
680
|
+
return row?.id ?? null;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Sets one live column of an entity's projected table to `value` (issue #32).
|
|
684
|
+
* The engine stores what it is handed and never learns what the column means;
|
|
685
|
+
* the projection triggers version it exactly as they version every other
|
|
686
|
+
* column write, so nothing here touches `facts` directly.
|
|
687
|
+
*
|
|
688
|
+
* The same choke point as numeric writes, and the same checks in the same
|
|
689
|
+
* voice: the entity and column are resolved against `PROJECTED_TABLES` /
|
|
690
|
+
* `liveColumns` (never a caller-supplied table name); the column that places
|
|
691
|
+
* the entity in its game is refused; a key carrying a numeric constraint is
|
|
692
|
+
* refused, because that value changes by a write where the constraint is
|
|
693
|
+
* evaluated; `resolve_only` asks the one question it always asks, whether an
|
|
694
|
+
* adjudication window is open; and a contradicted irreversible fact is
|
|
695
|
+
* translated into a typed `ConstraintViolationError` with the one hop
|
|
696
|
+
* attached, by `translateIrreversibleFailure`, never by reading the trigger's
|
|
697
|
+
* message.
|
|
698
|
+
*
|
|
699
|
+
* Not exported from the library: `resolve()` is its only caller, so a
|
|
700
|
+
* non-numeric consequence reaches storage through a resolution or not at all.
|
|
701
|
+
*/
|
|
702
|
+
export function setProjectedValue(params) {
|
|
703
|
+
const resolved = resolveProjection(params.entityId, params.key);
|
|
704
|
+
const db = getDatabase();
|
|
705
|
+
const projected = PROJECTED_TABLES.find((p) => p.table === resolved.table);
|
|
706
|
+
if (projected && projected.gameIdColumn === params.key) {
|
|
707
|
+
throw new Error(`timeline: '${params.key}' places entity '${params.entityId}' in its game and cannot be set -- ` +
|
|
708
|
+
`an entity does not move between games`);
|
|
709
|
+
}
|
|
710
|
+
for (const constraint of constraintsFor(params.entityId, params.key)) {
|
|
711
|
+
if (NUMERIC_CONSTRAINT_KINDS.has(constraint.kind)) {
|
|
712
|
+
throw new ConstraintViolationError(constraint.kind, params.entityId, `Entity '${params.entityId}' is ${constraint.kind}-constrained for key '${params.key}', which a set does not ` +
|
|
713
|
+
`evaluate; this value changes by a write, where the constraint is checked.`);
|
|
714
|
+
}
|
|
715
|
+
if (constraint.kind === "resolve_only" && !adjudicationOpen()) {
|
|
716
|
+
throw new ConstraintViolationError("resolve_only", params.entityId, `Entity '${params.entityId}' is resolve_only-constrained for key '${params.key}'; direct writes are refused. ` +
|
|
717
|
+
`This value can only change through the adjudicating call that opens the resolution window.`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const row = db.prepare(`SELECT ${resolved.key} AS value FROM ${resolved.table} WHERE id = ?`).get(resolved.entityId);
|
|
721
|
+
if (!row) {
|
|
722
|
+
throw new Error(`timeline: no live row in '${resolved.table}' for entity '${resolved.entityId}' -- ` +
|
|
723
|
+
`it may have been destroyed since it was last confirmed to exist`);
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
return withTransaction(() => {
|
|
727
|
+
const before = openFactId(db, resolved.entityId, resolved.key);
|
|
728
|
+
db.prepare(`UPDATE ${resolved.table} SET ${resolved.key} = ? WHERE id = ?`).run(params.value, resolved.entityId);
|
|
729
|
+
const after = openFactId(db, resolved.entityId, resolved.key);
|
|
730
|
+
const story = currentStoryTime(resolved.gameId);
|
|
731
|
+
if (!story) {
|
|
732
|
+
throw new Error(`timeline: game '${resolved.gameId}' has no timeline clock -- a set has no t to attach to`);
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
entityId: resolved.entityId,
|
|
736
|
+
key: resolved.key,
|
|
737
|
+
previousValue: row.value,
|
|
738
|
+
newValue: params.value,
|
|
739
|
+
t: story.t,
|
|
740
|
+
factId: after !== before ? after : null,
|
|
741
|
+
};
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
if (params.value === null)
|
|
746
|
+
throw err;
|
|
747
|
+
translateIrreversibleFailure(db, [{ entityId: params.entityId, key: params.key, table: resolved.table, attemptedValue: params.value }], err);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/** The projection event the trigger just wrote for `rowId` -- found by the
|
|
751
|
+
* trigger's own `row_id` token (projection.ts), never by guessing at `t`.
|
|
752
|
+
* `json_valid` guards the extraction for the reason `valueHistory` gives. */
|
|
753
|
+
function projectionEventId(db, kind, rowId) {
|
|
754
|
+
const row = db
|
|
755
|
+
.prepare(`SELECT id FROM events WHERE kind = ?
|
|
756
|
+
AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.row_id') = ?
|
|
757
|
+
ORDER BY rowid DESC LIMIT 1`)
|
|
758
|
+
.get(kind, rowId);
|
|
759
|
+
return row?.id ?? null;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Inserts one live row into the projected table for `entityKind` (issue
|
|
763
|
+
* #34) -- the choke point's door for bringing an entity into existence
|
|
764
|
+
* inside a resolution. The projection insert trigger (projection.ts) does
|
|
765
|
+
* every piece of timeline work: the `entities` row, one open fact per
|
|
766
|
+
* non-NULL column, the `<kind>.created` event, the provenance stamp. Nothing
|
|
767
|
+
* here writes `facts` or `events` directly.
|
|
768
|
+
*
|
|
769
|
+
* What it refuses, before any write: a kind with no projected table; the
|
|
770
|
+
* `game` kind (a resolution belongs to a game and cannot create one); a
|
|
771
|
+
* caller-supplied `id` (the engine allocates it, because a mechanic has no
|
|
772
|
+
* database handle and could not know a free one); the column that places
|
|
773
|
+
* the entity in its game (filled from the proposal, never the mechanic);
|
|
774
|
+
* and any column the table's `liveColumns()` does not report. Column names
|
|
775
|
+
* are interpolated only after that check -- they are this codebase's own
|
|
776
|
+
* `pragma_table_info` vocabulary by then, never a caller's string. Values
|
|
777
|
+
* are bound. A column the table itself requires and the caller omitted is
|
|
778
|
+
* SQLite's refusal, not this function's: the engine never learns what a
|
|
779
|
+
* column means, including whether it is optional.
|
|
780
|
+
*
|
|
781
|
+
* Not exported from the library: `resolve()` is its only caller, so an
|
|
782
|
+
* entity comes into being through a resolution or through the tool layer,
|
|
783
|
+
* and there is no third way.
|
|
784
|
+
*/
|
|
785
|
+
export function createProjectedEntity(params) {
|
|
786
|
+
const projected = PROJECTED_TABLES.find((p) => p.kind === params.entityKind);
|
|
787
|
+
if (!projected) {
|
|
788
|
+
throw new Error(`timeline: '${String(params.entityKind)}' is not a projected entity kind -- a resolution can create only ` +
|
|
789
|
+
PROJECTED_TABLES.filter((p) => p.kind !== "game")
|
|
790
|
+
.map((p) => `'${p.kind}'`)
|
|
791
|
+
.join(", "));
|
|
792
|
+
}
|
|
793
|
+
if (projected.kind === "game") {
|
|
794
|
+
throw new Error(`timeline: a resolution belongs to a game and cannot create one`);
|
|
795
|
+
}
|
|
796
|
+
const db = getDatabase();
|
|
797
|
+
const cols = liveColumns(db, projected.table);
|
|
798
|
+
const keys = Object.keys(params.columns);
|
|
799
|
+
for (const key of keys) {
|
|
800
|
+
if (key === "id") {
|
|
801
|
+
throw new Error(`timeline: 'id' is allocated by the engine and cannot be supplied for a created ${projected.kind}`);
|
|
802
|
+
}
|
|
803
|
+
if (key === projected.gameIdColumn) {
|
|
804
|
+
throw new Error(`timeline: '${key}' places a created ${projected.kind} in its game and is filled from the proposal, never by the mechanic`);
|
|
805
|
+
}
|
|
806
|
+
if (!cols.includes(key)) {
|
|
807
|
+
throw new Error(`timeline: '${key}' is not a live column of '${projected.table}' -- a created ${projected.kind} has no fact key by that name`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const id = uuidv4();
|
|
811
|
+
return withTransaction(() => {
|
|
812
|
+
const columnList = ["id", projected.gameIdColumn, ...keys];
|
|
813
|
+
db.prepare(`INSERT INTO ${projected.table} (${columnList.join(", ")}) VALUES (${columnList.map(() => "?").join(", ")})`).run(id, params.gameId, ...keys.map((key) => params.columns[key]));
|
|
814
|
+
const entity = db.prepare(`SELECT created_at_t FROM entities WHERE id = ?`).get(id);
|
|
815
|
+
if (!entity) {
|
|
816
|
+
// Cannot happen while the projection triggers are installed; not a
|
|
817
|
+
// case to assume silently forever.
|
|
818
|
+
throw new Error(`timeline: the insert trigger on '${projected.table}' recorded no entity for the row it just projected`);
|
|
819
|
+
}
|
|
820
|
+
assertT(entity.created_at_t);
|
|
821
|
+
return {
|
|
822
|
+
entityId: id,
|
|
823
|
+
entityKind: projected.kind,
|
|
824
|
+
t: entity.created_at_t,
|
|
825
|
+
eventId: projectionEventId(db, `${projected.kind}.created`, id),
|
|
826
|
+
};
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Deletes one entity's live row (issue #34) -- the choke point's door for
|
|
831
|
+
* ending an entity inside a resolution. The projection delete trigger closes
|
|
832
|
+
* every open fact, sets `destroyed_at_t` and writes `<kind>.destroyed`.
|
|
833
|
+
*
|
|
834
|
+
* Refused, before any write: an entity that does not exist, or is already
|
|
835
|
+
* destroyed, naming it; the `game` kind; and -- the conservative reading
|
|
836
|
+
* #34 asked to be pinned -- an entity carrying an irreversible fact, as a
|
|
837
|
+
* typed `ConstraintViolationError` with the one hop attached: destroying the
|
|
838
|
+
* entity would close that fact, and a fact declared to hold thereafter is
|
|
839
|
+
* not ended by removing what it is about. A `resolve_only` fact does not
|
|
840
|
+
* refuse a destroy: closing an interval is not a write of a new value
|
|
841
|
+
* (src/db/schema.ts's own note on `timeline_facts_resolve_only`), and inside
|
|
842
|
+
* a resolution the window is open, which is all resolve_only asks.
|
|
843
|
+
*
|
|
844
|
+
* Not exported from the library, for the reason `createProjectedEntity`
|
|
845
|
+
* gives.
|
|
846
|
+
*/
|
|
847
|
+
export function destroyProjectedEntity(params) {
|
|
848
|
+
const db = getDatabase();
|
|
849
|
+
const entity = db.prepare(`SELECT game_id, kind, destroyed_at_t FROM entities WHERE id = ?`).get(params.entityId);
|
|
850
|
+
if (!entity) {
|
|
851
|
+
throw new Error(`timeline: cannot destroy entity '${params.entityId}' -- it does not exist`);
|
|
852
|
+
}
|
|
853
|
+
if (entity.destroyed_at_t !== null) {
|
|
854
|
+
throw new Error(`timeline: entity '${params.entityId}' was already destroyed at t=${entity.destroyed_at_t}`);
|
|
855
|
+
}
|
|
856
|
+
const projected = PROJECTED_TABLES.find((p) => p.kind === entity.kind);
|
|
857
|
+
if (!projected) {
|
|
858
|
+
throw new Error(`timeline: entity '${params.entityId}' has kind '${entity.kind}', which has no projected table to delete from`);
|
|
859
|
+
}
|
|
860
|
+
if (projected.kind === "game") {
|
|
861
|
+
throw new Error(`timeline: a resolution belongs to a game and cannot destroy one`);
|
|
862
|
+
}
|
|
863
|
+
const live = db.prepare(`SELECT id FROM ${projected.table} WHERE id = ?`).get(params.entityId);
|
|
864
|
+
if (!live) {
|
|
865
|
+
throw new Error(`timeline: no live row in '${projected.table}' for entity '${params.entityId}' -- it may have been destroyed since it was last confirmed to exist`);
|
|
866
|
+
}
|
|
867
|
+
const [irreversible] = listIrreversibleFacts({ gameId: entity.game_id, entityId: params.entityId });
|
|
868
|
+
if (irreversible) {
|
|
869
|
+
throw new ConstraintViolationError("irreversible", params.entityId, `Entity '${params.entityId}' carries an irreversible fact for key '${irreversible.key}': value '${irreversible.value}' ` +
|
|
870
|
+
`holds as of t=${irreversible.validFromT}` +
|
|
871
|
+
(irreversible.openedByEventId !== null
|
|
872
|
+
? ` (opened by event '${irreversible.openedByEventId}')`
|
|
873
|
+
: ` (no event is recorded for when this was opened)`) +
|
|
874
|
+
`. Destroying the entity would end that fact and is refused.`, irreversible);
|
|
875
|
+
}
|
|
876
|
+
return withTransaction(() => {
|
|
877
|
+
db.prepare(`DELETE FROM ${projected.table} WHERE id = ?`).run(params.entityId);
|
|
878
|
+
const after = db.prepare(`SELECT destroyed_at_t FROM entities WHERE id = ?`).get(params.entityId);
|
|
879
|
+
if (!after || after.destroyed_at_t === null) {
|
|
880
|
+
throw new Error(`timeline: the delete trigger on '${projected.table}' recorded no destruction for entity '${params.entityId}'`);
|
|
881
|
+
}
|
|
882
|
+
assertT(after.destroyed_at_t);
|
|
883
|
+
return {
|
|
884
|
+
entityId: params.entityId,
|
|
885
|
+
entityKind: projected.kind,
|
|
886
|
+
t: after.destroyed_at_t,
|
|
887
|
+
eventId: projectionEventId(db, `${projected.kind}.destroyed`, params.entityId),
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type T } from "./t.js";
|
|
2
2
|
import { type NarrationConstraint, type Contradiction } from "./narration.js";
|
|
3
|
-
import {
|
|
3
|
+
import type { EntityKind } from "./kinds.js";
|
|
4
|
+
import { type ValueTransition, type SetTransition, type CreatedEntity, type DestroyedEntity } from "./constrained.js";
|
|
4
5
|
/**
|
|
5
6
|
* The inbound half of authority (design §5.2a, GitHub issue #10): propose ->
|
|
6
7
|
* adjudicate -> outcome. The engine enforces the PROTOCOL -- resolution
|
|
@@ -71,8 +72,17 @@ import { type ValueTransition } from "./constrained.js";
|
|
|
71
72
|
* INSIDE it (adjudication.ts's own doc comment asks for exactly this
|
|
72
73
|
* nesting, so the window row rolls back with the writes it
|
|
73
74
|
* authorized). Every change goes through `writeConstrainedValue` /
|
|
74
|
-
* `transferConstrainedValue`
|
|
75
|
-
*
|
|
75
|
+
* `transferConstrainedValue` / `setProjectedValue` (issue #32, a
|
|
76
|
+
* non-numeric column) / `createProjectedEntity` /
|
|
77
|
+
* `destroyProjectedEntity` (issue #34, an entity beginning or ending)
|
|
78
|
+
* -- the one choke point (root CLAUDE.md hard rule 7) -- never a
|
|
79
|
+
* direct write. A `create` is labelled with a caller-chosen `ref`,
|
|
80
|
+
* and a later leg of the same resolution may say `{ ref }` wherever
|
|
81
|
+
* it would say an entity id, because a mechanic has no database
|
|
82
|
+
* handle and cannot know the id the engine will allocate; every ref
|
|
83
|
+
* is checked against the legs before it, BEFORE the transaction
|
|
84
|
+
* opens, so a ref naming nothing refuses with no write attempted.
|
|
85
|
+
* A constraint violation
|
|
76
86
|
* anywhere in the list propagates out of the transaction untouched
|
|
77
87
|
* (never caught and re-labelled here) and rolls back EVERY change the
|
|
78
88
|
* transaction made, including ones that individually would have
|
|
@@ -135,13 +145,24 @@ export interface AdjudicationInput {
|
|
|
135
145
|
parameters: Record<string, unknown>;
|
|
136
146
|
constraint: NarrationConstraint;
|
|
137
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* An entity id, or the label of an entity created by an earlier `create`
|
|
150
|
+
* leg of the same resolution (issue #34). A mechanic cannot know the id the
|
|
151
|
+
* engine allocates -- it has no database handle, which is the whole
|
|
152
|
+
* mechanism of "every write goes through the audited path" -- so it names
|
|
153
|
+
* the new entity by the `ref` it chose, and `resolve()` substitutes the
|
|
154
|
+
* real id when it applies the leg.
|
|
155
|
+
*/
|
|
156
|
+
export type EntityRef = string | {
|
|
157
|
+
ref: string;
|
|
158
|
+
};
|
|
138
159
|
/** One intended write to a single fact key -- the generic shape
|
|
139
160
|
* `writeConstrainedValue` (constrained.ts) already takes, carried here so a
|
|
140
161
|
* mechanic can express "change this value" without ever calling that
|
|
141
162
|
* function itself. */
|
|
142
163
|
export interface IntendedWrite {
|
|
143
164
|
kind: "write";
|
|
144
|
-
entityId:
|
|
165
|
+
entityId: EntityRef;
|
|
145
166
|
key: string;
|
|
146
167
|
mode: "delta" | "set";
|
|
147
168
|
value: number;
|
|
@@ -155,8 +176,8 @@ export interface IntendedWrite {
|
|
|
155
176
|
* shape `transferConstrainedValue` (constrained.ts) already takes. */
|
|
156
177
|
export interface IntendedTransfer {
|
|
157
178
|
kind: "transfer";
|
|
158
|
-
fromEntityId:
|
|
159
|
-
toEntityId:
|
|
179
|
+
fromEntityId: EntityRef;
|
|
180
|
+
toEntityId: EntityRef;
|
|
160
181
|
key: string;
|
|
161
182
|
amount: number;
|
|
162
183
|
reason?: string | null;
|
|
@@ -169,7 +190,43 @@ export interface IntendedTransfer {
|
|
|
169
190
|
maxValue: number | null;
|
|
170
191
|
};
|
|
171
192
|
}
|
|
172
|
-
|
|
193
|
+
/** One intended set of a non-numeric column on an entity's projected row
|
|
194
|
+
* (issue #32) -- a thing changing owner, a character changing place. The
|
|
195
|
+
* engine stores `value` and never learns what `key` means; see
|
|
196
|
+
* `setProjectedValue` (constrained.ts) for what it refuses. */
|
|
197
|
+
export interface IntendedSet {
|
|
198
|
+
kind: "set";
|
|
199
|
+
entityId: EntityRef;
|
|
200
|
+
key: string;
|
|
201
|
+
/** `{ ref }` names an entity created earlier in this resolution -- the
|
|
202
|
+
* new thing becoming this entity's owner, say. */
|
|
203
|
+
value: string | number | null | {
|
|
204
|
+
ref: string;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** One intended entity coming into existence (issue #34): a row in the
|
|
208
|
+
* projected table for `entityKind`, with `columns` restricted to that
|
|
209
|
+
* table's live columns and the game column filled by the engine from the
|
|
210
|
+
* proposal. `ref` is the label later legs of this same resolution use for
|
|
211
|
+
* it; the engine allocates the id and reports it in `Outcome.created`.
|
|
212
|
+
* The engine never learns what the entity is for or what it was made
|
|
213
|
+
* from -- "derived from" is the caller's to record. */
|
|
214
|
+
export interface IntendedCreate {
|
|
215
|
+
kind: "create";
|
|
216
|
+
ref: string;
|
|
217
|
+
entityKind: EntityKind;
|
|
218
|
+
columns: Readonly<Record<string, string | number | null | {
|
|
219
|
+
ref: string;
|
|
220
|
+
}>>;
|
|
221
|
+
}
|
|
222
|
+
/** One intended entity ending (issue #34): its live row deleted, its facts
|
|
223
|
+
* closed by the projection trigger. See `destroyProjectedEntity`
|
|
224
|
+
* (constrained.ts) for what it refuses. */
|
|
225
|
+
export interface IntendedDestroy {
|
|
226
|
+
kind: "destroy";
|
|
227
|
+
entityId: EntityRef;
|
|
228
|
+
}
|
|
229
|
+
export type IntendedChange = IntendedWrite | IntendedTransfer | IntendedSet | IntendedCreate | IntendedDestroy;
|
|
173
230
|
/**
|
|
174
231
|
* What a mechanic returns. `changes` are intents, not writes -- `resolve()`
|
|
175
232
|
* applies every one of them through the one choke point (step 5); the
|
|
@@ -201,6 +258,18 @@ export interface Outcome {
|
|
|
201
258
|
t: T;
|
|
202
259
|
result: Record<string, unknown>;
|
|
203
260
|
transitions: ValueTransition[];
|
|
261
|
+
/** Every `set` this resolution applied, in order (issue #32). Kept apart
|
|
262
|
+
* from `transitions`, whose values are numbers. */
|
|
263
|
+
sets: SetTransition[];
|
|
264
|
+
/** Every entity this resolution created, in order, each with the `ref`
|
|
265
|
+
* the mechanic labelled it with and the id the engine allocated (issue
|
|
266
|
+
* #34). Kept apart from `transitions` and `sets`, so existing callers are
|
|
267
|
+
* unchanged. */
|
|
268
|
+
created: (CreatedEntity & {
|
|
269
|
+
ref: string;
|
|
270
|
+
})[];
|
|
271
|
+
/** Every entity this resolution destroyed, in order (issue #34). */
|
|
272
|
+
destroyed: DestroyedEntity[];
|
|
204
273
|
constraint: NarrationConstraint;
|
|
205
274
|
eventId: string;
|
|
206
275
|
}
|
|
@@ -218,7 +287,13 @@ export interface Mechanic {
|
|
|
218
287
|
* under -- never a judgement about the proposal, the mechanic, or the
|
|
219
288
|
* world (see the module doc comment's "records decisions, does not make
|
|
220
289
|
* them" paragraph). */
|
|
221
|
-
export type ResolveRefusalReason = "unknown-mechanic" | "no-clock" | "expectation-contradicted"
|
|
290
|
+
export type ResolveRefusalReason = "unknown-mechanic" | "no-clock" | "expectation-contradicted"
|
|
291
|
+
/** A leg said `{ ref }` and no earlier `create` leg of the same
|
|
292
|
+
* resolution defined that ref (issue #34). Refused before any write. */
|
|
293
|
+
| "unresolved-ref"
|
|
294
|
+
/** Two `create` legs of one resolution chose the same `ref` (issue #34).
|
|
295
|
+
* Refused before any write. */
|
|
296
|
+
| "duplicate-ref";
|
|
222
297
|
/**
|
|
223
298
|
* Refused before dispatch, before any write, or (never, by construction --
|
|
224
299
|
* see step 5 above) mid-apply. `reason` is the discriminant a caller
|
package/dist/timeline/resolve.js
CHANGED
|
@@ -3,7 +3,7 @@ import { getDatabase, withTransaction } from "../db/connection.js";
|
|
|
3
3
|
import { currentStoryTime } from "./clock.js";
|
|
4
4
|
import { narrationConstraintAt, contradictions } from "./narration.js";
|
|
5
5
|
import { withAdjudicationOpen } from "./adjudication.js";
|
|
6
|
-
import { writeConstrainedValue, transferConstrainedValue } from "./constrained.js";
|
|
6
|
+
import { writeConstrainedValue, transferConstrainedValue, setProjectedValue, createProjectedEntity, destroyProjectedEntity, } from "./constrained.js";
|
|
7
7
|
/**
|
|
8
8
|
* Refused before dispatch, before any write, or (never, by construction --
|
|
9
9
|
* see step 5 above) mid-apply. `reason` is the discriminant a caller
|
|
@@ -80,30 +80,106 @@ export function createResolver(params) {
|
|
|
80
80
|
},
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
/** Every `{ ref }` a change carries, in the order it is read (issue #34) --
|
|
84
|
+
* the one place the shape of each intent kind's ref-bearing fields is
|
|
85
|
+
* known, shared by the pre-transaction check and the apply step. */
|
|
86
|
+
function refsUsedBy(change) {
|
|
87
|
+
const refOf = (value) => (typeof value === "object" && value !== null && "ref" in value ? [String(value.ref)] : []);
|
|
88
|
+
switch (change.kind) {
|
|
89
|
+
case "write":
|
|
90
|
+
return refOf(change.entityId);
|
|
91
|
+
case "transfer":
|
|
92
|
+
return [...refOf(change.fromEntityId), ...refOf(change.toEntityId)];
|
|
93
|
+
case "set":
|
|
94
|
+
return [...refOf(change.entityId), ...refOf(change.value)];
|
|
95
|
+
case "create":
|
|
96
|
+
return Object.values(change.columns).flatMap(refOf);
|
|
97
|
+
case "destroy":
|
|
98
|
+
return refOf(change.entityId);
|
|
99
|
+
default:
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Step 5's precondition (issue #34): every `{ ref }` names a `create` leg
|
|
105
|
+
* EARLIER in the list, and no two creates share a ref. Checked over the
|
|
106
|
+
* intent list alone -- a structural property of what the mechanic returned,
|
|
107
|
+
* decidable with no database at all -- so a bad ref refuses with no
|
|
108
|
+
* transaction opened and no write attempted, in the same voice as every
|
|
109
|
+
* other protocol refusal.
|
|
110
|
+
*/
|
|
111
|
+
function assertRefsResolvable(mechanicName, changes) {
|
|
112
|
+
const defined = new Set();
|
|
113
|
+
changes.forEach((change, index) => {
|
|
114
|
+
for (const ref of refsUsedBy(change)) {
|
|
115
|
+
if (!defined.has(ref)) {
|
|
116
|
+
throw new ResolveProtocolError("unresolved-ref", `resolve: leg ${index + 1} of mechanic '${mechanicName}' names ref '${ref}', and no earlier 'create' leg of this ` +
|
|
117
|
+
`resolution defines it; refused before any write. A ref may only name an entity created by a leg before the one that uses it.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (change.kind === "create") {
|
|
121
|
+
if (defined.has(change.ref)) {
|
|
122
|
+
throw new ResolveProtocolError("duplicate-ref", `resolve: mechanic '${mechanicName}' creates two entities under the ref '${change.ref}'; refused before any write. ` +
|
|
123
|
+
`A ref is unique within one resolution.`);
|
|
124
|
+
}
|
|
125
|
+
defined.add(change.ref);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function deref(value, refs) {
|
|
130
|
+
if (typeof value === "string")
|
|
131
|
+
return value;
|
|
132
|
+
const id = refs.get(value.ref);
|
|
133
|
+
if (id === undefined) {
|
|
134
|
+
// Unreachable after assertRefsResolvable; guarded so a future caller of
|
|
135
|
+
// this function alone still fails loudly.
|
|
136
|
+
throw new ResolveProtocolError("unresolved-ref", `resolve: ref '${value.ref}' names no entity created in this resolution`);
|
|
137
|
+
}
|
|
138
|
+
return id;
|
|
139
|
+
}
|
|
140
|
+
function derefValue(value, refs) {
|
|
141
|
+
return typeof value === "object" && value !== null ? deref(value, refs) : value;
|
|
142
|
+
}
|
|
143
|
+
function applyChange(change, gameId, refs) {
|
|
144
|
+
if (change.kind === "set") {
|
|
145
|
+
return { set: setProjectedValue({ entityId: deref(change.entityId, refs), key: change.key, value: derefValue(change.value, refs) }) };
|
|
146
|
+
}
|
|
84
147
|
if (change.kind === "write") {
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
148
|
+
return {
|
|
149
|
+
transitions: [
|
|
150
|
+
writeConstrainedValue({
|
|
151
|
+
entityId: deref(change.entityId, refs),
|
|
152
|
+
key: change.key,
|
|
153
|
+
mode: change.mode,
|
|
154
|
+
value: change.value,
|
|
155
|
+
reason: change.reason,
|
|
156
|
+
bounds: change.bounds,
|
|
157
|
+
}),
|
|
158
|
+
],
|
|
159
|
+
};
|
|
95
160
|
}
|
|
96
161
|
if (change.kind === "transfer") {
|
|
97
162
|
const { from, to } = transferConstrainedValue({
|
|
98
|
-
fromEntityId: change.fromEntityId,
|
|
99
|
-
toEntityId: change.toEntityId,
|
|
163
|
+
fromEntityId: deref(change.fromEntityId, refs),
|
|
164
|
+
toEntityId: deref(change.toEntityId, refs),
|
|
100
165
|
key: change.key,
|
|
101
166
|
amount: change.amount,
|
|
102
167
|
reason: change.reason,
|
|
103
168
|
fromBounds: change.fromBounds,
|
|
104
169
|
toBounds: change.toBounds,
|
|
105
170
|
});
|
|
106
|
-
return [from, to];
|
|
171
|
+
return { transitions: [from, to] };
|
|
172
|
+
}
|
|
173
|
+
if (change.kind === "create") {
|
|
174
|
+
const columns = {};
|
|
175
|
+
for (const [key, value] of Object.entries(change.columns))
|
|
176
|
+
columns[key] = derefValue(value, refs);
|
|
177
|
+
const created = createProjectedEntity({ gameId, entityKind: change.entityKind, columns });
|
|
178
|
+
refs.set(change.ref, created.entityId);
|
|
179
|
+
return { created: { ...created, ref: change.ref } };
|
|
180
|
+
}
|
|
181
|
+
if (change.kind === "destroy") {
|
|
182
|
+
return { destroyed: destroyProjectedEntity({ entityId: deref(change.entityId, refs) }) };
|
|
107
183
|
}
|
|
108
184
|
// Unreachable through the exported types (`IntendedChange` is an
|
|
109
185
|
// exhaustive discriminated union), but a mechanic is caller-supplied code
|
|
@@ -170,6 +246,8 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
170
246
|
});
|
|
171
247
|
const resolutionId = uuidv4();
|
|
172
248
|
const changes = adjudication.changes ?? [];
|
|
249
|
+
// 5's precondition: every ref resolves, before the transaction opens.
|
|
250
|
+
assertRefsResolvable(mechanicName, changes);
|
|
173
251
|
// 5 & 6. Apply every intended change through the one choke point, and
|
|
174
252
|
// record one event -- both inside ONE transaction with the adjudication
|
|
175
253
|
// window nested inside it (adjudication.ts's own doc comment asks for
|
|
@@ -180,8 +258,20 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
180
258
|
// is never relabelled as a ResolveProtocolError).
|
|
181
259
|
const applied = withTransaction(() => withAdjudicationOpen(gameId, () => {
|
|
182
260
|
const transitions = [];
|
|
261
|
+
const sets = [];
|
|
262
|
+
const created = [];
|
|
263
|
+
const destroyed = [];
|
|
264
|
+
const refs = new Map();
|
|
183
265
|
for (const change of changes) {
|
|
184
|
-
|
|
266
|
+
const applied = applyChange(change, gameId, refs);
|
|
267
|
+
if ("transitions" in applied)
|
|
268
|
+
transitions.push(...applied.transitions);
|
|
269
|
+
else if ("set" in applied)
|
|
270
|
+
sets.push(applied.set);
|
|
271
|
+
else if ("created" in applied)
|
|
272
|
+
created.push(applied.created);
|
|
273
|
+
else
|
|
274
|
+
destroyed.push(applied.destroyed);
|
|
185
275
|
}
|
|
186
276
|
// Re-read the clock AFTER every write has landed, inside this same
|
|
187
277
|
// transaction -- a sequence-axis game advances its own t once per
|
|
@@ -206,7 +296,7 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
206
296
|
getDatabase()
|
|
207
297
|
.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'resolution.recorded', ?, ?)`)
|
|
208
298
|
.run(eventId, gameId, postStory.t, adjudication.description ?? null, JSON.stringify(causes));
|
|
209
|
-
return { transitions, eventId, postT: postStory.t };
|
|
299
|
+
return { transitions, sets, created, destroyed, eventId, postT: postStory.t };
|
|
210
300
|
}));
|
|
211
301
|
// 7. The outcome's constraint, built AFTER the writes landed and the
|
|
212
302
|
// transaction holding them has already committed -- reachable only from a
|
|
@@ -220,6 +310,9 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
220
310
|
t,
|
|
221
311
|
result: adjudication.result ?? {},
|
|
222
312
|
transitions: applied.transitions,
|
|
313
|
+
sets: applied.sets,
|
|
314
|
+
created: applied.created,
|
|
315
|
+
destroyed: applied.destroyed,
|
|
223
316
|
constraint: postConstraint,
|
|
224
317
|
eventId: applied.eventId,
|
|
225
318
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "run-dmcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|