run-dmcp 0.7.0 → 0.9.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/db/schema.js +61 -0
- package/dist/index.d.ts +2 -2
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.js +1 -1
- package/dist/timeline/constrained.d.ts +71 -0
- package/dist/timeline/constrained.js +142 -1
- package/dist/timeline/registry.d.ts +42 -4
- package/dist/timeline/registry.js +62 -4
- package/dist/timeline/resolve.d.ts +116 -10
- package/dist/timeline/resolve.js +205 -22
- package/dist/tools/constraint.js +10 -20
- package/dist/types/index.d.ts +3 -0
- package/package.json +1 -1
package/dist/db/schema.js
CHANGED
|
@@ -1270,6 +1270,67 @@ export function initializeSchema(options) {
|
|
|
1270
1270
|
SELECT RAISE(ABORT, 'timeline: ''' || NEW.key || ''' on this entity is resolve_only-constrained; direct writes are refused -- this value can only change through the adjudicating call that opens the resolution window');
|
|
1271
1271
|
END;
|
|
1272
1272
|
`);
|
|
1273
|
+
// Issue #42: a `create` leg of a resolution may declare `bounded`/
|
|
1274
|
+
// `resolve_only`/`monotonic` constraints on the entity it makes, applied
|
|
1275
|
+
// by resolve() inside the SAME transaction as the create -- so a later
|
|
1276
|
+
// leg of the same resolution is already held to them. Two additions to
|
|
1277
|
+
// `resource_constraints`:
|
|
1278
|
+
//
|
|
1279
|
+
// - min_value/max_value: 'bounded' only, carried on the constraint's own
|
|
1280
|
+
// row. declareBoundedConstraint() (src/tools/constraint.ts) has no
|
|
1281
|
+
// need for this -- it enforces whatever `bounds` a caller passes at
|
|
1282
|
+
// write time against an EXISTING resource's own min_value/max_value
|
|
1283
|
+
// columns -- but a constraint declared in the same breath as the
|
|
1284
|
+
// entity it governs is recorded here instead, opaquely: the engine
|
|
1285
|
+
// stores these bounds and does not interpret them (issue #42's own
|
|
1286
|
+
// scope note), the same "one column, several kinds, mostly null"
|
|
1287
|
+
// shape `direction` (monotonic) and `total` (conserved) already use.
|
|
1288
|
+
// - caused_by_event_id: which `resolution.recorded` event's resolve()
|
|
1289
|
+
// call declared this constraint -- design §5.2c's one hop of
|
|
1290
|
+
// causality, recorded rather than derived later. NULL for a
|
|
1291
|
+
// constraint declared the ordinary way, through
|
|
1292
|
+
// declareBoundedConstraint/declareMonotonicConstraint/
|
|
1293
|
+
// declareConservedConstraint/declareResolveOnlyConstraint, none of
|
|
1294
|
+
// which run inside a resolution and so have no event to point at.
|
|
1295
|
+
//
|
|
1296
|
+
// Idempotent ALTERs, the same idiom RESOURCE_CONSTRAINTS_ADD_FACT_KEY_DDL
|
|
1297
|
+
// uses above -- no CHECK constraint changes here, so no table rebuild is
|
|
1298
|
+
// needed. Placed here, after initializeTimelineSchema() (a few lines up)
|
|
1299
|
+
// rather than beside RESOURCE_CONSTRAINTS_DDL earlier in this function,
|
|
1300
|
+
// so a fresh database's `resource_constraints` picks these up right after
|
|
1301
|
+
// the tables issue #42's writer touches (`events`) already exist.
|
|
1302
|
+
//
|
|
1303
|
+
// `caused_by_event_id` carries NO `REFERENCES events(id)` -- deliberately,
|
|
1304
|
+
// unlike `facts.opened_by_event_id` a few lines up. That column is always
|
|
1305
|
+
// written NULL-then-UPDATEd once its target event already exists (see
|
|
1306
|
+
// this function's own comment on it and projection.ts's insert trigger);
|
|
1307
|
+
// resolve()'s create-leg constraints (src/timeline/resolve.ts, issue #42)
|
|
1308
|
+
// declare a constraint and stamp the resolution's event id in the SAME
|
|
1309
|
+
// statement, before that event row exists (the event is written only
|
|
1310
|
+
// after every change in the resolution has landed, so its own `t` can be
|
|
1311
|
+
// read post-write -- see resolve.ts step 7). An immediate FK here would
|
|
1312
|
+
// refuse every such INSERT. `events.causes` already carries this same
|
|
1313
|
+
// "one hop of provenance, unenforced by a foreign key" shape for every
|
|
1314
|
+
// other event-to-event reference in this codebase (resolution_id, row_id,
|
|
1315
|
+
// fact_id) -- this column follows it rather than being the one exception.
|
|
1316
|
+
try {
|
|
1317
|
+
db.exec(`ALTER TABLE resource_constraints ADD COLUMN min_value REAL`);
|
|
1318
|
+
}
|
|
1319
|
+
catch {
|
|
1320
|
+
// Column already exists.
|
|
1321
|
+
}
|
|
1322
|
+
try {
|
|
1323
|
+
db.exec(`ALTER TABLE resource_constraints ADD COLUMN max_value REAL`);
|
|
1324
|
+
}
|
|
1325
|
+
catch {
|
|
1326
|
+
// Column already exists.
|
|
1327
|
+
}
|
|
1328
|
+
try {
|
|
1329
|
+
db.exec(`ALTER TABLE resource_constraints ADD COLUMN caused_by_event_id TEXT`);
|
|
1330
|
+
}
|
|
1331
|
+
catch {
|
|
1332
|
+
// Column already exists.
|
|
1333
|
+
}
|
|
1273
1334
|
}
|
|
1274
1335
|
function runConsumerMigrations(db, migrations) {
|
|
1275
1336
|
if (!migrations || migrations.length === 0) {
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +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 } from "./timeline/constrained.js";
|
|
17
|
+
export type { SetTransition, CreatedEntity, DestroyedEntity } from "./timeline/constrained.js";
|
|
18
18
|
export { openingEventId } from "./timeline/provenance.js";
|
|
19
19
|
export type { FactProvenance } from "./timeline/provenance.js";
|
|
20
20
|
export { narrationConstraintAt, contradictions, NARRATION_CONSTRAINT_FORMAT_VERSION, } from "./timeline/narration.js";
|
|
@@ -23,7 +23,7 @@ export { writeConstrainedValue, transferConstrainedValue, valueHistory, } from "
|
|
|
23
23
|
export type { ValueTransition } from "./timeline/constrained.js";
|
|
24
24
|
export { ConstraintViolationError, constraintsFor, conservedConstraintFor } from "./timeline/registry.js";
|
|
25
25
|
export { createResolver, ResolveProtocolError } from "./timeline/resolve.js";
|
|
26
|
-
export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, IntendedSet, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
|
|
26
|
+
export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, IntendedSet, IntendedCreate, CreateConstraint, IntendedDestroy, EntityRef, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
|
|
27
27
|
export { createStateRenderer } from "./timeline/render.js";
|
|
28
28
|
export type { RenderVocabulary, VocabularyEntry, StateRenderer, RenderedState, RenderedNoun, UnnamedFact, } from "./timeline/render.js";
|
|
29
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.9.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.9.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`'
|
|
@@ -257,3 +258,73 @@ export declare function setProjectedValue(params: {
|
|
|
257
258
|
key: string;
|
|
258
259
|
value: string | number | null;
|
|
259
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
|
|
@@ -747,3 +747,144 @@ export function setProjectedValue(params) {
|
|
|
747
747
|
translateIrreversibleFailure(db, [{ entityId: params.entityId, key: params.key, table: resolved.table, attemptedValue: params.value }], err);
|
|
748
748
|
}
|
|
749
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
|
+
}
|
|
@@ -19,10 +19,23 @@ import type { IrreversibleFact } from "./irreversible.js";
|
|
|
19
19
|
* the read side here breaks that cycle before the choke point exists to hit
|
|
20
20
|
* it.
|
|
21
21
|
*
|
|
22
|
-
* Everything that WRITES `resource_constraints`
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* Everything that WRITES `resource_constraints` used to stay entirely in
|
|
23
|
+
* src/tools/constraint.ts -- insertConstraint() and the declare*()
|
|
24
|
+
* functions, and all the validation that goes with them. Issue #42 moved
|
|
25
|
+
* the one raw INSERT (insertConstraintRow() below) down here, because
|
|
26
|
+
* resolve() (src/timeline/resolve.ts) now also has to write this exact row,
|
|
27
|
+
* from INSIDE its own transaction, when a `create` leg declares
|
|
28
|
+
* `bounded`/`resolve_only`/`monotonic` on the entity it makes. Reaching
|
|
29
|
+
* from src/timeline/ back into src/tools/constraint.ts to get there would
|
|
30
|
+
* close the identical cycle this module's own doc comment (above) describes
|
|
31
|
+
* for the choke point: tools/resource.ts already imports tools/constraint.ts
|
|
32
|
+
* (indirectly, via getResource), and tools/constraint.ts would then import
|
|
33
|
+
* timeline/resolve.ts's writer, closing tools/* -> timeline/* -> tools/*.
|
|
34
|
+
* src/tools/constraint.ts's declare*() functions keep every piece of
|
|
35
|
+
* business validation they always had (game exists, resource exists, no
|
|
36
|
+
* duplicate, minValue/maxValue already set for 'bounded') and call
|
|
37
|
+
* insertConstraintRow() only once every check has passed -- they are not
|
|
38
|
+
* merged away, only pointed at the one place the row is actually written.
|
|
26
39
|
*/
|
|
27
40
|
/** Absolute tolerance for floating-point sum comparisons on 'conserved'
|
|
28
41
|
* constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
|
|
@@ -67,11 +80,36 @@ export interface ConstraintRow {
|
|
|
67
80
|
total: number | null;
|
|
68
81
|
fact_key: string;
|
|
69
82
|
created_at: string;
|
|
83
|
+
min_value: number | null;
|
|
84
|
+
max_value: number | null;
|
|
85
|
+
caused_by_event_id: string | null;
|
|
70
86
|
}
|
|
71
87
|
/** The resource ids belonging to a constraint, in insertion order. Exported
|
|
72
88
|
* alongside ConstraintRow/rowToConstraint for the same reason. */
|
|
73
89
|
export declare function memberIdsFor(constraintId: string): string[];
|
|
74
90
|
export declare function rowToConstraint(row: ConstraintRow): ResourceConstraint;
|
|
91
|
+
/**
|
|
92
|
+
* The one INSERT for `resource_constraints` (+ its members) -- see this
|
|
93
|
+
* module's own doc comment on why it lives here rather than in
|
|
94
|
+
* src/tools/constraint.ts. Every declare*Constraint() function there calls
|
|
95
|
+
* this only after its own business validation passes; resolve()'s create-leg
|
|
96
|
+
* declarations (src/timeline/resolve.ts, issue #42) call it directly, from
|
|
97
|
+
* inside their own transaction, after the leaner pre-transaction check that
|
|
98
|
+
* module runs (a declared key must be a live column -- see
|
|
99
|
+
* assertCreateConstraintKeysValid there). Either way this is the only
|
|
100
|
+
* `INSERT INTO resource_constraints` in the codebase.
|
|
101
|
+
*/
|
|
102
|
+
export declare function insertConstraintRow(params: {
|
|
103
|
+
gameId: string;
|
|
104
|
+
kind: ConstraintKind;
|
|
105
|
+
resourceIds: readonly string[];
|
|
106
|
+
direction?: MonotonicDirection | null;
|
|
107
|
+
total?: number | null;
|
|
108
|
+
factKey?: string;
|
|
109
|
+
minValue?: number | null;
|
|
110
|
+
maxValue?: number | null;
|
|
111
|
+
causedByEventId?: string | null;
|
|
112
|
+
}): ResourceConstraint;
|
|
75
113
|
/**
|
|
76
114
|
* Every constraint governing `(entityId, factKey)`, ordered by
|
|
77
115
|
* `created_at`. This is the whole point of Phase 3 step 1: a constraint
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
1
2
|
import { getDatabase } from "../db/connection.js";
|
|
2
3
|
/**
|
|
3
4
|
* The read side of the resource-constraint registry (design §5.3 / §5.4
|
|
@@ -18,10 +19,23 @@ import { getDatabase } from "../db/connection.js";
|
|
|
18
19
|
* the read side here breaks that cycle before the choke point exists to hit
|
|
19
20
|
* it.
|
|
20
21
|
*
|
|
21
|
-
* Everything that WRITES `resource_constraints`
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Everything that WRITES `resource_constraints` used to stay entirely in
|
|
23
|
+
* src/tools/constraint.ts -- insertConstraint() and the declare*()
|
|
24
|
+
* functions, and all the validation that goes with them. Issue #42 moved
|
|
25
|
+
* the one raw INSERT (insertConstraintRow() below) down here, because
|
|
26
|
+
* resolve() (src/timeline/resolve.ts) now also has to write this exact row,
|
|
27
|
+
* from INSIDE its own transaction, when a `create` leg declares
|
|
28
|
+
* `bounded`/`resolve_only`/`monotonic` on the entity it makes. Reaching
|
|
29
|
+
* from src/timeline/ back into src/tools/constraint.ts to get there would
|
|
30
|
+
* close the identical cycle this module's own doc comment (above) describes
|
|
31
|
+
* for the choke point: tools/resource.ts already imports tools/constraint.ts
|
|
32
|
+
* (indirectly, via getResource), and tools/constraint.ts would then import
|
|
33
|
+
* timeline/resolve.ts's writer, closing tools/* -> timeline/* -> tools/*.
|
|
34
|
+
* src/tools/constraint.ts's declare*() functions keep every piece of
|
|
35
|
+
* business validation they always had (game exists, resource exists, no
|
|
36
|
+
* duplicate, minValue/maxValue already set for 'bounded') and call
|
|
37
|
+
* insertConstraintRow() only once every check has passed -- they are not
|
|
38
|
+
* merged away, only pointed at the one place the row is actually written.
|
|
25
39
|
*/
|
|
26
40
|
/** Absolute tolerance for floating-point sum comparisons on 'conserved'
|
|
27
41
|
* constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
|
|
@@ -70,6 +84,50 @@ export function rowToConstraint(row) {
|
|
|
70
84
|
total: row.total,
|
|
71
85
|
factKey: row.fact_key,
|
|
72
86
|
createdAt: row.created_at,
|
|
87
|
+
minValue: row.min_value,
|
|
88
|
+
maxValue: row.max_value,
|
|
89
|
+
causedByEventId: row.caused_by_event_id,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The one INSERT for `resource_constraints` (+ its members) -- see this
|
|
94
|
+
* module's own doc comment on why it lives here rather than in
|
|
95
|
+
* src/tools/constraint.ts. Every declare*Constraint() function there calls
|
|
96
|
+
* this only after its own business validation passes; resolve()'s create-leg
|
|
97
|
+
* declarations (src/timeline/resolve.ts, issue #42) call it directly, from
|
|
98
|
+
* inside their own transaction, after the leaner pre-transaction check that
|
|
99
|
+
* module runs (a declared key must be a live column -- see
|
|
100
|
+
* assertCreateConstraintKeysValid there). Either way this is the only
|
|
101
|
+
* `INSERT INTO resource_constraints` in the codebase.
|
|
102
|
+
*/
|
|
103
|
+
export function insertConstraintRow(params) {
|
|
104
|
+
const db = getDatabase();
|
|
105
|
+
const id = uuidv4();
|
|
106
|
+
const createdAt = new Date().toISOString();
|
|
107
|
+
const direction = params.direction ?? null;
|
|
108
|
+
const total = params.total ?? null;
|
|
109
|
+
const factKey = params.factKey ?? "value";
|
|
110
|
+
const minValue = params.minValue ?? null;
|
|
111
|
+
const maxValue = params.maxValue ?? null;
|
|
112
|
+
const causedByEventId = params.causedByEventId ?? null;
|
|
113
|
+
db.prepare(`INSERT INTO resource_constraints (id, game_id, kind, direction, total, fact_key, created_at, min_value, max_value, caused_by_event_id)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.gameId, params.kind, direction, total, factKey, createdAt, minValue, maxValue, causedByEventId);
|
|
115
|
+
const memberStmt = db.prepare(`INSERT INTO resource_constraint_members (constraint_id, resource_id) VALUES (?, ?)`);
|
|
116
|
+
for (const resourceId of params.resourceIds) {
|
|
117
|
+
memberStmt.run(id, resourceId);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
gameId: params.gameId,
|
|
122
|
+
kind: params.kind,
|
|
123
|
+
resourceIds: [...params.resourceIds],
|
|
124
|
+
direction,
|
|
125
|
+
total,
|
|
126
|
+
factKey,
|
|
127
|
+
createdAt,
|
|
128
|
+
minValue,
|
|
129
|
+
maxValue,
|
|
130
|
+
causedByEventId,
|
|
73
131
|
};
|
|
74
132
|
}
|
|
75
133
|
/**
|
|
@@ -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
|
|
@@ -72,8 +73,16 @@ import { type ValueTransition, type SetTransition } from "./constrained.js";
|
|
|
72
73
|
* nesting, so the window row rolls back with the writes it
|
|
73
74
|
* authorized). Every change goes through `writeConstrainedValue` /
|
|
74
75
|
* `transferConstrainedValue` / `setProjectedValue` (issue #32, a
|
|
75
|
-
* non-numeric column)
|
|
76
|
-
*
|
|
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
|
|
77
86
|
* anywhere in the list propagates out of the transaction untouched
|
|
78
87
|
* (never caught and re-labelled here) and rolls back EVERY change the
|
|
79
88
|
* transaction made, including ones that individually would have
|
|
@@ -136,13 +145,24 @@ export interface AdjudicationInput {
|
|
|
136
145
|
parameters: Record<string, unknown>;
|
|
137
146
|
constraint: NarrationConstraint;
|
|
138
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
|
+
};
|
|
139
159
|
/** One intended write to a single fact key -- the generic shape
|
|
140
160
|
* `writeConstrainedValue` (constrained.ts) already takes, carried here so a
|
|
141
161
|
* mechanic can express "change this value" without ever calling that
|
|
142
162
|
* function itself. */
|
|
143
163
|
export interface IntendedWrite {
|
|
144
164
|
kind: "write";
|
|
145
|
-
entityId:
|
|
165
|
+
entityId: EntityRef;
|
|
146
166
|
key: string;
|
|
147
167
|
mode: "delta" | "set";
|
|
148
168
|
value: number;
|
|
@@ -156,8 +176,8 @@ export interface IntendedWrite {
|
|
|
156
176
|
* shape `transferConstrainedValue` (constrained.ts) already takes. */
|
|
157
177
|
export interface IntendedTransfer {
|
|
158
178
|
kind: "transfer";
|
|
159
|
-
fromEntityId:
|
|
160
|
-
toEntityId:
|
|
179
|
+
fromEntityId: EntityRef;
|
|
180
|
+
toEntityId: EntityRef;
|
|
161
181
|
key: string;
|
|
162
182
|
amount: number;
|
|
163
183
|
reason?: string | null;
|
|
@@ -176,11 +196,79 @@ export interface IntendedTransfer {
|
|
|
176
196
|
* `setProjectedValue` (constrained.ts) for what it refuses. */
|
|
177
197
|
export interface IntendedSet {
|
|
178
198
|
kind: "set";
|
|
179
|
-
entityId:
|
|
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
|
+
/**
|
|
208
|
+
* One constraint a `create` leg declares on the entity it makes (issue #42),
|
|
209
|
+
* applied by `resolve()` step 5 INSIDE the same transaction as the create --
|
|
210
|
+
* so a later leg of the same resolution is already held to it, and the
|
|
211
|
+
* whole resolution rolls back together if it is not (see
|
|
212
|
+
* assertCreateConstraintKeysValid and the `declareCreateConstraints` call in
|
|
213
|
+
* `resolveProposal` below). Vocabulary matches the existing constraint
|
|
214
|
+
* family exactly (declareBoundedConstraint/declareMonotonicConstraint/
|
|
215
|
+
* declareResolveOnlyConstraint, src/tools/constraint.ts) -- `conserved` is
|
|
216
|
+
* deliberately absent: it is a statement about several EXISTING entities
|
|
217
|
+
* summing to a total, not about one being created, so a single `create` leg
|
|
218
|
+
* has nothing to attach it to.
|
|
219
|
+
*
|
|
220
|
+
* The engine interprets neither `key` nor any bound below -- it only
|
|
221
|
+
* confirms, before any write, that `key` names a live column of the
|
|
222
|
+
* entity's own table (assertCreateConstraintKeysValid). `direction`'s
|
|
223
|
+
* vocabulary here ('up'/'down') is this field's own; declareCreateConstraints
|
|
224
|
+
* translates it to the registry's stored 'increasing'/'decreasing' the same
|
|
225
|
+
* way `entityKind` is translated to a table name -- a structural lookup, not
|
|
226
|
+
* an interpretation of what either word means (root CLAUDE.md hard rule 4).
|
|
227
|
+
*/
|
|
228
|
+
export type CreateConstraint = {
|
|
229
|
+
kind: "bounded";
|
|
230
|
+
key: string;
|
|
231
|
+
minValue: number | null;
|
|
232
|
+
maxValue: number | null;
|
|
233
|
+
} | {
|
|
234
|
+
kind: "resolve_only";
|
|
180
235
|
key: string;
|
|
181
|
-
|
|
236
|
+
} | {
|
|
237
|
+
kind: "monotonic";
|
|
238
|
+
key: string;
|
|
239
|
+
direction: "up" | "down";
|
|
240
|
+
};
|
|
241
|
+
/** One intended entity coming into existence (issue #34): a row in the
|
|
242
|
+
* projected table for `entityKind`, with `columns` restricted to that
|
|
243
|
+
* table's live columns and the game column filled by the engine from the
|
|
244
|
+
* proposal. `ref` is the label later legs of this same resolution use for
|
|
245
|
+
* it; the engine allocates the id and reports it in `Outcome.created`.
|
|
246
|
+
* The engine never learns what the entity is for or what it was made
|
|
247
|
+
* from -- "derived from" is the caller's to record.
|
|
248
|
+
*
|
|
249
|
+
* `constraints` (issue #42) is optional and, unlike `columns`, is never a
|
|
250
|
+
* column write -- it declares `resource_constraints` rows governing the
|
|
251
|
+
* entity this leg creates, the same rows declareBoundedConstraint/
|
|
252
|
+
* declareMonotonicConstraint/declareResolveOnlyConstraint would declare
|
|
253
|
+
* after the fact, but inside the resolution's own transaction instead of a
|
|
254
|
+
* second write path after `resolve()` returns. */
|
|
255
|
+
export interface IntendedCreate {
|
|
256
|
+
kind: "create";
|
|
257
|
+
ref: string;
|
|
258
|
+
entityKind: EntityKind;
|
|
259
|
+
columns: Readonly<Record<string, string | number | null | {
|
|
260
|
+
ref: string;
|
|
261
|
+
}>>;
|
|
262
|
+
constraints?: readonly CreateConstraint[];
|
|
263
|
+
}
|
|
264
|
+
/** One intended entity ending (issue #34): its live row deleted, its facts
|
|
265
|
+
* closed by the projection trigger. See `destroyProjectedEntity`
|
|
266
|
+
* (constrained.ts) for what it refuses. */
|
|
267
|
+
export interface IntendedDestroy {
|
|
268
|
+
kind: "destroy";
|
|
269
|
+
entityId: EntityRef;
|
|
182
270
|
}
|
|
183
|
-
export type IntendedChange = IntendedWrite | IntendedTransfer | IntendedSet;
|
|
271
|
+
export type IntendedChange = IntendedWrite | IntendedTransfer | IntendedSet | IntendedCreate | IntendedDestroy;
|
|
184
272
|
/**
|
|
185
273
|
* What a mechanic returns. `changes` are intents, not writes -- `resolve()`
|
|
186
274
|
* applies every one of them through the one choke point (step 5); the
|
|
@@ -215,6 +303,15 @@ export interface Outcome {
|
|
|
215
303
|
/** Every `set` this resolution applied, in order (issue #32). Kept apart
|
|
216
304
|
* from `transitions`, whose values are numbers. */
|
|
217
305
|
sets: SetTransition[];
|
|
306
|
+
/** Every entity this resolution created, in order, each with the `ref`
|
|
307
|
+
* the mechanic labelled it with and the id the engine allocated (issue
|
|
308
|
+
* #34). Kept apart from `transitions` and `sets`, so existing callers are
|
|
309
|
+
* unchanged. */
|
|
310
|
+
created: (CreatedEntity & {
|
|
311
|
+
ref: string;
|
|
312
|
+
})[];
|
|
313
|
+
/** Every entity this resolution destroyed, in order (issue #34). */
|
|
314
|
+
destroyed: DestroyedEntity[];
|
|
218
315
|
constraint: NarrationConstraint;
|
|
219
316
|
eventId: string;
|
|
220
317
|
}
|
|
@@ -232,7 +329,16 @@ export interface Mechanic {
|
|
|
232
329
|
* under -- never a judgement about the proposal, the mechanic, or the
|
|
233
330
|
* world (see the module doc comment's "records decisions, does not make
|
|
234
331
|
* them" paragraph). */
|
|
235
|
-
export type ResolveRefusalReason = "unknown-mechanic" | "no-clock" | "expectation-contradicted"
|
|
332
|
+
export type ResolveRefusalReason = "unknown-mechanic" | "no-clock" | "expectation-contradicted"
|
|
333
|
+
/** A leg said `{ ref }` and no earlier `create` leg of the same
|
|
334
|
+
* resolution defined that ref (issue #34). Refused before any write. */
|
|
335
|
+
| "unresolved-ref"
|
|
336
|
+
/** Two `create` legs of one resolution chose the same `ref` (issue #34).
|
|
337
|
+
* Refused before any write. */
|
|
338
|
+
| "duplicate-ref"
|
|
339
|
+
/** A `create` leg's `constraints` names a `key` that is not a live column
|
|
340
|
+
* of the entity it creates (issue #42). Refused before any write. */
|
|
341
|
+
| "invalid-constraint-key";
|
|
236
342
|
/**
|
|
237
343
|
* Refused before dispatch, before any write, or (never, by construction --
|
|
238
344
|
* see step 5 above) mid-apply. `reason` is the discriminant a caller
|
package/dist/timeline/resolve.js
CHANGED
|
@@ -3,7 +3,9 @@ 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 {
|
|
6
|
+
import { PROJECTED_TABLES, liveColumns } from "./projection.js";
|
|
7
|
+
import { insertConstraintRow } from "./registry.js";
|
|
8
|
+
import { writeConstrainedValue, transferConstrainedValue, setProjectedValue, createProjectedEntity, destroyProjectedEntity, } from "./constrained.js";
|
|
7
9
|
/**
|
|
8
10
|
* Refused before dispatch, before any write, or (never, by construction --
|
|
9
11
|
* see step 5 above) mid-apply. `reason` is the discriminant a caller
|
|
@@ -80,33 +82,185 @@ export function createResolver(params) {
|
|
|
80
82
|
},
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
|
-
|
|
85
|
+
/** Every `{ ref }` a change carries, in the order it is read (issue #34) --
|
|
86
|
+
* the one place the shape of each intent kind's ref-bearing fields is
|
|
87
|
+
* known, shared by the pre-transaction check and the apply step. */
|
|
88
|
+
function refsUsedBy(change) {
|
|
89
|
+
const refOf = (value) => (typeof value === "object" && value !== null && "ref" in value ? [String(value.ref)] : []);
|
|
90
|
+
switch (change.kind) {
|
|
91
|
+
case "write":
|
|
92
|
+
return refOf(change.entityId);
|
|
93
|
+
case "transfer":
|
|
94
|
+
return [...refOf(change.fromEntityId), ...refOf(change.toEntityId)];
|
|
95
|
+
case "set":
|
|
96
|
+
return [...refOf(change.entityId), ...refOf(change.value)];
|
|
97
|
+
case "create":
|
|
98
|
+
return Object.values(change.columns).flatMap(refOf);
|
|
99
|
+
case "destroy":
|
|
100
|
+
return refOf(change.entityId);
|
|
101
|
+
default:
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Step 5's precondition (issue #34): every `{ ref }` names a `create` leg
|
|
107
|
+
* EARLIER in the list, and no two creates share a ref. Checked over the
|
|
108
|
+
* intent list alone -- a structural property of what the mechanic returned,
|
|
109
|
+
* decidable with no database at all -- so a bad ref refuses with no
|
|
110
|
+
* transaction opened and no write attempted, in the same voice as every
|
|
111
|
+
* other protocol refusal.
|
|
112
|
+
*/
|
|
113
|
+
function assertRefsResolvable(mechanicName, changes) {
|
|
114
|
+
const defined = new Set();
|
|
115
|
+
changes.forEach((change, index) => {
|
|
116
|
+
for (const ref of refsUsedBy(change)) {
|
|
117
|
+
if (!defined.has(ref)) {
|
|
118
|
+
throw new ResolveProtocolError("unresolved-ref", `resolve: leg ${index + 1} of mechanic '${mechanicName}' names ref '${ref}', and no earlier 'create' leg of this ` +
|
|
119
|
+
`resolution defines it; refused before any write. A ref may only name an entity created by a leg before the one that uses it.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (change.kind === "create") {
|
|
123
|
+
if (defined.has(change.ref)) {
|
|
124
|
+
throw new ResolveProtocolError("duplicate-ref", `resolve: mechanic '${mechanicName}' creates two entities under the ref '${change.ref}'; refused before any write. ` +
|
|
125
|
+
`A ref is unique within one resolution.`);
|
|
126
|
+
}
|
|
127
|
+
defined.add(change.ref);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Step 5's second precondition (issue #42), checked in the same voice and
|
|
133
|
+
* at the same point as assertRefsResolvable above: every `constraints`
|
|
134
|
+
* entry on every `create` leg names a `key` that is a live column of the
|
|
135
|
+
* projected table for that leg's `entityKind` -- decidable from the schema
|
|
136
|
+
* alone, with no database write and no dependency on the entity actually
|
|
137
|
+
* existing yet, because the live column set is a property of the KIND, not
|
|
138
|
+
* of any one row of it. A bad key refuses with no transaction opened and no
|
|
139
|
+
* write attempted, naming the key.
|
|
140
|
+
*/
|
|
141
|
+
function assertCreateConstraintKeysValid(mechanicName, changes) {
|
|
142
|
+
const db = getDatabase();
|
|
143
|
+
changes.forEach((change, index) => {
|
|
144
|
+
if (change.kind !== "create" || !change.constraints || change.constraints.length === 0)
|
|
145
|
+
return;
|
|
146
|
+
const projected = PROJECTED_TABLES.find((p) => p.kind === change.entityKind);
|
|
147
|
+
// An entityKind with no projected table is createProjectedEntity's own
|
|
148
|
+
// refusal to make, inside the transaction -- nothing to validate a
|
|
149
|
+
// constraint key against here.
|
|
150
|
+
if (!projected)
|
|
151
|
+
return;
|
|
152
|
+
const cols = liveColumns(db, projected.table);
|
|
153
|
+
for (const constraint of change.constraints) {
|
|
154
|
+
if (!cols.includes(constraint.key)) {
|
|
155
|
+
throw new ResolveProtocolError("invalid-constraint-key", `resolve: leg ${index + 1} of mechanic '${mechanicName}' declares a '${constraint.kind}' constraint on key ` +
|
|
156
|
+
`'${constraint.key}' for the entity it creates under ref '${change.ref}', and '${constraint.key}' is not a ` +
|
|
157
|
+
`live column of '${projected.table}' -- refused before any write.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Translates every `CreateConstraint` (issue #42) a `create` leg declared
|
|
164
|
+
* into a `resource_constraints` row, through insertConstraintRow
|
|
165
|
+
* (registry.ts) -- the same primitive every declare*Constraint()
|
|
166
|
+
* (src/tools/constraint.ts) call now shares, so this is never a second
|
|
167
|
+
* write path for that table. Called from INSIDE the resolution's own
|
|
168
|
+
* transaction (resolveProposal below), immediately after the entity it
|
|
169
|
+
* governs is created, so `entityId` is real and `eventId` is the id the
|
|
170
|
+
* resolution's own `resolution.recorded` event will carry.
|
|
171
|
+
*/
|
|
172
|
+
function declareCreateConstraints(params) {
|
|
173
|
+
for (const constraint of params.constraints) {
|
|
174
|
+
if (constraint.kind === "bounded") {
|
|
175
|
+
insertConstraintRow({
|
|
176
|
+
gameId: params.gameId,
|
|
177
|
+
kind: "bounded",
|
|
178
|
+
resourceIds: [params.entityId],
|
|
179
|
+
factKey: constraint.key,
|
|
180
|
+
minValue: constraint.minValue,
|
|
181
|
+
maxValue: constraint.maxValue,
|
|
182
|
+
causedByEventId: params.eventId,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else if (constraint.kind === "resolve_only") {
|
|
186
|
+
insertConstraintRow({
|
|
187
|
+
gameId: params.gameId,
|
|
188
|
+
kind: "resolve_only",
|
|
189
|
+
resourceIds: [params.entityId],
|
|
190
|
+
factKey: constraint.key,
|
|
191
|
+
causedByEventId: params.eventId,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
insertConstraintRow({
|
|
196
|
+
gameId: params.gameId,
|
|
197
|
+
kind: "monotonic",
|
|
198
|
+
resourceIds: [params.entityId],
|
|
199
|
+
factKey: constraint.key,
|
|
200
|
+
// This field's own vocabulary ('up'/'down') is translated to the
|
|
201
|
+
// registry's stored 'increasing'/'decreasing' -- a structural
|
|
202
|
+
// lookup, not an interpretation of what either word means (see
|
|
203
|
+
// CreateConstraint's own doc comment).
|
|
204
|
+
direction: constraint.direction === "up" ? "increasing" : "decreasing",
|
|
205
|
+
causedByEventId: params.eventId,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function deref(value, refs) {
|
|
211
|
+
if (typeof value === "string")
|
|
212
|
+
return value;
|
|
213
|
+
const id = refs.get(value.ref);
|
|
214
|
+
if (id === undefined) {
|
|
215
|
+
// Unreachable after assertRefsResolvable; guarded so a future caller of
|
|
216
|
+
// this function alone still fails loudly.
|
|
217
|
+
throw new ResolveProtocolError("unresolved-ref", `resolve: ref '${value.ref}' names no entity created in this resolution`);
|
|
218
|
+
}
|
|
219
|
+
return id;
|
|
220
|
+
}
|
|
221
|
+
function derefValue(value, refs) {
|
|
222
|
+
return typeof value === "object" && value !== null ? deref(value, refs) : value;
|
|
223
|
+
}
|
|
224
|
+
function applyChange(change, gameId, refs) {
|
|
84
225
|
if (change.kind === "set") {
|
|
85
|
-
return setProjectedValue({ entityId: change.entityId, key: change.key, value: change.value });
|
|
226
|
+
return { set: setProjectedValue({ entityId: deref(change.entityId, refs), key: change.key, value: derefValue(change.value, refs) }) };
|
|
86
227
|
}
|
|
87
228
|
if (change.kind === "write") {
|
|
88
|
-
return
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
229
|
+
return {
|
|
230
|
+
transitions: [
|
|
231
|
+
writeConstrainedValue({
|
|
232
|
+
entityId: deref(change.entityId, refs),
|
|
233
|
+
key: change.key,
|
|
234
|
+
mode: change.mode,
|
|
235
|
+
value: change.value,
|
|
236
|
+
reason: change.reason,
|
|
237
|
+
bounds: change.bounds,
|
|
238
|
+
}),
|
|
239
|
+
],
|
|
240
|
+
};
|
|
98
241
|
}
|
|
99
242
|
if (change.kind === "transfer") {
|
|
100
243
|
const { from, to } = transferConstrainedValue({
|
|
101
|
-
fromEntityId: change.fromEntityId,
|
|
102
|
-
toEntityId: change.toEntityId,
|
|
244
|
+
fromEntityId: deref(change.fromEntityId, refs),
|
|
245
|
+
toEntityId: deref(change.toEntityId, refs),
|
|
103
246
|
key: change.key,
|
|
104
247
|
amount: change.amount,
|
|
105
248
|
reason: change.reason,
|
|
106
249
|
fromBounds: change.fromBounds,
|
|
107
250
|
toBounds: change.toBounds,
|
|
108
251
|
});
|
|
109
|
-
return [from, to];
|
|
252
|
+
return { transitions: [from, to] };
|
|
253
|
+
}
|
|
254
|
+
if (change.kind === "create") {
|
|
255
|
+
const columns = {};
|
|
256
|
+
for (const [key, value] of Object.entries(change.columns))
|
|
257
|
+
columns[key] = derefValue(value, refs);
|
|
258
|
+
const created = createProjectedEntity({ gameId, entityKind: change.entityKind, columns });
|
|
259
|
+
refs.set(change.ref, created.entityId);
|
|
260
|
+
return { created: { ...created, ref: change.ref } };
|
|
261
|
+
}
|
|
262
|
+
if (change.kind === "destroy") {
|
|
263
|
+
return { destroyed: destroyProjectedEntity({ entityId: deref(change.entityId, refs) }) };
|
|
110
264
|
}
|
|
111
265
|
// Unreachable through the exported types (`IntendedChange` is an
|
|
112
266
|
// exhaustive discriminated union), but a mechanic is caller-supplied code
|
|
@@ -173,6 +327,10 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
173
327
|
});
|
|
174
328
|
const resolutionId = uuidv4();
|
|
175
329
|
const changes = adjudication.changes ?? [];
|
|
330
|
+
// 5's preconditions: every ref resolves, and every create leg's declared
|
|
331
|
+
// constraint keys are live columns -- both before the transaction opens.
|
|
332
|
+
assertRefsResolvable(mechanicName, changes);
|
|
333
|
+
assertCreateConstraintKeysValid(mechanicName, changes);
|
|
176
334
|
// 5 & 6. Apply every intended change through the one choke point, and
|
|
177
335
|
// record one event -- both inside ONE transaction with the adjudication
|
|
178
336
|
// window nested inside it (adjudication.ts's own doc comment asks for
|
|
@@ -184,12 +342,36 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
184
342
|
const applied = withTransaction(() => withAdjudicationOpen(gameId, () => {
|
|
185
343
|
const transitions = [];
|
|
186
344
|
const sets = [];
|
|
345
|
+
const created = [];
|
|
346
|
+
const destroyed = [];
|
|
347
|
+
const refs = new Map();
|
|
348
|
+
// Generated here, before any change is applied, rather than after the
|
|
349
|
+
// loop below (as it used to be) -- issue #42's create-leg constraint
|
|
350
|
+
// declarations (below) need to stamp the SAME event id this
|
|
351
|
+
// resolution's own `resolution.recorded` event is about to carry, and
|
|
352
|
+
// that event cannot be written until every change has landed (step 7's
|
|
353
|
+
// t comes from AFTER the writes). uuidv4() needs nothing from the
|
|
354
|
+
// database, so generating it early costs nothing and lets both sides
|
|
355
|
+
// agree on one id.
|
|
356
|
+
const eventId = uuidv4();
|
|
187
357
|
for (const change of changes) {
|
|
188
|
-
const applied = applyChange(change);
|
|
189
|
-
if (
|
|
190
|
-
transitions.push(...applied);
|
|
358
|
+
const applied = applyChange(change, gameId, refs);
|
|
359
|
+
if ("transitions" in applied)
|
|
360
|
+
transitions.push(...applied.transitions);
|
|
361
|
+
else if ("set" in applied)
|
|
362
|
+
sets.push(applied.set);
|
|
363
|
+
else if ("created" in applied) {
|
|
364
|
+
created.push(applied.created);
|
|
365
|
+
// Issue #42: a create leg's declared constraints are applied
|
|
366
|
+
// immediately, inside this same transaction, once the entity they
|
|
367
|
+
// govern has a real id -- so a later leg of this same resolution
|
|
368
|
+
// (still to come in this loop) is already held to them.
|
|
369
|
+
if (change.kind === "create" && change.constraints && change.constraints.length > 0) {
|
|
370
|
+
declareCreateConstraints({ gameId, entityId: applied.created.entityId, constraints: change.constraints, eventId });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
191
373
|
else
|
|
192
|
-
|
|
374
|
+
destroyed.push(applied.destroyed);
|
|
193
375
|
}
|
|
194
376
|
// Re-read the clock AFTER every write has landed, inside this same
|
|
195
377
|
// transaction -- a sequence-axis game advances its own t once per
|
|
@@ -204,7 +386,6 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
204
386
|
// function has no business assuming that silently forever.
|
|
205
387
|
throw new Error(`resolve: game '${gameId}' lost its timeline clock mid-resolution -- cannot record the outcome event`);
|
|
206
388
|
}
|
|
207
|
-
const eventId = uuidv4();
|
|
208
389
|
const causes = {
|
|
209
390
|
source: "resolve",
|
|
210
391
|
resolution_id: resolutionId,
|
|
@@ -214,7 +395,7 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
214
395
|
getDatabase()
|
|
215
396
|
.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'resolution.recorded', ?, ?)`)
|
|
216
397
|
.run(eventId, gameId, postStory.t, adjudication.description ?? null, JSON.stringify(causes));
|
|
217
|
-
return { transitions, sets, eventId, postT: postStory.t };
|
|
398
|
+
return { transitions, sets, created, destroyed, eventId, postT: postStory.t };
|
|
218
399
|
}));
|
|
219
400
|
// 7. The outcome's constraint, built AFTER the writes landed and the
|
|
220
401
|
// transaction holding them has already committed -- reachable only from a
|
|
@@ -229,6 +410,8 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
229
410
|
result: adjudication.result ?? {},
|
|
230
411
|
transitions: applied.transitions,
|
|
231
412
|
sets: applied.sets,
|
|
413
|
+
created: applied.created,
|
|
414
|
+
destroyed: applied.destroyed,
|
|
232
415
|
constraint: postConstraint,
|
|
233
416
|
eventId: applied.eventId,
|
|
234
417
|
};
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "run-dmcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|