run-dmcp 0.4.0 → 0.6.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/mcp-server.d.ts +5 -1
- package/dist/mcp-server.js +5 -1
- package/dist/register/batch.js +11 -10
- package/dist/register/character.js +16 -16
- package/dist/register/core.js +128 -111
- package/dist/register/display.js +42 -41
- package/dist/register/resolve.js +4 -1
- package/dist/register/world.js +13 -13
- package/dist/rpg/register/batch.js +3 -3
- package/dist/schemas/index.js +91 -72
- package/dist/timeline/export.d.ts +13 -4
- package/dist/timeline/export.js +15 -9
- package/dist/timeline/irreversible.js +11 -6
- package/dist/timeline/narration.js +6 -4
- package/dist/timeline/projection.js +21 -4
- package/dist/timeline/provenance.d.ts +21 -28
- package/dist/timeline/provenance.js +30 -34
- package/dist/timeline/schema.js +29 -1
- package/dist/utils/validation.d.ts +31 -2
- package/dist/utils/validation.js +67 -10
- package/package.json +1 -1
package/dist/register/world.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import * as worldTools from "../tools/world.js";
|
|
3
3
|
import { imageGenSchema } from "../schemas/index.js";
|
|
4
|
-
import { LIMITS } from "../utils/validation.js";
|
|
4
|
+
import { LIMITS, validatedSchemas } from "../utils/validation.js";
|
|
5
5
|
import { ANNOTATIONS } from "../utils/tool-annotations.js";
|
|
6
6
|
export function registerWorldTools(server) {
|
|
7
7
|
server.registerTool("create_location", {
|
|
@@ -26,7 +26,7 @@ export function registerWorldTools(server) {
|
|
|
26
26
|
server.registerTool("get_location", {
|
|
27
27
|
description: "Get location details",
|
|
28
28
|
inputSchema: {
|
|
29
|
-
locationId:
|
|
29
|
+
locationId: validatedSchemas.id.describe("The location ID"),
|
|
30
30
|
},
|
|
31
31
|
annotations: ANNOTATIONS.READ_ONLY,
|
|
32
32
|
}, async ({ locationId }) => {
|
|
@@ -44,9 +44,9 @@ export function registerWorldTools(server) {
|
|
|
44
44
|
server.registerTool("update_location", {
|
|
45
45
|
description: "Update a location",
|
|
46
46
|
inputSchema: {
|
|
47
|
-
locationId:
|
|
48
|
-
name:
|
|
49
|
-
description:
|
|
47
|
+
locationId: validatedSchemas.id.describe("The location ID"),
|
|
48
|
+
name: validatedSchemas.token.optional().describe("New name"),
|
|
49
|
+
description: validatedSchemas.description.optional().describe("New description"),
|
|
50
50
|
properties: z.record(z.string(), z.unknown()).optional().describe("Property updates"),
|
|
51
51
|
imageGen: imageGenSchema.nullable().optional().describe("Image generation metadata (null to remove)"),
|
|
52
52
|
},
|
|
@@ -66,7 +66,7 @@ export function registerWorldTools(server) {
|
|
|
66
66
|
server.registerTool("list_locations", {
|
|
67
67
|
description: "List all locations in a game",
|
|
68
68
|
inputSchema: {
|
|
69
|
-
gameId:
|
|
69
|
+
gameId: validatedSchemas.id.describe("The game ID"),
|
|
70
70
|
},
|
|
71
71
|
annotations: ANNOTATIONS.READ_ONLY,
|
|
72
72
|
}, async ({ gameId }) => {
|
|
@@ -78,11 +78,11 @@ export function registerWorldTools(server) {
|
|
|
78
78
|
server.registerTool("connect_locations", {
|
|
79
79
|
description: "Create exits/paths between two locations. Call this whenever you describe how locations connect to each other - the player should be able to navigate based on database connections.",
|
|
80
80
|
inputSchema: {
|
|
81
|
-
fromLocationId:
|
|
82
|
-
toLocationId:
|
|
83
|
-
fromDirection:
|
|
84
|
-
toDirection:
|
|
85
|
-
description:
|
|
81
|
+
fromLocationId: validatedSchemas.id.describe("First location ID"),
|
|
82
|
+
toLocationId: validatedSchemas.id.describe("Second location ID"),
|
|
83
|
+
fromDirection: validatedSchemas.token.describe("Direction from first location (e.g., 'north', 'up', 'through the door')"),
|
|
84
|
+
toDirection: validatedSchemas.token.describe("Direction from second location back (e.g., 'south', 'down')"),
|
|
85
|
+
description: validatedSchemas.description.optional().describe("Description of the path"),
|
|
86
86
|
bidirectional: z.boolean().optional().describe("Create exit in both directions (default: true)"),
|
|
87
87
|
},
|
|
88
88
|
annotations: ANNOTATIONS.CREATE,
|
|
@@ -108,8 +108,8 @@ export function registerWorldTools(server) {
|
|
|
108
108
|
server.registerTool("get_location_by_name", {
|
|
109
109
|
description: "Look up a location by name within a game. Supports exact, partial, and fuzzy matching. Returns the best match or an error if no reasonable match found.",
|
|
110
110
|
inputSchema: {
|
|
111
|
-
gameId:
|
|
112
|
-
name:
|
|
111
|
+
gameId: validatedSchemas.id.describe("The game ID to search within"),
|
|
112
|
+
name: validatedSchemas.token.describe("Location name to search for (case-insensitive)"),
|
|
113
113
|
},
|
|
114
114
|
annotations: ANNOTATIONS.READ_ONLY,
|
|
115
115
|
}, async ({ gameId, name }) => {
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import * as characterTools from "../../tools/character.js";
|
|
3
3
|
import * as narrativeTools from "../../tools/narrative.js";
|
|
4
4
|
import * as combatTools from "../tools/combat.js";
|
|
5
|
-
import { LIMITS } from "../../utils/validation.js";
|
|
5
|
+
import { LIMITS, validatedSchemas } from "../../utils/validation.js";
|
|
6
6
|
import { ANNOTATIONS } from "../../utils/tool-annotations.js";
|
|
7
7
|
// The one multi-entity workflow tool that reaches into combat, split out of
|
|
8
8
|
// src/register/batch.ts (design §8, issue #17): everything else there --
|
|
@@ -17,8 +17,8 @@ export function registerRpgBatchTools(server) {
|
|
|
17
17
|
server.registerTool("setup_combat_encounter", {
|
|
18
18
|
description: "Complete combat setup in one call: creates enemy NPCs and starts combat with all participants (enemies + players at location). Returns the ready-to-play combat state.",
|
|
19
19
|
inputSchema: {
|
|
20
|
-
gameId:
|
|
21
|
-
locationId:
|
|
20
|
+
gameId: validatedSchemas.id.describe("The game ID"),
|
|
21
|
+
locationId: validatedSchemas.id.describe("Location where combat takes place"),
|
|
22
22
|
enemies: z
|
|
23
23
|
.array(z.object({
|
|
24
24
|
name: z.string().min(1).max(LIMITS.NAME_MAX).describe("Enemy name"),
|
package/dist/schemas/index.js
CHANGED
|
@@ -1,121 +1,140 @@
|
|
|
1
|
+
// Reusable Zod schemas, shared by the register modules.
|
|
2
|
+
//
|
|
3
|
+
// EVERY string here is bounded, and this file is where most of the engine's
|
|
4
|
+
// bounds actually live: `imageGenSchema` alone is accepted by seven tools, so
|
|
5
|
+
// one unbounded leaf in it was seven unbounded declarations in the published
|
|
6
|
+
// contract (issue #29 -- 698 of them, against 348 hand-written `.max()` calls
|
|
7
|
+
// spread across the register modules). Bounds belong in the shared schema, not
|
|
8
|
+
// at each call site, for the same reason the schema itself is shared.
|
|
9
|
+
//
|
|
10
|
+
// The tiers come from `validatedSchemas` (../utils/validation.ts) and nowhere
|
|
11
|
+
// else: `.token` for a short descriptor, `.description` for free text a model
|
|
12
|
+
// writes prose into, `.embeddedData` for inline base64. Reach for one of those
|
|
13
|
+
// rather than hand-rolling `z.string().max(...)` -- inlining the bound at each
|
|
14
|
+
// site is exactly how 698 of them came to be forgotten. Each access hands back
|
|
15
|
+
// a FRESH schema instance, deliberately; that file's comment says why, and it
|
|
16
|
+
// is the difference between publishing a declaration and publishing a `$ref`.
|
|
1
17
|
import { z } from "zod";
|
|
18
|
+
import { LIMITS, validatedSchemas } from "../utils/validation.js";
|
|
2
19
|
// Reusable Zod schemas for image generation
|
|
3
20
|
export const subjectDescriptionSchema = z.object({
|
|
4
21
|
type: z.enum(["character", "location", "item", "scene"]),
|
|
5
|
-
primaryDescription:
|
|
22
|
+
primaryDescription: validatedSchemas.description,
|
|
6
23
|
physicalTraits: z.object({
|
|
7
|
-
age:
|
|
8
|
-
gender:
|
|
9
|
-
bodyType:
|
|
10
|
-
height:
|
|
11
|
-
skinTone:
|
|
12
|
-
hairColor:
|
|
13
|
-
hairStyle:
|
|
14
|
-
eyeColor:
|
|
15
|
-
facialFeatures:
|
|
16
|
-
distinguishingMarks:
|
|
24
|
+
age: validatedSchemas.token.optional(),
|
|
25
|
+
gender: validatedSchemas.token.optional(),
|
|
26
|
+
bodyType: validatedSchemas.token.optional(),
|
|
27
|
+
height: validatedSchemas.token.optional(),
|
|
28
|
+
skinTone: validatedSchemas.token.optional(),
|
|
29
|
+
hairColor: validatedSchemas.token.optional(),
|
|
30
|
+
hairStyle: validatedSchemas.token.optional(),
|
|
31
|
+
eyeColor: validatedSchemas.token.optional(),
|
|
32
|
+
facialFeatures: validatedSchemas.description.optional(),
|
|
33
|
+
distinguishingMarks: validatedSchemas.stringArray.optional(),
|
|
17
34
|
}).optional(),
|
|
18
35
|
attire: z.object({
|
|
19
|
-
description:
|
|
20
|
-
colors:
|
|
21
|
-
materials:
|
|
22
|
-
accessories:
|
|
36
|
+
description: validatedSchemas.description,
|
|
37
|
+
colors: validatedSchemas.stringArray.optional(),
|
|
38
|
+
materials: validatedSchemas.stringArray.optional(),
|
|
39
|
+
accessories: validatedSchemas.stringArray.optional(),
|
|
23
40
|
}).optional(),
|
|
24
41
|
environment: z.object({
|
|
25
|
-
setting:
|
|
26
|
-
timeOfDay:
|
|
27
|
-
weather:
|
|
28
|
-
lighting:
|
|
29
|
-
architecture:
|
|
30
|
-
vegetation:
|
|
31
|
-
notableFeatures:
|
|
42
|
+
setting: validatedSchemas.description,
|
|
43
|
+
timeOfDay: validatedSchemas.token.optional(),
|
|
44
|
+
weather: validatedSchemas.token.optional(),
|
|
45
|
+
lighting: validatedSchemas.token.optional(),
|
|
46
|
+
architecture: validatedSchemas.token.optional(),
|
|
47
|
+
vegetation: validatedSchemas.token.optional(),
|
|
48
|
+
notableFeatures: validatedSchemas.stringArray.optional(),
|
|
32
49
|
}).optional(),
|
|
33
50
|
objectDetails: z.object({
|
|
34
|
-
material:
|
|
35
|
-
size:
|
|
36
|
-
condition:
|
|
37
|
-
glowOrEffects:
|
|
51
|
+
material: validatedSchemas.token.optional(),
|
|
52
|
+
size: validatedSchemas.token.optional(),
|
|
53
|
+
condition: validatedSchemas.token.optional(),
|
|
54
|
+
glowOrEffects: validatedSchemas.description.optional(),
|
|
38
55
|
}).optional(),
|
|
39
|
-
pose:
|
|
40
|
-
expression:
|
|
41
|
-
action:
|
|
56
|
+
pose: validatedSchemas.description.optional(),
|
|
57
|
+
expression: validatedSchemas.description.optional(),
|
|
58
|
+
action: validatedSchemas.description.optional(),
|
|
42
59
|
});
|
|
43
60
|
export const styleDescriptionSchema = z.object({
|
|
44
|
-
artisticStyle:
|
|
45
|
-
genre:
|
|
46
|
-
mood:
|
|
47
|
-
colorScheme:
|
|
48
|
-
influences:
|
|
49
|
-
qualityTags:
|
|
50
|
-
negativeElements:
|
|
61
|
+
artisticStyle: validatedSchemas.description,
|
|
62
|
+
genre: validatedSchemas.token,
|
|
63
|
+
mood: validatedSchemas.token,
|
|
64
|
+
colorScheme: validatedSchemas.token.optional(),
|
|
65
|
+
influences: validatedSchemas.stringArray.optional(),
|
|
66
|
+
qualityTags: validatedSchemas.stringArray.optional(),
|
|
67
|
+
negativeElements: validatedSchemas.stringArray.optional(),
|
|
51
68
|
});
|
|
52
69
|
export const compositionDescriptionSchema = z.object({
|
|
53
|
-
framing:
|
|
54
|
-
cameraAngle:
|
|
55
|
-
aspectRatio:
|
|
56
|
-
focusPoint:
|
|
57
|
-
background:
|
|
58
|
-
depth:
|
|
70
|
+
framing: validatedSchemas.description,
|
|
71
|
+
cameraAngle: validatedSchemas.token.optional(),
|
|
72
|
+
aspectRatio: validatedSchemas.token.optional(),
|
|
73
|
+
focusPoint: validatedSchemas.description.optional(),
|
|
74
|
+
background: validatedSchemas.description.optional(),
|
|
75
|
+
depth: validatedSchemas.token.optional(),
|
|
59
76
|
});
|
|
60
77
|
export const comfyUIPromptSchema = z.object({
|
|
61
|
-
positive:
|
|
62
|
-
negative:
|
|
63
|
-
checkpoint:
|
|
78
|
+
positive: validatedSchemas.description,
|
|
79
|
+
negative: validatedSchemas.description,
|
|
80
|
+
checkpoint: validatedSchemas.token.optional(),
|
|
64
81
|
loras: z.array(z.object({
|
|
65
|
-
name:
|
|
82
|
+
name: validatedSchemas.name,
|
|
66
83
|
weight: z.number(),
|
|
67
|
-
})).optional(),
|
|
84
|
+
})).max(LIMITS.ARRAY_MAX).optional(),
|
|
68
85
|
samplerSettings: z.object({
|
|
69
|
-
sampler:
|
|
70
|
-
scheduler:
|
|
86
|
+
sampler: validatedSchemas.token.optional(),
|
|
87
|
+
scheduler: validatedSchemas.token.optional(),
|
|
71
88
|
steps: z.number().optional(),
|
|
72
89
|
cfg: z.number().optional(),
|
|
73
90
|
}).optional(),
|
|
74
91
|
});
|
|
75
92
|
export const generatedImageSchema = z.object({
|
|
76
|
-
id:
|
|
77
|
-
tool:
|
|
78
|
-
prompt:
|
|
79
|
-
|
|
80
|
-
base64
|
|
93
|
+
id: validatedSchemas.id,
|
|
94
|
+
tool: validatedSchemas.token,
|
|
95
|
+
prompt: validatedSchemas.description,
|
|
96
|
+
// A URL, including a `data:` one. Over DESCRIPTION_MAX it is not an address,
|
|
97
|
+
// it is a payload, and `base64` below is the field for those.
|
|
98
|
+
url: validatedSchemas.description.optional(),
|
|
99
|
+
base64: validatedSchemas.embeddedData.optional(),
|
|
81
100
|
seed: z.number().optional(),
|
|
82
|
-
timestamp:
|
|
83
|
-
metadata: z.record(
|
|
101
|
+
timestamp: validatedSchemas.token,
|
|
102
|
+
metadata: z.record(validatedSchemas.token, z.unknown()).optional(),
|
|
84
103
|
});
|
|
85
104
|
export const imageGenSchema = z.object({
|
|
86
105
|
subject: subjectDescriptionSchema,
|
|
87
106
|
style: styleDescriptionSchema,
|
|
88
107
|
composition: compositionDescriptionSchema,
|
|
89
108
|
prompts: z.object({
|
|
90
|
-
generic:
|
|
91
|
-
sdxl:
|
|
92
|
-
dalle:
|
|
93
|
-
midjourney:
|
|
94
|
-
flux:
|
|
109
|
+
generic: validatedSchemas.description.optional(),
|
|
110
|
+
sdxl: validatedSchemas.description.optional(),
|
|
111
|
+
dalle: validatedSchemas.description.optional(),
|
|
112
|
+
midjourney: validatedSchemas.description.optional(),
|
|
113
|
+
flux: validatedSchemas.description.optional(),
|
|
95
114
|
comfyui: comfyUIPromptSchema.optional(),
|
|
96
115
|
}).optional(),
|
|
97
|
-
generations: z.array(generatedImageSchema).optional(),
|
|
116
|
+
generations: z.array(generatedImageSchema).max(LIMITS.ARRAY_MAX).optional(),
|
|
98
117
|
consistency: z.object({
|
|
99
|
-
characterRef:
|
|
100
|
-
seedImage:
|
|
101
|
-
colorPalette:
|
|
102
|
-
styleRef:
|
|
118
|
+
characterRef: validatedSchemas.description.optional(),
|
|
119
|
+
seedImage: validatedSchemas.description.optional(),
|
|
120
|
+
colorPalette: validatedSchemas.stringArray.optional(),
|
|
121
|
+
styleRef: validatedSchemas.description.optional(),
|
|
103
122
|
}).optional(),
|
|
104
123
|
}).describe("Image generation metadata for visual representation");
|
|
105
124
|
// Voice schema for characters
|
|
106
125
|
export const voiceSchema = z.object({
|
|
107
126
|
pitch: z.enum(["very_low", "low", "medium", "high", "very_high"]).describe("Voice pitch"),
|
|
108
127
|
speed: z.enum(["very_slow", "slow", "medium", "fast", "very_fast"]).describe("Speaking speed"),
|
|
109
|
-
tone:
|
|
110
|
-
accent:
|
|
111
|
-
quirks:
|
|
112
|
-
description:
|
|
128
|
+
tone: validatedSchemas.token.describe("Voice tone (e.g., 'gravelly', 'melodic', 'nasal', 'breathy')"),
|
|
129
|
+
accent: validatedSchemas.token.optional().describe("Accent (e.g., 'Scottish', 'French', 'Brooklyn')"),
|
|
130
|
+
quirks: validatedSchemas.stringArray.optional().describe("Speech quirks (e.g., 'stutters when nervous')"),
|
|
131
|
+
description: validatedSchemas.description.optional().describe("Free-form voice description for more nuance"),
|
|
113
132
|
});
|
|
114
133
|
// Random table entry schema
|
|
115
134
|
export const tableEntrySchema = z.object({
|
|
116
135
|
minRoll: z.number().optional().describe("Minimum roll to get this result (for ranged tables)"),
|
|
117
136
|
maxRoll: z.number().optional().describe("Maximum roll for this result"),
|
|
118
137
|
weight: z.number().optional().describe("Weight for weighted random selection"),
|
|
119
|
-
result:
|
|
120
|
-
effects: z.record(z.unknown()).optional().describe("Optional structured effects"),
|
|
138
|
+
result: validatedSchemas.description.describe("The result text"),
|
|
139
|
+
effects: z.record(validatedSchemas.token, z.unknown()).optional().describe("Optional structured effects"),
|
|
121
140
|
});
|
|
@@ -80,6 +80,13 @@ export interface TimelineExportFact {
|
|
|
80
80
|
validFromT: T;
|
|
81
81
|
validToT: T | null;
|
|
82
82
|
irreversible: boolean;
|
|
83
|
+
/** The one hop of causality (design §5.2c, issue #30) -- the event that
|
|
84
|
+
* opened this fact, or null when none is recorded. Optional, not just
|
|
85
|
+
* nullable: a v1 artifact written before issue #30 landed carries no such
|
|
86
|
+
* field at all, and `importTimeline` treats an absent field and an
|
|
87
|
+
* explicit `null` identically -- there was never a recorded hop for
|
|
88
|
+
* either, so there is nothing to guess and no reason to refuse. */
|
|
89
|
+
openedByEventId?: string | null;
|
|
83
90
|
}
|
|
84
91
|
export interface TimelineExportEvent {
|
|
85
92
|
id: string;
|
|
@@ -158,10 +165,12 @@ export declare function exportTimeline(gameId: string): TimelineExport;
|
|
|
158
165
|
* artifact shape above) is stamped fresh at import time; it was never
|
|
159
166
|
* exported and never round-trips.
|
|
160
167
|
*
|
|
161
|
-
* `entities` are inserted before `facts` because `facts.entity_id`
|
|
162
|
-
* real foreign key
|
|
163
|
-
*
|
|
164
|
-
*
|
|
168
|
+
* `entities` are inserted before `events` and `facts` because `facts.entity_id`
|
|
169
|
+
* is a real foreign key; `events` are inserted before `facts` (issue #30) for
|
|
170
|
+
* the identical reason now that `facts.opened_by_event_id` is one too --
|
|
171
|
+
* this database runs with `PRAGMA foreign_keys = ON` (`../db/connection.ts`),
|
|
172
|
+
* so inserting out of order would fail loudly rather than silently, but
|
|
173
|
+
* there is no reason to invite the failure.
|
|
165
174
|
*/
|
|
166
175
|
export declare function importTimeline(artifact: TimelineExport): TimelineImportResult;
|
|
167
176
|
/**
|
package/dist/timeline/export.js
CHANGED
|
@@ -103,7 +103,7 @@ function readTimeline(gameId) {
|
|
|
103
103
|
// scoped by joining back to entities, exactly the way replay.ts scopes
|
|
104
104
|
// "what was true of them" to "who was alive."
|
|
105
105
|
const factRows = db
|
|
106
|
-
.prepare(`SELECT f.id, f.entity_id, f.key, f.value, f.valid_from_t, f.valid_to_t, f.irreversible
|
|
106
|
+
.prepare(`SELECT f.id, f.entity_id, f.key, f.value, f.valid_from_t, f.valid_to_t, f.irreversible, f.opened_by_event_id
|
|
107
107
|
FROM facts f
|
|
108
108
|
JOIN entities e ON e.id = f.entity_id
|
|
109
109
|
WHERE e.game_id = ?
|
|
@@ -137,6 +137,7 @@ function readTimeline(gameId) {
|
|
|
137
137
|
validFromT: row.valid_from_t,
|
|
138
138
|
validToT: row.valid_to_t,
|
|
139
139
|
irreversible: Boolean(row.irreversible),
|
|
140
|
+
openedByEventId: row.opened_by_event_id,
|
|
140
141
|
})),
|
|
141
142
|
events: eventRows.map((row) => ({
|
|
142
143
|
id: row.id,
|
|
@@ -266,10 +267,12 @@ function assertTargetIsEmpty(gameId) {
|
|
|
266
267
|
* artifact shape above) is stamped fresh at import time; it was never
|
|
267
268
|
* exported and never round-trips.
|
|
268
269
|
*
|
|
269
|
-
* `entities` are inserted before `facts` because `facts.entity_id`
|
|
270
|
-
* real foreign key
|
|
271
|
-
*
|
|
272
|
-
*
|
|
270
|
+
* `entities` are inserted before `events` and `facts` because `facts.entity_id`
|
|
271
|
+
* is a real foreign key; `events` are inserted before `facts` (issue #30) for
|
|
272
|
+
* the identical reason now that `facts.opened_by_event_id` is one too --
|
|
273
|
+
* this database runs with `PRAGMA foreign_keys = ON` (`../db/connection.ts`),
|
|
274
|
+
* so inserting out of order would fail loudly rather than silently, but
|
|
275
|
+
* there is no reason to invite the failure.
|
|
273
276
|
*/
|
|
274
277
|
export function importTimeline(artifact) {
|
|
275
278
|
assertValidArtifactShape(artifact);
|
|
@@ -289,14 +292,17 @@ export function importTimeline(artifact) {
|
|
|
289
292
|
for (const entity of artifact.entities) {
|
|
290
293
|
insertEntity.run(entity.id, entity.gameId, entity.kind, entity.name, entity.createdAtT, entity.destroyedAtT);
|
|
291
294
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
295
|
+
// Events before facts (issue #30): `facts.opened_by_event_id` is a real
|
|
296
|
+
// foreign key into `events` now, on top of `facts.entity_id`'s existing
|
|
297
|
+
// one into `entities` -- see this function's doc comment.
|
|
296
298
|
const insertEvent = db.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, ?, ?, ?)`);
|
|
297
299
|
for (const event of artifact.events) {
|
|
298
300
|
insertEvent.run(event.id, event.gameId, event.atT, event.kind, event.description, event.causes);
|
|
299
301
|
}
|
|
302
|
+
const insertFact = db.prepare(`INSERT INTO facts (id, entity_id, key, value, valid_from_t, valid_to_t, irreversible, opened_by_event_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
303
|
+
for (const fact of artifact.facts) {
|
|
304
|
+
insertFact.run(fact.id, fact.entityId, fact.key, fact.value, fact.validFromT, fact.validToT, fact.irreversible ? 1 : 0, fact.openedByEventId ?? null);
|
|
305
|
+
}
|
|
300
306
|
return {
|
|
301
307
|
gameId: artifact.gameId,
|
|
302
308
|
entities: artifact.entities.length,
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { getDatabase } from "../db/connection.js";
|
|
2
2
|
import { assertT } from "./t.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/** `gameId` is no longer used to look up the hop (issue #30: it is a stored
|
|
4
|
+
* column on the fact row itself, never derived), but stays a parameter so
|
|
5
|
+
* every call site here keeps naming the game it is working in -- and so a
|
|
6
|
+
* future caller that genuinely needs to re-scope by game has somewhere to
|
|
7
|
+
* put it without changing every signature in this file again. */
|
|
8
|
+
function toIrreversibleFact(row, _gameId) {
|
|
5
9
|
assertT(row.valid_from_t);
|
|
6
10
|
return {
|
|
7
11
|
factId: row.id,
|
|
@@ -9,7 +13,7 @@ function toIrreversibleFact(row, gameId) {
|
|
|
9
13
|
key: row.key,
|
|
10
14
|
value: row.value,
|
|
11
15
|
validFromT: row.valid_from_t,
|
|
12
|
-
openedByEventId:
|
|
16
|
+
openedByEventId: row.opened_by_event_id,
|
|
13
17
|
};
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
@@ -44,7 +48,7 @@ export function declareIrreversible(params) {
|
|
|
44
48
|
// order. Same tiebreak as irreversibleFactFor below, so the two functions
|
|
45
49
|
// can never disagree about which row they mean.
|
|
46
50
|
const open = db
|
|
47
|
-
.prepare(`SELECT id, entity_id, key, value, valid_from_t FROM facts
|
|
51
|
+
.prepare(`SELECT id, entity_id, key, value, valid_from_t, opened_by_event_id FROM facts
|
|
48
52
|
WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL
|
|
49
53
|
ORDER BY valid_from_t DESC, id DESC
|
|
50
54
|
LIMIT 1`)
|
|
@@ -73,7 +77,7 @@ export function irreversibleFactFor(entityId, key) {
|
|
|
73
77
|
if (!entity)
|
|
74
78
|
return null;
|
|
75
79
|
const row = db
|
|
76
|
-
.prepare(`SELECT id, entity_id, key, value, valid_from_t FROM facts
|
|
80
|
+
.prepare(`SELECT id, entity_id, key, value, valid_from_t, opened_by_event_id FROM facts
|
|
77
81
|
WHERE entity_id = ? AND key = ? AND irreversible = 1
|
|
78
82
|
ORDER BY valid_from_t DESC, id DESC
|
|
79
83
|
LIMIT 1`)
|
|
@@ -92,7 +96,8 @@ export function irreversibleFactFor(entityId, key) {
|
|
|
92
96
|
export function listIrreversibleFacts(params) {
|
|
93
97
|
const db = getDatabase();
|
|
94
98
|
let query = `
|
|
95
|
-
SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value, f.valid_from_t AS valid_from_t
|
|
99
|
+
SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value, f.valid_from_t AS valid_from_t,
|
|
100
|
+
f.opened_by_event_id AS opened_by_event_id
|
|
96
101
|
FROM facts f
|
|
97
102
|
JOIN entities e ON e.id = f.entity_id
|
|
98
103
|
WHERE e.game_id = ? AND f.irreversible = 1
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { getDatabase } from "../db/connection.js";
|
|
2
2
|
import { assertT, compareT } from "./t.js";
|
|
3
|
-
import { openingEventId } from "./provenance.js";
|
|
4
3
|
/**
|
|
5
4
|
* The outbound half of authority (design §5.2b/§5.2c, GitHub issues #11 and
|
|
6
5
|
* #12): "Here is what is true; depict it, do not argue with it." One
|
|
@@ -47,7 +46,10 @@ import { openingEventId } from "./provenance.js";
|
|
|
47
46
|
* opened it" in this codebase, not a second copy grown for this module.
|
|
48
47
|
*/
|
|
49
48
|
export const NARRATION_CONSTRAINT_FORMAT_VERSION = 1;
|
|
50
|
-
|
|
49
|
+
/** `gameId` is unused now that the hop is a stored column (issue #30) rather
|
|
50
|
+
* than derived per-row, but the parameter stays -- see irreversible.ts's
|
|
51
|
+
* identical note on `toIrreversibleFact`. */
|
|
52
|
+
function toConstraintFact(row, _gameId) {
|
|
51
53
|
assertT(row.valid_from_t);
|
|
52
54
|
if (row.valid_to_t !== null)
|
|
53
55
|
assertT(row.valid_to_t);
|
|
@@ -61,7 +63,7 @@ function toConstraintFact(row, gameId) {
|
|
|
61
63
|
irreversible: Boolean(row.irreversible),
|
|
62
64
|
entityKind: row.entity_kind,
|
|
63
65
|
entityName: row.entity_name,
|
|
64
|
-
openedByEventId:
|
|
66
|
+
openedByEventId: row.opened_by_event_id,
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
/**
|
|
@@ -125,7 +127,7 @@ export function narrationConstraintAt(params) {
|
|
|
125
127
|
const rows = db
|
|
126
128
|
.prepare(`SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value,
|
|
127
129
|
f.valid_from_t AS valid_from_t, f.valid_to_t AS valid_to_t, f.irreversible AS irreversible,
|
|
128
|
-
e.kind AS entity_kind, e.name AS entity_name
|
|
130
|
+
e.kind AS entity_kind, e.name AS entity_name, f.opened_by_event_id AS opened_by_event_id
|
|
129
131
|
FROM facts f
|
|
130
132
|
JOIN entities e ON e.id = f.entity_id
|
|
131
133
|
WHERE e.game_id = ?
|
|
@@ -41,10 +41,16 @@ function tExpr(gidExpr) {
|
|
|
41
41
|
/**
|
|
42
42
|
* `AFTER INSERT`: ensure the game's clock row exists, advance it, insert the
|
|
43
43
|
* entity, insert one fact per non-NULL column, insert a `<kind>.created`
|
|
44
|
-
* event
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* event, then (issue #30) stamp every fact this firing just opened with that
|
|
45
|
+
* event's id via `last_insert_rowid()` -- the one hop of causality (design
|
|
46
|
+
* §5.2c), recorded rather than derived later. The `opened_by_event_id IS
|
|
47
|
+
* NULL` guard is what keeps a repeated `t` on a non-`sequence` axis correct:
|
|
48
|
+
* it restricts the stamp to facts THIS firing opened, never one a previous
|
|
49
|
+
* firing at the same `t` already stamped. Column names are interpolated
|
|
50
|
+
* directly (never bound as parameters) because they come from this
|
|
51
|
+
* codebase's own `pragma_table_info`, never from anything a caller supplied
|
|
52
|
+
* -- there is no user input anywhere in this SQL (trigger-sql-skeleton
|
|
53
|
+
* trap #6).
|
|
48
54
|
*/
|
|
49
55
|
function buildInsertTrigger(row, cols) {
|
|
50
56
|
const gid = `NEW.${row.gameIdColumn}`;
|
|
@@ -72,6 +78,9 @@ ${factInserts}
|
|
|
72
78
|
INSERT INTO events (id, game_id, at_t, kind, description, causes)
|
|
73
79
|
VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.created', '${row.kind} created',
|
|
74
80
|
json_object('table', '${row.table}', 'row_id', NEW.id));
|
|
81
|
+
|
|
82
|
+
UPDATE facts SET opened_by_event_id = (SELECT id FROM events WHERE rowid = last_insert_rowid())
|
|
83
|
+
WHERE entity_id = NEW.id AND valid_from_t = ${t} AND opened_by_event_id IS NULL;
|
|
75
84
|
END;
|
|
76
85
|
`;
|
|
77
86
|
}
|
|
@@ -84,6 +93,11 @@ ${factInserts}
|
|
|
84
93
|
* column: reversed, the open's subquery would still see the value the
|
|
85
94
|
* close was about to retire and write nothing (trap #3). The five-case
|
|
86
95
|
* table this produces is walked by the test suite, not re-derived here.
|
|
96
|
+
* After the `<kind>.updated` event lands, every fact this firing just opened
|
|
97
|
+
* (across every column touched) is stamped with that event's id -- see
|
|
98
|
+
* `buildInsertTrigger`'s doc comment for why the `opened_by_event_id IS
|
|
99
|
+
* NULL` guard is what keeps this correct when a non-`sequence` axis repeats
|
|
100
|
+
* a `t` across firings (issue #30).
|
|
87
101
|
*/
|
|
88
102
|
function buildUpdateTrigger(row, cols) {
|
|
89
103
|
const gid = `NEW.${row.gameIdColumn}`;
|
|
@@ -111,6 +125,9 @@ ${perColumn}
|
|
|
111
125
|
INSERT INTO events (id, game_id, at_t, kind, description, causes)
|
|
112
126
|
VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.updated', '${row.kind} updated',
|
|
113
127
|
json_object('table', '${row.table}', 'row_id', NEW.id));
|
|
128
|
+
|
|
129
|
+
UPDATE facts SET opened_by_event_id = (SELECT id FROM events WHERE rowid = last_insert_rowid())
|
|
130
|
+
WHERE entity_id = NEW.id AND valid_from_t = ${t} AND opened_by_event_id IS NULL;
|
|
114
131
|
END;
|
|
115
132
|
`;
|
|
116
133
|
}
|
|
@@ -32,35 +32,28 @@ export interface FactProvenance {
|
|
|
32
32
|
openedByEventId: string | null;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
* The one hop of causality (design §5.2c)
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
35
|
+
* The one hop of causality (design §5.2c), READ rather than derived (issue
|
|
36
|
+
* #30). `facts.opened_by_event_id` is stamped by the projection triggers'
|
|
37
|
+
* `_ai`/`_au` bodies (projection.ts) at the moment a fact opens, in the same
|
|
38
|
+
* firing, via `last_insert_rowid()` against the event they just inserted --
|
|
39
|
+
* so this is a direct column lookup now, not a search over `events` keyed by
|
|
40
|
+
* `(at_t, causes.row_id)` with a random-hex tiebreak among rows sharing a
|
|
41
|
+
* `t`. That derivation is gone, along with the failure modes it carried: it
|
|
42
|
+
* could return null for an event whose `causes` was not valid JSON, and it
|
|
43
|
+
* broke ties among same-`t` events arbitrarily.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* query, not to the offending row, so a single bad row anywhere in this
|
|
50
|
-
* game's events would make every function that calls this throw, including
|
|
51
|
-
* ones that have nothing to do with that event. That is reachable in
|
|
52
|
-
* practice: timeline import (export.ts) carries `causes` through verbatim
|
|
53
|
-
* by design, because an importer that rewrote a recorded cause would be
|
|
54
|
-
* inventing history. A hop of provenance must never be able to fail the
|
|
55
|
-
* write it annotates, so a row we cannot read simply does not match.
|
|
56
|
-
* Written as CASE rather than `json_valid(causes) AND json_extract(...)`
|
|
57
|
-
* because SQLite does not guarantee the evaluation order of AND operands --
|
|
58
|
-
* the planner may reorder them, and then the guard is decoration that
|
|
59
|
-
* happens to work today.
|
|
45
|
+
* Both internal callers of this shape (`irreversible.ts`, `narration.ts`)
|
|
46
|
+
* no longer call this function at all -- each already queries its own fact
|
|
47
|
+
* row and now selects `opened_by_event_id` directly as part of that same
|
|
48
|
+
* query, which is strictly cheaper than a second round trip through here.
|
|
60
49
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
50
|
+
* This function is kept, and re-pointed at the stored column rather than
|
|
51
|
+
* removed, because it is part of this package's published library surface
|
|
52
|
+
* (`src/index.ts` re-exports it) -- issue #30 did not ask for a public API
|
|
53
|
+
* removal, and removing an exported function silently would be exactly the
|
|
54
|
+
* kind of undocumented break root CLAUDE.md's "what we declare is what we
|
|
55
|
+
* mean" section warns against. A caller that already holds
|
|
56
|
+
* `(gameId, entityId, validFromT)` rather than a fact id can still use it;
|
|
57
|
+
* it now does less work to answer the same question.
|
|
65
58
|
*/
|
|
66
59
|
export declare function openingEventId(gameId: string, entityId: string, validFromT: number): string | null;
|