run-dmcp 0.2.0 → 0.3.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/README.md CHANGED
@@ -12,23 +12,26 @@ holds full adjudication discretion over a world that only ever stores *now*.
12
12
 
13
13
  ## Status
14
14
 
15
- **0.1.1 — the foundation, not the thesis.** What ships today is the predecessor's engine plus four
16
- pieces of generic mechanism that were built for it and offered back to it:
15
+ **0.3.0.** The base is the predecessor's engine plus four pieces of generic mechanism that were built
16
+ for it and offered back to it:
17
17
 
18
18
  - **Atomicity** — `withTransaction()` wired into the multi-write operations that were running
19
19
  non-atomically.
20
20
  - **Declarative constraints** — resources can be declared `bounded` or `monotonic`, and the store
21
- enforces it rather than trusting every caller. (`resolve_only` — every direct write rejected, so a
22
- value can move only through an adjudicating call — exists in a downstream consumer and has not been
23
- extracted here yet. It arrives with the resolve protocol; see [docs/DESIGN.md](docs/DESIGN.md) §5.2a.)
21
+ enforces it rather than trusting every caller. `resolve_only` — every direct write rejected, so a
22
+ value can move only through an adjudicating call — joined them alongside the resolve protocol, and
23
+ is enforced by a trigger rather than by a checker anyone has to remember to call; see
24
+ [docs/DESIGN.md](docs/DESIGN.md) §5.2a.
24
25
  - **Conserved resource sets** — a set of resources can be declared conserved, with an atomic transfer
25
26
  that never silently clamps.
26
27
  - **On-expiry consequences** — scheduled events and timers can carry a consequence that actually
27
28
  lands when they expire, rather than expiring into nothing.
28
29
 
29
- 0.1.1 adds the packaging half of that: importing the library starts nothing, the database resolves to
30
- the consuming application rather than into `node_modules`, and a consumer can bring up its own tables
31
- through the migration hook below.
30
+ 0.1.1 added the packaging half of that: importing the library starts nothing, the database resolves
31
+ to the consuming application rather than into `node_modules`, and a consumer can bring up its own
32
+ tables through the migration hook below. 0.3.0 adds the consuming half — the entity/property spine is
33
+ exported as library functions, so a consumer that deletes its own vendored copy has something to
34
+ import rather than a server to make tool calls into.
32
35
 
33
36
  The timeline that gives this project its reason to exist — interval-versioned facts, `replay(t)`,
34
37
  irreversibility, timeline export, `changes_within` — is built, and every write of world state appends
@@ -93,6 +96,27 @@ The database lands in the consuming application: `DMCP_DB_PATH` if set, else an
93
96
  `~/.local/share/dmcp`, else `./data/games.db` relative to the working directory. Never inside the
94
97
  installed package.
95
98
 
99
+ **The spine is importable, not only callable over a transport.** Games, locations, characters,
100
+ factions, relationships, resources, secrets, items, notes, tags, time and timers are library
101
+ functions first and MCP tools second, so a consumer reads and writes its own world directly and only
102
+ serves the tools it actually wants an LLM to reach. That includes the constrained-write choke point:
103
+ a number that moves through `updateResourceValue` is checked against its declared constraints and its
104
+ history is `facts`, queryable with `valueHistory`.
105
+
106
+ ```ts
107
+ import { createGame, createResource, updateResourceValue, valueHistory } from "run-dmcp";
108
+
109
+ const game = createGame({ name: "The Granary", setting: "…", style: "…" });
110
+ const grain = createResource({ gameId: game.id, ownerType: "game", name: "grain", value: 100 });
111
+
112
+ updateResourceValue({ resourceId: grain.id, mode: "delta", value: -30, reason: "the winter ration" });
113
+ valueHistory(grain.id, "value"); // → the transitions, with the reason each one carried
114
+ ```
115
+
116
+ The annotations, input limits, error envelope and logger the engine's own tools are built from are
117
+ exported too (`ANNOTATIONS`, `LIMITS`, `errors`, `createLogger`), so a consumer's own tools can
118
+ refuse and bound the way these do instead of re-implementing it.
119
+
96
120
  ## Provenance
97
121
 
98
122
  This continues [DMCP](https://github.com/shawnrushefsky/dmcp) by Shawn Rushefsky (MIT), whose last
package/dist/db/schema.js CHANGED
@@ -3,6 +3,49 @@ import { createLogger } from "../utils/logger.js";
3
3
  import { initializeTimelineSchema } from "../timeline/schema.js";
4
4
  import { initializeAdjudicationSchema } from "../timeline/adjudication.js";
5
5
  const log = createLogger("schema");
6
+ /**
7
+ * Run one whole-table CHECK rebuild with foreign-key enforcement suspended,
8
+ * and verify afterwards that nothing was left dangling.
9
+ *
10
+ * SQLite cannot ALTER a CHECK, so widening one means rebuilding the table:
11
+ * copy the rows aside, DROP, CREATE with the new CHECK, copy back. Two
12
+ * migrations in this file do that, and both need the same two guarantees.
13
+ *
14
+ * ENFORCEMENT MUST BE OFF ACROSS THE DROP. With it on, `DROP TABLE` performs
15
+ * an implicit per-row DELETE first, precisely so that any ON DELETE action
16
+ * declared against that table fires as though each row had genuinely been
17
+ * deleted -- which for both of these tables means cascade-emptying the tables
18
+ * that reference them. Suspending it is what makes the drop a schema
19
+ * operation instead of a silent mass deletion. It is toggled out here rather
20
+ * than inside `withTransaction`, because `PRAGMA foreign_keys` is a
21
+ * documented no-op while a transaction is pending.
22
+ *
23
+ * AND IT MUST GO BACK ON WHEN THE REBUILD THROWS, which is why the restore is
24
+ * in a `finally` and why this is a shared function rather than two copies.
25
+ * Both call sites previously restored it on the success path only.
26
+ * `getDatabase()` caches one connection at module scope, so a rebuild that
27
+ * threw handed the rest of the process a handle with foreign keys still
28
+ * disabled -- and a disabled foreign key does not announce itself. It means
29
+ * every ON DELETE CASCADE in this schema quietly stops working: deleting a
30
+ * game orphans its characters, resources, locations and secrets instead of
31
+ * taking them with it, and nothing errors. The scenario is not exotic -- a
32
+ * staging table left behind by a rebuild that died partway is exactly what
33
+ * makes the next startup's `CREATE TABLE ..._staging` throw. Covered by
34
+ * `src/db/__tests__/foreignKeysRestored.test.ts`.
35
+ */
36
+ function rebuildWithForeignKeysSuspended(db, label, rebuild) {
37
+ db.pragma("foreign_keys = OFF");
38
+ try {
39
+ withTransaction(rebuild);
40
+ const violations = db.pragma("foreign_key_check");
41
+ if (violations.length > 0) {
42
+ throw new Error(`${label} CHECK migration left dangling foreign keys: ` + JSON.stringify(violations));
43
+ }
44
+ }
45
+ finally {
46
+ db.pragma("foreign_keys = ON");
47
+ }
48
+ }
6
49
  export function initializeSchema(options) {
7
50
  const db = getDatabase();
8
51
  // ============================================================================
@@ -203,12 +246,22 @@ export function initializeSchema(options) {
203
246
  )
204
247
  `);
205
248
  // Resources table (for tracking currency, reputation, counters, etc.)
206
- db.exec(`
249
+ //
250
+ // `RESOURCES_DDL` is a shared constant, not a literal inlined here, for the
251
+ // same reason `RESOURCE_CONSTRAINTS_DDL` further down is one: the CHECK-
252
+ // rebuild migration immediately below needs this exact text executed in
253
+ // TWO places -- here, for a fresh database, and again inside the rebuild,
254
+ // for a database that still carries the OLD two-member CHECK. Sharing one
255
+ // JS string is what makes "a fresh database and a migrated database
256
+ // converge on byte-identical `sqlite_master.sql` for this table" true BY
257
+ // CONSTRUCTION rather than by two hand-written literals happening to agree
258
+ // today.
259
+ const RESOURCES_DDL = `
207
260
  CREATE TABLE IF NOT EXISTS resources (
208
261
  id TEXT PRIMARY KEY,
209
262
  game_id TEXT NOT NULL,
210
263
  owner_id TEXT,
211
- owner_type TEXT NOT NULL CHECK (owner_type IN ('game', 'character')),
264
+ owner_type TEXT NOT NULL CHECK (owner_type IN ('game', 'character', 'faction', 'location')),
212
265
  name TEXT NOT NULL,
213
266
  description TEXT,
214
267
  category TEXT,
@@ -218,7 +271,115 @@ export function initializeSchema(options) {
218
271
  created_at TEXT NOT NULL,
219
272
  FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
220
273
  )
221
- `);
274
+ `;
275
+ db.exec(RESOURCES_DDL);
276
+ // Migration: widen `resources.owner_type` to admit 'faction' and
277
+ // 'location' alongside 'game' and 'character'. A resource owned by a
278
+ // faction or a location is generic mechanism -- the engine already has
279
+ // `factions` and `locations` tables; this just lets `resources` point at
280
+ // either the way it already points at a `game` or a `character` -- so it
281
+ // belongs here rather than behind a downstream application's own migration.
282
+ //
283
+ // SQLite cannot ALTER a CHECK constraint, so a database that already has
284
+ // `resources` rows under the OLD two-member CHECK needs the same full-
285
+ // table-rebuild recipe the `resource_constraints` CHECK-widening migration
286
+ // uses further down this function (see that block's comment for the
287
+ // detailed reasoning this one leans on): build a replacement table with
288
+ // the new CHECK, copy every row across, drop the old table, put the
289
+ // replacement in its place.
290
+ //
291
+ // DETECTION IS IDEMPOTENT AND LITERAL, not a guess: read this codebase's
292
+ // OWN generated DDL back out of `sqlite_master` and check whether it
293
+ // already contains the token 'faction' -- the same "a literal check for a
294
+ // token we defined in output we generated is fine" carve-out the
295
+ // `resource_constraints` migration's own comment cites (hard rule 4 in the
296
+ // downstream game's own engineering standards; this engine has no
297
+ // narrative-language rule of its own to point at, but the reasoning is the
298
+ // same: this is a substring check against SQL text THIS FUNCTION generated
299
+ // a few lines above, never against anything a player or a model wrote). A
300
+ // truly fresh database never takes the branch below: `RESOURCES_DDL`'s
301
+ // `CREATE TABLE IF NOT EXISTS` a few lines up already carries the widened
302
+ // CHECK, so by the time this runs, this database's own `resources` already
303
+ // contains 'faction'.
304
+ const resourcesDdl = db
305
+ .prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'resources'`)
306
+ .get();
307
+ if (resourcesDdl && !resourcesDdl.sql.includes("faction")) {
308
+ // DROP THE PROJECTION TRIGGERS FIRST, UNCONDITIONALLY, IF THEY EXIST.
309
+ // `timeline_resources_ai`/`_au`/`_ad` are defined ON `resources` itself
310
+ // (`AFTER INSERT/UPDATE/DELETE ON resources`), so SQLite drops them
311
+ // automatically the moment `DROP TABLE resources` below runs -- but
312
+ // dropping them here too, explicitly, costs nothing and removes any
313
+ // dependence on that implicit behaviour being exactly right. They are
314
+ // unconditionally reinstalled, generated fresh off the rebuilt table's
315
+ // own `pragma_table_info`, by `installProjectionTriggers()`
316
+ // (`src/timeline/projection.ts`), which `initializeTimelineSchema()`
317
+ // calls LAST in this function -- see the comment on that call for why it
318
+ // runs after every migration above it, this one included.
319
+ db.exec(`DROP TRIGGER IF EXISTS timeline_resources_ai`);
320
+ db.exec(`DROP TRIGGER IF EXISTS timeline_resources_au`);
321
+ db.exec(`DROP TRIGGER IF EXISTS timeline_resources_ad`);
322
+ // Enforcement has to be suspended across the drop, and put back
323
+ // afterwards on every path -- see `rebuildWithForeignKeysSuspended`. Here
324
+ // the tables that would be cascade-emptied are `resource_history` and
325
+ // `resource_constraint_members`, both of which declare
326
+ // `FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE`.
327
+ rebuildWithForeignKeysSuspended(db, "resources.owner_type", () => {
328
+ // 1. Copy the OLD table's rows into a staging table under a temporary
329
+ // name, already carrying the widened CHECK.
330
+ db.exec(`
331
+ CREATE TABLE resources_staging (
332
+ id TEXT PRIMARY KEY,
333
+ game_id TEXT NOT NULL,
334
+ owner_id TEXT,
335
+ owner_type TEXT NOT NULL CHECK (owner_type IN ('game', 'character', 'faction', 'location')),
336
+ name TEXT NOT NULL,
337
+ description TEXT,
338
+ category TEXT,
339
+ value REAL NOT NULL DEFAULT 0,
340
+ min_value REAL,
341
+ max_value REAL,
342
+ created_at TEXT NOT NULL
343
+ )
344
+ `);
345
+ db.exec(`
346
+ INSERT INTO resources_staging (id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at)
347
+ SELECT id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at FROM resources
348
+ `);
349
+ // 2. Drop the OLD table outright -- not renamed. Renaming it out of
350
+ // the way first would rewrite `resource_history` and
351
+ // `resource_constraint_members`'s own stored FOREIGN KEY clauses to
352
+ // point at the temporary name (RENAME TO rewrites every OTHER
353
+ // table's FK text that references the renamed table, regardless of
354
+ // the `foreign_keys` pragma), leaving them permanently dangling
355
+ // once that temporary table is dropped a few steps later. `DROP
356
+ // TABLE`, unlike `RENAME TO`, does not rewrite other tables'
357
+ // references -- there is nothing to rewrite them TO -- so this
358
+ // recipe never renames the table other tables' foreign keys point
359
+ // at; the FINAL name is produced by a genuine `CREATE TABLE`
360
+ // instead, and `resource_history`/`resource_constraint_members`'s
361
+ // FK text is never touched by anything in this block.
362
+ db.exec(`DROP TABLE resources`);
363
+ // 3. Recreate under the FINAL name using the exact same DDL text the
364
+ // fresh-database path executed above -- `RESOURCES_DDL` itself, not
365
+ // a second hand-copied literal -- so its stored SQL matches the
366
+ // fresh-database path byte for byte.
367
+ db.exec(RESOURCES_DDL);
368
+ // 4. Copy every row back across from the staging table with an
369
+ // EXPLICIT column list -- never SELECT * -- and drop the staging
370
+ // table. Every id was copied verbatim, so `resource_history` and
371
+ // `resource_constraint_members`'s own `FOREIGN KEY (resource_id)
372
+ // REFERENCES resources(id)` -- never touched by any of the steps
373
+ // above -- is satisfied by the replacement table throughout,
374
+ // verified for real by the `PRAGMA foreign_key_check` below, not
375
+ // merely assumed here.
376
+ db.exec(`
377
+ INSERT INTO resources (id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at)
378
+ SELECT id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at FROM resources_staging
379
+ `);
380
+ db.exec(`DROP TABLE resources_staging`);
381
+ });
382
+ }
222
383
  // Resource history table (tracks all changes) -- FROZEN, see the trigger
223
384
  // immediately below. Kept for existing rows only; nothing writes here any
224
385
  // more.
@@ -393,15 +554,12 @@ export function initializeSchema(options) {
393
554
  // means it is unconditionally reinstalled before this function returns
394
555
  // regardless -- this is that discipline paying for itself a second time.
395
556
  db.exec(`DROP TRIGGER IF EXISTS timeline_facts_resolve_only`);
396
- // PRAGMA foreign_keys is a documented no-op when toggled inside a
397
- // pending transaction, so it brackets withTransaction() below rather
398
- // than living inside it -- set OFF here (before BEGIN), restored ON
399
- // after COMMIT. connection.ts turns it ON for every connection at
400
- // open (`getDatabase()`); this block is the one place in the codebase
401
- // that deliberately, temporarily, undoes that, and it is responsible
402
- // for putting it back.
403
- db.pragma("foreign_keys = OFF");
404
- withTransaction(() => {
557
+ // Enforcement has to be suspended across the drop, and put back
558
+ // afterwards on every path -- see `rebuildWithForeignKeysSuspended`. Here
559
+ // the table that would be cascade-emptied is `resource_constraint_members`,
560
+ // which declares
561
+ // `FOREIGN KEY (constraint_id) REFERENCES resource_constraints(id) ON DELETE CASCADE`.
562
+ rebuildWithForeignKeysSuspended(db, "resource_constraints (resolve_only, issue #13)", () => {
405
563
  // EMPIRICALLY MEASURED, NOT ASSUMED (see resolveOnly.test.ts, whose
406
564
  // FK-check assertion caught a real bug in an earlier version of this
407
565
  // block): `ALTER TABLE ... RENAME TO` does not just rename the table
@@ -501,12 +659,6 @@ export function initializeSchema(options) {
501
659
  // below, not merely assumed here.
502
660
  db.exec(`DROP TABLE resource_constraints_staging`);
503
661
  });
504
- const fkViolations = db.pragma("foreign_key_check");
505
- if (fkViolations.length > 0) {
506
- throw new Error(`resource_constraints CHECK migration (resolve_only, issue #13) left dangling foreign keys: ` +
507
- JSON.stringify(fkViolations));
508
- }
509
- db.pragma("foreign_keys = ON");
510
662
  }
511
663
  // Members of a resource constraint. 'bounded' and 'monotonic' constraints
512
664
  // have exactly one member (the resource they govern); 'conserved'
@@ -583,7 +583,28 @@ export function createHttpServer(_port = 3456) {
583
583
  next();
584
584
  return;
585
585
  }
586
- res.sendFile(join(CLIENT_DIST, "index.html"));
586
+ // `dotfiles: "allow"` is load-bearing, and its absence was a real bug.
587
+ //
588
+ // res.sendFile() with no `root` option hands the WHOLE absolute path to
589
+ // `send`, whose `dotfiles` default is "ignore" -- so if ANY segment of
590
+ // the path this package is installed under begins with a dot, `send`
591
+ // refuses the file with NotFoundError and the error handler below turns
592
+ // that into a 500. Every client-side route breaks at once, while `/`
593
+ // keeps working (it is served by express.static above, which only
594
+ // dotfile-checks the REQUEST path, not its own root). That is a
595
+ // spectacularly confusing failure: the app is fine, the file is there,
596
+ // and the deep link 500s because of where the checkout happens to live.
597
+ //
598
+ // Installing under a dot directory is ordinary -- a worktree beneath
599
+ // `.claude/`, a deploy under `~/.local/share`, a CI checkout in a dotted
600
+ // cache path -- so this is not a hypothetical.
601
+ //
602
+ // Safe, because the dotfiles guard is protecting nothing here: this path
603
+ // is a server-controlled constant (CLIENT_DIST plus a literal file name)
604
+ // and no part of it comes from the request. The guard exists to stop a
605
+ // user-supplied path from reaching `.env` or `.git`; there is no
606
+ // user-supplied path in it.
607
+ res.sendFile(join(CLIENT_DIST, "index.html"), { dotfiles: "allow" });
587
608
  });
588
609
  }
589
610
  else {
package/dist/index.d.ts CHANGED
@@ -32,5 +32,38 @@ export { exportTimeline, importTimeline, exportTimelineToFile, importTimelineFro
32
32
  export type { TimelineExport, TimelineExportEntity, TimelineExportFact, TimelineExportEvent, TimelineExportClock, TimelineImportResult, } from "./timeline/export.js";
33
33
  export { ENTITY_KINDS } from "./timeline/kinds.js";
34
34
  export type { EntityKind } from "./timeline/kinds.js";
35
- export { DEFAULT_HTTP_PORT, httpPortFromEnv, webUiEnabled, setHttpPort, getWebUiBaseUrl, } from "./utils/webui.js";
35
+ export { DEFAULT_HTTP_PORT, httpPortFromEnv, webUiEnabled, setHttpPort, getWebUiBaseUrl, getGameUrl, getCharacterUrl, getLocationUrl, } from "./utils/webui.js";
36
+ export * from "./tools/game.js";
37
+ export * from "./tools/world.js";
38
+ export * from "./tools/character.js";
39
+ export * from "./tools/faction.js";
40
+ export * from "./tools/relationship.js";
41
+ export * from "./tools/resource.js";
42
+ export * from "./tools/constraint.js";
43
+ export * from "./tools/inventory.js";
44
+ export * from "./tools/secrets.js";
45
+ export * from "./tools/narrative.js";
46
+ export * from "./tools/notes.js";
47
+ export * from "./tools/tags.js";
48
+ export * from "./tools/time.js";
49
+ export * from "./tools/timers.js";
50
+ export * from "./tools/rules.js";
51
+ export * from "./tools/pause.js";
52
+ export * from "./tools/display.js";
53
+ export * from "./tools/images.js";
54
+ export * from "./tools/audio.js";
55
+ export * from "./tools/image-prompt.js";
56
+ export { gameEvents } from "./events/emitter.js";
57
+ export type { GameEvent } from "./events/emitter.js";
58
+ export { ANNOTATIONS, withAnnotations } from "./utils/tool-annotations.js";
59
+ export { LIMITS, validatedSchemas, boundedString, boundedArray } from "./utils/validation.js";
60
+ export { createError, formatErrorResponse, errors } from "./utils/errors.js";
61
+ export type { AgentError } from "./utils/errors.js";
62
+ export { createLogger } from "./utils/logger.js";
63
+ export type { Logger } from "./utils/logger.js";
64
+ export { verbositySchema, applyVerbosity, filterFields } from "./utils/verbosity.js";
65
+ export type { VerbosityLevel } from "./utils/verbosity.js";
66
+ export { safeJsonParse, safeJsonParseOrNull } from "./utils/json.js";
67
+ export { successResponseSchema, textResultSchema, deletedResponseSchema, listResponseSchema, characterOutputSchema, characterStatusSchema, conditionModifyOutputSchema, tagModifyOutputSchema, } from "./utils/output-schemas.js";
68
+ export { imageGenSchema, voiceSchema } from "./schemas/index.js";
36
69
  export type * from "./types/index.js";
package/dist/index.js CHANGED
@@ -181,4 +181,82 @@ export { ENTITY_KINDS } from "./timeline/kinds.js";
181
181
  // ability and combat tools and is exported from "run-dmcp/rpg" instead
182
182
  // (src/rpg/index.ts), even though the file that implements it stays at
183
183
  // src/http/server.ts on disk.
184
- export { DEFAULT_HTTP_PORT, httpPortFromEnv, webUiEnabled, setHttpPort, getWebUiBaseUrl, } from "./utils/webui.js";
184
+ export { DEFAULT_HTTP_PORT, httpPortFromEnv, webUiEnabled, setHttpPort, getWebUiBaseUrl, getGameUrl, getCharacterUrl, getLocationUrl, } from "./utils/webui.js";
185
+ // ===========================================================================
186
+ // The core's own tool modules, as library functions (design §8, §11 Phase 5).
187
+ //
188
+ // §8's layer table puts factions, relationships-with-history, secrets,
189
+ // resources, locations and items in the CORE, and gives the reason: they are
190
+ // "the client's spine. If these go up into the RPG layer, the client cannot
191
+ // consume the package without dragging the RPG layer with it -- which defeats
192
+ // the split." That reason is only satisfied if a consumer can actually IMPORT
193
+ // them. Until this block, it could not: everything above is the timeline and
194
+ // the database, and the only door to the spine was `createCoreMcpServer` --
195
+ // the whole assembled server, which a consumer would then have to make tool
196
+ // calls into, over a transport, to read its own tables.
197
+ //
198
+ // The layer ABOVE this one already got it right. src/rpg/index.ts ends with
199
+ // six export-stars over its own tool modules, under a comment saying it is
200
+ // using "the same shape core's index.ts uses for the timeline... a consumer
201
+ // that wants to call combat/quest/table/status/ability/dice logic directly,
202
+ // without going through an MCP tool call, can." The core never did
203
+ //
204
+ // (Those six are named here in prose rather than quoted as import lines on
205
+ // purpose: layerBoundary.test.ts walks this file's import graph with a
206
+ // deliberately syntactic scan for `from "<specifier>"`, and it cannot tell a
207
+ // quoted example in a comment from a real edge. That is the guard being
208
+ // conservative rather than clever, which is the right trade -- it fails loud
209
+ // and names the chain. Do not teach it to strip comments; reword instead.)
210
+ // the same for its own tools, which left the OPTIONAL layer more consumable
211
+ // than the thing it is optional on top of. That is an oversight and not a
212
+ // decision: §6's rule is "library functions first, MCP tools second", and the
213
+ // narration-constraint and timeline-export blocks above both invoke it.
214
+ //
215
+ // This changes no behaviour and adds no dependency. Every module below is
216
+ // ALREADY in this file's static import graph, reached through
217
+ // ./mcp-server.js -> ./register/* -> ./tools/*, so src/__tests__/
218
+ // layerBoundary.test.ts walks exactly the same file set before and after --
219
+ // nothing under src/rpg/ becomes reachable, and none of the six tool modules
220
+ // that moved up there is named here.
221
+ export * from "./tools/game.js";
222
+ export * from "./tools/world.js";
223
+ export * from "./tools/character.js";
224
+ export * from "./tools/faction.js";
225
+ export * from "./tools/relationship.js";
226
+ export * from "./tools/resource.js";
227
+ export * from "./tools/constraint.js";
228
+ export * from "./tools/inventory.js";
229
+ export * from "./tools/secrets.js";
230
+ export * from "./tools/narrative.js";
231
+ export * from "./tools/notes.js";
232
+ export * from "./tools/tags.js";
233
+ export * from "./tools/time.js";
234
+ export * from "./tools/timers.js";
235
+ export * from "./tools/rules.js";
236
+ export * from "./tools/pause.js";
237
+ export * from "./tools/display.js";
238
+ export * from "./tools/images.js";
239
+ export * from "./tools/audio.js";
240
+ export * from "./tools/image-prompt.js";
241
+ // The event emitter the tool modules above write to. A consumer's own write
242
+ // paths emit through the same singleton, so its tables and the engine's reach
243
+ // one SSE subscriber rather than two competing ones.
244
+ export { gameEvents } from "./events/emitter.js";
245
+ // ===========================================================================
246
+ // What a consumer needs to register tools OF ITS OWN onto the core server.
247
+ //
248
+ // A client keeps its own MCP surface -- that is the whole point of the split;
249
+ // its mechanics are its own and the engine never learns their names. But a
250
+ // tool it registers should refuse, bound and annotate the way the engine's do,
251
+ // and today a client re-implements these or copies them and lets the copy
252
+ // drift. Named one by one rather than star-exported: these modules contain
253
+ // identifiers like `CREATE` and `UPDATE` that have no business in a package's
254
+ // root namespace.
255
+ export { ANNOTATIONS, withAnnotations } from "./utils/tool-annotations.js";
256
+ export { LIMITS, validatedSchemas, boundedString, boundedArray } from "./utils/validation.js";
257
+ export { createError, formatErrorResponse, errors } from "./utils/errors.js";
258
+ export { createLogger } from "./utils/logger.js";
259
+ export { verbositySchema, applyVerbosity, filterFields } from "./utils/verbosity.js";
260
+ export { safeJsonParse, safeJsonParseOrNull } from "./utils/json.js";
261
+ export { successResponseSchema, textResultSchema, deletedResponseSchema, listResponseSchema, characterOutputSchema, characterStatusSchema, conditionModifyOutputSchema, tagModifyOutputSchema, } from "./utils/output-schemas.js";
262
+ export { imageGenSchema, voiceSchema } from "./schemas/index.js";
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { type Mechanic } from "./timeline/resolve.js";
3
3
  import { type RenderVocabulary } from "./timeline/render.js";
4
4
  export declare const SERVER_NAME = "dmcp";
5
- export declare const SERVER_VERSION = "0.2.0";
5
+ export declare const SERVER_VERSION = "0.3.0";
6
6
  /**
7
7
  * Build an MCP server with every CORE tool, resource and prompt this engine
8
8
  * serves -- entities, facts, events, the timeline, and the entity/property
@@ -41,7 +41,7 @@ import { registerRenderTools } from "./register/render.js";
41
41
  import { createResolver } from "./timeline/resolve.js";
42
42
  import { createStateRenderer } from "./timeline/render.js";
43
43
  export const SERVER_NAME = "dmcp";
44
- export const SERVER_VERSION = "0.2.0";
44
+ export const SERVER_VERSION = "0.3.0";
45
45
  /**
46
46
  * Build an MCP server with every CORE tool, resource and prompt this engine
47
47
  * serves -- entities, facts, events, the timeline, and the entity/property
@@ -9,7 +9,7 @@ export function registerResourceTools(server) {
9
9
  description: "Create a new resource (currency, reputation, counter, etc.)",
10
10
  inputSchema: {
11
11
  gameId: z.string().max(100).describe("The game ID"),
12
- ownerType: z.enum(["game", "character"]).describe("Owner type: 'game' for party/global resources, 'character' for personal resources"),
12
+ ownerType: z.enum(["game", "character", "faction", "location"]).describe("Owner type: 'game' for party/global resources, 'character' for personal resources, 'faction' or 'location' for resources owned by one of those entities"),
13
13
  ownerId: z.string().max(100).optional().describe("Character ID if ownerType is 'character' (omit for game-level resources)"),
14
14
  name: z.string().min(1).max(LIMITS.NAME_MAX).describe("Resource name (e.g., 'Gold', 'Sanity', 'Thieves Guild Reputation')"),
15
15
  description: z.string().max(LIMITS.DESCRIPTION_MAX).optional().describe("Resource description"),
@@ -105,7 +105,7 @@ export function registerResourceTools(server) {
105
105
  description: "List resources in a game",
106
106
  inputSchema: {
107
107
  gameId: z.string().max(100).describe("The game ID"),
108
- ownerType: z.enum(["game", "character"]).optional().describe("Filter by owner type"),
108
+ ownerType: z.enum(["game", "character", "faction", "location"]).optional().describe("Filter by owner type"),
109
109
  ownerId: z.string().max(100).optional().describe("Filter by owner ID (for character resources)"),
110
110
  category: z.string().max(100).optional().describe("Filter by category"),
111
111
  },
@@ -92,6 +92,14 @@ export interface ChangeSet {
92
92
  * retroactively un-happen it. Filtering these rows by aliveness would be
93
93
  * exactly the kind of policy this module isn't allowed to have an opinion
94
94
  * on (see `ChangeSet`'s doc comment).
95
+ *
96
+ * DECISION(#18): changesWithin() returns every transition, whoever could observe it.
97
+ *
98
+ * Omniscient for the same reason and by the same decision as `replay()` --
99
+ * see its doc comment for the argument. Every transition in the window is
100
+ * returned regardless of which principal could have observed it, and a
101
+ * later per-principal filter arrives as one predicate on the two queries
102
+ * below (issue #18).
95
103
  */
96
104
  export declare function changesWithin(params: {
97
105
  gameId: string;
@@ -71,6 +71,14 @@ function compareChanges(a, b) {
71
71
  * retroactively un-happen it. Filtering these rows by aliveness would be
72
72
  * exactly the kind of policy this module isn't allowed to have an opinion
73
73
  * on (see `ChangeSet`'s doc comment).
74
+ *
75
+ * DECISION(#18): changesWithin() returns every transition, whoever could observe it.
76
+ *
77
+ * Omniscient for the same reason and by the same decision as `replay()` --
78
+ * see its doc comment for the argument. Every transition in the window is
79
+ * returned regardless of which principal could have observed it, and a
80
+ * later per-principal filter arrives as one predicate on the two queries
81
+ * below (issue #18).
74
82
  */
75
83
  export function changesWithin(params) {
76
84
  const { gameId, t0, t1 } = params;
@@ -28,6 +28,16 @@ import type { EntityKind } from "./kinds.js";
28
28
  * artifact contains no `file_path` anywhere and that those rows
29
29
  * contributed nothing (not even an extra entity).
30
30
  *
31
+ * DECISION(#18): the frozen artifact carries no per-principal projection.
32
+ *
33
+ * No visibility filtering either, and the same "requirement, not omission"
34
+ * applies (issue #18): the artifact is the omniscient timeline. Whether a
35
+ * per-principal export should exist is not a small question deferred for
36
+ * tidiness -- it decides whether §6's "one file a deterministic consumer
37
+ * depends on" becomes N files plus a rule for choosing between them. That
38
+ * is a decision for the caller that first needs it to own, and it cannot be
39
+ * made well against no caller, so it is not made here.
40
+ *
31
41
  * No live tables either. The live projected tables (`games`, `characters`,
32
42
  * `resources`, ...) are a projection of the timeline, not a second source
33
43
  * of truth (design §5.4's decided destination) -- a file carrying both
@@ -28,6 +28,16 @@ import { getDatabase, withTransaction } from "../db/connection.js";
28
28
  * artifact contains no `file_path` anywhere and that those rows
29
29
  * contributed nothing (not even an extra entity).
30
30
  *
31
+ * DECISION(#18): the frozen artifact carries no per-principal projection.
32
+ *
33
+ * No visibility filtering either, and the same "requirement, not omission"
34
+ * applies (issue #18): the artifact is the omniscient timeline. Whether a
35
+ * per-principal export should exist is not a small question deferred for
36
+ * tidiness -- it decides whether §6's "one file a deterministic consumer
37
+ * depends on" becomes N files plus a rule for choosing between them. That
38
+ * is a decision for the caller that first needs it to own, and it cannot be
39
+ * made well against no caller, so it is not made here.
40
+ *
31
41
  * No live tables either. The live projected tables (`games`, `characters`,
32
42
  * `resources`, ...) are a projection of the timeline, not a second source
33
43
  * of truth (design §5.4's decided destination) -- a file carrying both
@@ -1,5 +1,7 @@
1
1
  import { type FactProvenance } from "./provenance.js";
2
2
  /**
3
+ * DECISION(#21): contradiction is whole-value comparison under one key.
4
+ *
3
5
  * `irreversible` -- the temporal member of the constraint family alongside
4
6
  * `bounded`, `monotonic`, and conserved sets (design §5.3). Declared per
5
7
  * fact, not per entity or per value: `facts.irreversible` is a column on the
@@ -57,6 +57,28 @@ export interface Snapshot {
57
57
  * large one and make every distinct entity count its own SQL text, which
58
58
  * defeats better-sqlite3's prepared-statement cache. Fixed SQL, two binds
59
59
  * of `t`, regardless of how many entities exist.
60
+ *
61
+ * DECISION(#18): replay() applies no visibility filtering, by decision rather than omission.
62
+ *
63
+ * OMNISCIENT, DELIBERATELY (issue #18). This returns every fact valid at
64
+ * `t`, for every entity alive at `t`, with no visibility filtering of any
65
+ * kind. That is a decision, not an omission: per-principal visibility is
66
+ * deferred until a real caller makes the requirement concrete, because the
67
+ * expensive half of such a feature is the principal model, and inventing a
68
+ * principal against an imagined client is precisely what design §13 forbids.
69
+ *
70
+ * Worth stating out loud here rather than leaving to be inferred, because
71
+ * the predecessor's unenforced version of the same decision is legible
72
+ * today for exactly one reason -- `get_secret`'s own description says "DM
73
+ * view - shows all info" -- and a reader who finds an unfiltered query with
74
+ * no such sentence cannot tell a decision from a hole.
75
+ *
76
+ * A later filter is additive and needs nothing reserved for it now: it is
77
+ * one predicate, added here once, because this function is the single place
78
+ * every reader of "what was true at `t`" goes through. Do NOT add a
79
+ * nullable visibility column to `facts` in anticipation -- an unpopulated
80
+ * one reads equally well as "visible to everyone" and "visible to no one",
81
+ * and both readings survive review.
60
82
  */
61
83
  export declare function replay(params: {
62
84
  gameId: string;
@@ -36,6 +36,28 @@ const ALIVE_AT_T = "e.created_at_t <= ? AND (e.destroyed_at_t IS NULL OR e.destr
36
36
  * large one and make every distinct entity count its own SQL text, which
37
37
  * defeats better-sqlite3's prepared-statement cache. Fixed SQL, two binds
38
38
  * of `t`, regardless of how many entities exist.
39
+ *
40
+ * DECISION(#18): replay() applies no visibility filtering, by decision rather than omission.
41
+ *
42
+ * OMNISCIENT, DELIBERATELY (issue #18). This returns every fact valid at
43
+ * `t`, for every entity alive at `t`, with no visibility filtering of any
44
+ * kind. That is a decision, not an omission: per-principal visibility is
45
+ * deferred until a real caller makes the requirement concrete, because the
46
+ * expensive half of such a feature is the principal model, and inventing a
47
+ * principal against an imagined client is precisely what design §13 forbids.
48
+ *
49
+ * Worth stating out loud here rather than leaving to be inferred, because
50
+ * the predecessor's unenforced version of the same decision is legible
51
+ * today for exactly one reason -- `get_secret`'s own description says "DM
52
+ * view - shows all info" -- and a reader who finds an unfiltered query with
53
+ * no such sentence cannot tell a decision from a hole.
54
+ *
55
+ * A later filter is additive and needs nothing reserved for it now: it is
56
+ * one predicate, added here once, because this function is the single place
57
+ * every reader of "what was true at `t`" goes through. Do NOT add a
58
+ * nullable visibility column to `facts` in anticipation -- an unpopulated
59
+ * one reads equally well as "visible to everyone" and "visible to no one",
60
+ * and both readings survive review.
39
61
  */
40
62
  export function replay(params) {
41
63
  const { gameId, t } = params;
@@ -53,6 +53,8 @@ export function initializeTimelineSchema() {
53
53
  // all (absence is the absence of a fact, never a fact of absence -- hard
54
54
  // rule 3). No ON DELETE CASCADE and no FK to `games`: deleting a game
55
55
  // deletes its live rows, but the timeline of that game survives.
56
+ // DECISION(#21): a property to be declared irreversible needs its own fact key.
57
+ //
56
58
  // `irreversible` is a per-fact flag, not a per-entity or per-value one --
57
59
  // so any property a future consumer wants to declare irreversible has to
58
60
  // live under its own fact key; you cannot flag half a blob.
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid";
2
2
  import { getDatabase, getDataDir, withTransaction } from "../db/connection.js";
3
3
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, rmSync, } from "fs";
4
4
  import { dirname, join, extname } from "path";
5
+ import { mediaDirPath, mediaFilePath, mediaPathWithin } from "../utils/media-path.js";
5
6
  import { getCharacter } from "./character.js";
6
7
  import { getLocation } from "./world.js";
7
8
  import { getFaction } from "./faction.js";
@@ -128,10 +129,11 @@ export async function storeAudio(params) {
128
129
  else {
129
130
  throw new Error("Either url or filePath must be provided");
130
131
  }
131
- // Build file path
132
+ // Build file path. Every segment below arrives from the caller, so the
133
+ // composition goes through the media-path choke point, which rejects
134
+ // anything that would land outside the audio directory.
132
135
  const ext = getExtension(mimeType);
133
- const relativePath = join(params.gameId, `${params.entityType}s`, params.entityId, `${id}.${ext}`);
134
- const fullPath = join(getAudioDir(), relativePath);
136
+ const { relativePath, fullPath } = mediaFilePath(getAudioDir(), [params.gameId, `${params.entityType}s`, params.entityId], `${id}.${ext}`);
135
137
  // Ensure directory exists and write file
136
138
  ensureDir(dirname(fullPath));
137
139
  writeFileSync(fullPath, audioBuffer);
@@ -192,7 +194,7 @@ export function getAudioFilePath(audioId) {
192
194
  const audio = getAudio(audioId);
193
195
  if (!audio)
194
196
  return null;
195
- const fullPath = join(getAudioDir(), audio.filePath);
197
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
196
198
  if (!existsSync(fullPath))
197
199
  return null;
198
200
  return fullPath;
@@ -201,7 +203,7 @@ export function getAudioData(audioId) {
201
203
  const audio = getAudio(audioId);
202
204
  if (!audio)
203
205
  return null;
204
- const fullPath = join(getAudioDir(), audio.filePath);
206
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
205
207
  if (!existsSync(fullPath))
206
208
  return null;
207
209
  const buffer = readFileSync(fullPath);
@@ -264,7 +266,7 @@ export function getCharacterVoiceReferences(gameId, characterId) {
264
266
  const audioDir = getAudioDir();
265
267
  const filePaths = voiceRefs
266
268
  .map((ref) => {
267
- const fullPath = join(audioDir, ref.filePath);
269
+ const fullPath = mediaPathWithin(audioDir, ref.filePath);
268
270
  return existsSync(fullPath) ? fullPath : null;
269
271
  })
270
272
  .filter((p) => p !== null);
@@ -283,7 +285,7 @@ export function deleteAudio(audioId) {
283
285
  if (!audio)
284
286
  return false;
285
287
  // Delete file
286
- const fullPath = join(getAudioDir(), audio.filePath);
288
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
287
289
  if (existsSync(fullPath)) {
288
290
  unlinkSync(fullPath);
289
291
  }
@@ -339,8 +341,10 @@ export function updateAudioMetadata(audioId, updates) {
339
341
  // Cleanup helper - delete all audio for a game
340
342
  export function deleteGameAudio(gameId) {
341
343
  const db = getDatabase();
342
- // Delete files
343
- const gameDir = join(getAudioDir(), gameId);
344
+ // Delete files. mediaDirPath rejects before the rmSync below, which is
345
+ // recursive and forced: an unchecked `..` here would take the whole data
346
+ // directory with it.
347
+ const gameDir = mediaDirPath(getAudioDir(), [gameId]);
344
348
  if (existsSync(gameDir)) {
345
349
  rmSync(gameDir, { recursive: true, force: true });
346
350
  }
@@ -1,6 +1,10 @@
1
1
  import { v4 as uuidv4 } from "uuid";
2
2
  import { getDatabase } from "../db/connection.js";
3
3
  import { safeJsonParse } from "../utils/json.js";
4
+ import { createLogger } from "../utils/logger.js";
5
+ import { deleteGameAudio } from "./audio.js";
6
+ import { deleteGameImages } from "./images.js";
7
+ const log = createLogger("game");
4
8
  /**
5
9
  * Validates that a game exists and returns it, or throws an error if not found.
6
10
  * Use this in create operations to prevent orphaned records.
@@ -119,7 +123,35 @@ export function deleteGame(id) {
119
123
  const db = getDatabase();
120
124
  const stmt = db.prepare(`DELETE FROM games WHERE id = ?`);
121
125
  const result = stmt.run(id);
122
- return result.changes > 0;
126
+ if (result.changes === 0)
127
+ return false;
128
+ // The stored_audio / stored_images rows went with the game by foreign key
129
+ // cascade, so nothing is left that knows the files on disk exist. Cleanup
130
+ // therefore runs here, after the delete has committed.
131
+ //
132
+ // A cleanup failure degrades to a log rather than an exception: the row is
133
+ // already gone and throwing cannot un-delete it, so it would trade a stale
134
+ // directory for a failed tool call and lose both. Each cleanup is attempted
135
+ // separately, so one failing does not skip the other.
136
+ try {
137
+ deleteGameAudio(id);
138
+ }
139
+ catch (error) {
140
+ log.error("Failed to remove stored audio for a deleted game; files remain on disk", {
141
+ gameId: id,
142
+ error: error.message,
143
+ });
144
+ }
145
+ try {
146
+ deleteGameImages(id);
147
+ }
148
+ catch (error) {
149
+ log.error("Failed to remove stored images for a deleted game; files remain on disk", {
150
+ gameId: id,
151
+ error: error.message,
152
+ });
153
+ }
154
+ return true;
123
155
  }
124
156
  export function updateGameLocation(gameId, locationId) {
125
157
  const db = getDatabase();
@@ -3,6 +3,7 @@ import { getDatabase, getDataDir, withTransaction } from "../db/connection.js";
3
3
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, rmSync, } from "fs";
4
4
  import { dirname, join } from "path";
5
5
  import sharp from "sharp";
6
+ import { mediaDirPath, mediaFilePath, mediaPathWithin } from "../utils/media-path.js";
6
7
  import { getCharacter } from "./character.js";
7
8
  import { getLocation } from "./world.js";
8
9
  import { getItem } from "./inventory.js";
@@ -122,10 +123,11 @@ export async function storeImage(params) {
122
123
  catch {
123
124
  // If sharp can't parse it, proceed without dimensions
124
125
  }
125
- // Build file path
126
+ // Build file path. Every segment below arrives from the caller, so the
127
+ // composition goes through the media-path choke point, which rejects
128
+ // anything that would land outside the images directory.
126
129
  const ext = getExtension(mimeType);
127
- const relativePath = join(params.gameId, `${params.entityType}s`, params.entityId, `${id}.${ext}`);
128
- const fullPath = join(getImagesDir(), relativePath);
130
+ const { relativePath, fullPath } = mediaFilePath(getImagesDir(), [params.gameId, `${params.entityType}s`, params.entityId], `${id}.${ext}`);
129
131
  // Ensure directory exists and write file
130
132
  ensureDir(dirname(fullPath));
131
133
  writeFileSync(fullPath, imageBuffer);
@@ -177,7 +179,7 @@ export async function getImageData(imageId, options) {
177
179
  const image = getImage(imageId);
178
180
  if (!image)
179
181
  return null;
180
- const fullPath = join(getImagesDir(), image.filePath);
182
+ const fullPath = mediaPathWithin(getImagesDir(), image.filePath);
181
183
  if (!existsSync(fullPath))
182
184
  return null;
183
185
  const originalBuffer = readFileSync(fullPath);
@@ -335,7 +337,7 @@ export function deleteImage(imageId) {
335
337
  if (!image)
336
338
  return false;
337
339
  // Delete file
338
- const fullPath = join(getImagesDir(), image.filePath);
340
+ const fullPath = mediaPathWithin(getImagesDir(), image.filePath);
339
341
  if (existsSync(fullPath)) {
340
342
  unlinkSync(fullPath);
341
343
  }
@@ -380,10 +382,13 @@ export function updateImageMetadata(imageId, updates) {
380
382
  validateEntityExists(newEntityId, newEntityType);
381
383
  // If entity changed, move the file
382
384
  if (newEntityId !== current.entityId || newEntityType !== current.entityType) {
383
- const oldFullPath = join(getImagesDir(), current.filePath);
385
+ const oldFullPath = mediaPathWithin(getImagesDir(), current.filePath);
384
386
  const ext = current.filePath.split(".").pop() || "png";
385
- newFilePath = join(current.gameId, `${newEntityType}s`, newEntityId, `${imageId}.${ext}`);
386
- const newFullPath = join(getImagesDir(), newFilePath);
387
+ // The move recomposes the path from a caller-supplied entity id and
388
+ // type, so it goes through the same choke point as the original write.
389
+ const moved = mediaFilePath(getImagesDir(), [current.gameId, `${newEntityType}s`, newEntityId], `${imageId}.${ext}`);
390
+ newFilePath = moved.relativePath;
391
+ const newFullPath = moved.fullPath;
387
392
  // Ensure new directory exists
388
393
  ensureDir(dirname(newFullPath));
389
394
  // Move the file
@@ -411,8 +416,10 @@ export function updateImageMetadata(imageId, updates) {
411
416
  // Cleanup helper - delete all images for a game
412
417
  export function deleteGameImages(gameId) {
413
418
  const db = getDatabase();
414
- // Delete files
415
- const sessionDir = join(getImagesDir(), gameId);
419
+ // Delete files. mediaDirPath rejects before the rmSync below, which is
420
+ // recursive and forced: an unchecked `..` here would take the whole data
421
+ // directory with it.
422
+ const sessionDir = mediaDirPath(getImagesDir(), [gameId]);
416
423
  if (existsSync(sessionDir)) {
417
424
  rmSync(sessionDir, { recursive: true, force: true });
418
425
  }
@@ -1,7 +1,7 @@
1
1
  import type { Resource, ResourceChange } from "../types/index.js";
2
2
  export declare function createResource(params: {
3
3
  gameId: string;
4
- ownerType: "game" | "character";
4
+ ownerType: "game" | "character" | "faction" | "location";
5
5
  ownerId?: string;
6
6
  name: string;
7
7
  description?: string;
@@ -20,7 +20,7 @@ export declare function updateResource(id: string, updates: {
20
20
  }): Resource | null;
21
21
  export declare function deleteResource(id: string): boolean;
22
22
  export declare function listResources(gameId: string, filter?: {
23
- ownerType?: "game" | "character";
23
+ ownerType?: "game" | "character" | "faction" | "location";
24
24
  ownerId?: string;
25
25
  category?: string;
26
26
  }): Resource[];
@@ -133,9 +133,24 @@ export function advanceTime(gameId, duration) {
133
133
  const consequenceFailures = [];
134
134
  for (const row of events) {
135
135
  const triggerTime = safeJsonParse(row.trigger_time, { year: 1, month: 1, day: 1, hour: 0, minute: 0 });
136
- // Check if event should trigger (trigger time is between previous and new time)
137
- if (compareDateTime(triggerTime, previousTime, calendarConfig) >= 0 &&
138
- compareDateTime(triggerTime, newTime, calendarConfig) <= 0) {
136
+ // Due if the trigger time has been reached -- deliberately NOT also gated
137
+ // on `triggerTime >= previousTime`.
138
+ //
139
+ // That lower bound looks like the right window ("did we cross it on THIS
140
+ // call?") and quietly broke the retry the catch block below promises. When
141
+ // a consequence throws, the transaction rolls back and the row stays
142
+ // pending, exactly as intended -- but the clock has already moved past
143
+ // triggerTime by then, because it is updated unconditionally above. On
144
+ // every subsequent call the lower bound therefore excluded the row, and
145
+ // the event sat pending forever with its consequence never applied. The
146
+ // rollback was correct and unreachable.
147
+ //
148
+ // Without the lower bound, "pending and past due" is the whole condition,
149
+ // which is the same retry semantics timers already have. The `triggered =
150
+ // 0` filter in the query above is what keeps this exactly-once: a
151
+ // successful event is marked (or, if recurring, rescheduled forward) in
152
+ // the same transaction as its consequence, so it cannot come back.
153
+ if (compareDateTime(triggerTime, newTime, calendarConfig) <= 0) {
139
154
  const eventId = row.id;
140
155
  const eventName = row.name;
141
156
  const recurring = row.recurring;
@@ -465,7 +465,7 @@ export interface Resource {
465
465
  id: string;
466
466
  gameId: string;
467
467
  ownerId: string | null;
468
- ownerType: "game" | "character";
468
+ ownerType: "game" | "character" | "faction" | "location";
469
469
  name: string;
470
470
  description: string;
471
471
  category: string | null;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The one place a path beneath a media root is composed or resolved.
3
+ *
4
+ * Media file paths are built from ids that arrive over the wire -- a game id,
5
+ * an entity id, an entity type -- and are then handed to `writeFileSync` and,
6
+ * worse, to a recursive `rmSync`. `join()` resolves `..` happily, so an id
7
+ * carrying one walks out of the data directory before the write. The rule this
8
+ * breaks is the engine's: it writes nothing to the consumer's machine that the
9
+ * consumer did not name.
10
+ *
11
+ * The guard is here, at the composition point, rather than at each call site,
12
+ * because a per-site check is one forgotten site away from being no check. Two
13
+ * things are asserted, and both are literal checks over characters -- never an
14
+ * attempt to read meaning out of a value (root CLAUDE.md hard rule 4):
15
+ *
16
+ * 1. Every path segment matches an allowlist. Every id the engine mints is a
17
+ * UUID and every entity type is an ASCII word, so the allowlist costs
18
+ * nothing the engine actually uses.
19
+ * 2. The resolved result is strictly beneath the resolved root -- a
20
+ * structural backstop that holds even for a path this module did not
21
+ * compose, such as one read back out of a row written before this guard
22
+ * existed.
23
+ *
24
+ * Rejection, never normalisation: an id containing `a/../b` is refused rather
25
+ * than quietly rewritten to `b`, because the rewrite would silently store one
26
+ * caller's media under a different id than the caller named.
27
+ */
28
+ export declare class MediaPathError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * Resolve a relative path against a media root, refusing anything that is not
33
+ * strictly beneath it. Use for paths read back from a row; `mediaFilePath` and
34
+ * `mediaDirPath` funnel through it too, so every media path in the codebase
35
+ * passes this check exactly once.
36
+ */
37
+ export declare function mediaPathWithin(root: string, relativePath: string): string;
38
+ /**
39
+ * Compose the path of a media file from segments and a leaf file name,
40
+ * returning both the relative path to store in the row and the full path to
41
+ * write to.
42
+ */
43
+ export declare function mediaFilePath(root: string, segments: string[], filename: string): {
44
+ relativePath: string;
45
+ fullPath: string;
46
+ };
47
+ /**
48
+ * Compose the path of a directory beneath a media root. The caller of this one
49
+ * is a recursive delete, so an empty segment list -- which would resolve to the
50
+ * media root itself -- is refused along with everything else.
51
+ */
52
+ export declare function mediaDirPath(root: string, segments: string[]): string;
@@ -0,0 +1,106 @@
1
+ import { isAbsolute, join, resolve, sep } from "path";
2
+ import { createLogger } from "./logger.js";
3
+ const log = createLogger("media-path");
4
+ /**
5
+ * The one place a path beneath a media root is composed or resolved.
6
+ *
7
+ * Media file paths are built from ids that arrive over the wire -- a game id,
8
+ * an entity id, an entity type -- and are then handed to `writeFileSync` and,
9
+ * worse, to a recursive `rmSync`. `join()` resolves `..` happily, so an id
10
+ * carrying one walks out of the data directory before the write. The rule this
11
+ * breaks is the engine's: it writes nothing to the consumer's machine that the
12
+ * consumer did not name.
13
+ *
14
+ * The guard is here, at the composition point, rather than at each call site,
15
+ * because a per-site check is one forgotten site away from being no check. Two
16
+ * things are asserted, and both are literal checks over characters -- never an
17
+ * attempt to read meaning out of a value (root CLAUDE.md hard rule 4):
18
+ *
19
+ * 1. Every path segment matches an allowlist. Every id the engine mints is a
20
+ * UUID and every entity type is an ASCII word, so the allowlist costs
21
+ * nothing the engine actually uses.
22
+ * 2. The resolved result is strictly beneath the resolved root -- a
23
+ * structural backstop that holds even for a path this module did not
24
+ * compose, such as one read back out of a row written before this guard
25
+ * existed.
26
+ *
27
+ * Rejection, never normalisation: an id containing `a/../b` is refused rather
28
+ * than quietly rewritten to `b`, because the rewrite would silently store one
29
+ * caller's media under a different id than the caller named.
30
+ */
31
+ export class MediaPathError extends Error {
32
+ constructor(message) {
33
+ super(message);
34
+ this.name = "MediaPathError";
35
+ }
36
+ }
37
+ /** Directory names: ids the engine mints, plus pluralised entity types. */
38
+ const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/;
39
+ /** Leaf file names: a safe stem, one dot, an alphanumeric extension. */
40
+ const SAFE_FILENAME = /^[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/;
41
+ function reject(what, value) {
42
+ log.warn("Refused a media path built from an unsafe value", { what, value });
43
+ throw new MediaPathError(`Unsafe ${what} for a media path: ${JSON.stringify(value)}. ` +
44
+ `Only letters, digits, underscore and hyphen are allowed; a path separator, ` +
45
+ `"." or ".." would escape the media directory.`);
46
+ }
47
+ function assertSafeSegment(value) {
48
+ if (typeof value !== "string" || !SAFE_SEGMENT.test(value)) {
49
+ reject("path segment", String(value));
50
+ }
51
+ }
52
+ /**
53
+ * Resolve a relative path against a media root, refusing anything that is not
54
+ * strictly beneath it. Use for paths read back from a row; `mediaFilePath` and
55
+ * `mediaDirPath` funnel through it too, so every media path in the codebase
56
+ * passes this check exactly once.
57
+ */
58
+ export function mediaPathWithin(root, relativePath) {
59
+ if (typeof relativePath !== "string" || relativePath.length === 0) {
60
+ reject("stored path", String(relativePath));
61
+ }
62
+ if (isAbsolute(relativePath)) {
63
+ reject("stored path", relativePath);
64
+ }
65
+ // Split on both separators regardless of platform: a stored path composed on
66
+ // one and read on another must not sneak a traversal past the check.
67
+ for (const segment of relativePath.split(/[\\/]/)) {
68
+ if (segment.length === 0 || segment === "." || segment === "..") {
69
+ reject("stored path", relativePath);
70
+ }
71
+ }
72
+ const rootResolved = resolve(root);
73
+ const fullPath = resolve(rootResolved, relativePath);
74
+ // Compare with the separator appended, so a sibling directory whose name
75
+ // merely starts with the root's (`<root>-elsewhere`) is not mistaken for a
76
+ // child of it.
77
+ if (!fullPath.startsWith(rootResolved + sep)) {
78
+ reject("stored path", relativePath);
79
+ }
80
+ return fullPath;
81
+ }
82
+ /**
83
+ * Compose the path of a media file from segments and a leaf file name,
84
+ * returning both the relative path to store in the row and the full path to
85
+ * write to.
86
+ */
87
+ export function mediaFilePath(root, segments, filename) {
88
+ segments.forEach(assertSafeSegment);
89
+ if (typeof filename !== "string" || !SAFE_FILENAME.test(filename)) {
90
+ reject("file name", String(filename));
91
+ }
92
+ const relativePath = join(...segments, filename);
93
+ return { relativePath, fullPath: mediaPathWithin(root, relativePath) };
94
+ }
95
+ /**
96
+ * Compose the path of a directory beneath a media root. The caller of this one
97
+ * is a recursive delete, so an empty segment list -- which would resolve to the
98
+ * media root itself -- is refused along with everything else.
99
+ */
100
+ export function mediaDirPath(root, segments) {
101
+ if (segments.length === 0) {
102
+ reject("path segment", "");
103
+ }
104
+ segments.forEach(assertSafeSegment);
105
+ return mediaPathWithin(root, join(...segments));
106
+ }
@@ -76,17 +76,17 @@ export declare const gameMenuOutputSchema: {
76
76
  setting: string;
77
77
  id: string;
78
78
  name: string;
79
+ createdAt: string;
79
80
  style: string;
80
81
  updatedAt: string;
81
- createdAt: string;
82
82
  webUiUrl?: string | undefined;
83
83
  }, {
84
84
  setting: string;
85
85
  id: string;
86
86
  name: string;
87
+ createdAt: string;
87
88
  style: string;
88
89
  updatedAt: string;
89
- createdAt: string;
90
90
  webUiUrl?: string | undefined;
91
91
  }>, "many">;
92
92
  instruction: z.ZodString;
@@ -776,8 +776,8 @@ export declare const characterListOutputSchema: {
776
776
  experience: number;
777
777
  level: number;
778
778
  };
779
- locationId: string | null;
780
779
  isPlayer: boolean;
780
+ locationId: string | null;
781
781
  }, {
782
782
  id: string;
783
783
  name: string;
@@ -788,8 +788,8 @@ export declare const characterListOutputSchema: {
788
788
  experience: number;
789
789
  level: number;
790
790
  };
791
- locationId: string | null;
792
791
  isPlayer: boolean;
792
+ locationId: string | null;
793
793
  }>, "many">;
794
794
  count: z.ZodNumber;
795
795
  };
@@ -1138,7 +1138,7 @@ export declare const resourceOutputSchema: {
1138
1138
  id: z.ZodString;
1139
1139
  gameId: z.ZodString;
1140
1140
  ownerId: z.ZodNullable<z.ZodString>;
1141
- ownerType: z.ZodEnum<["game", "character"]>;
1141
+ ownerType: z.ZodEnum<["game", "character", "faction", "location"]>;
1142
1142
  name: z.ZodString;
1143
1143
  description: z.ZodString;
1144
1144
  category: z.ZodNullable<z.ZodString>;
@@ -278,7 +278,7 @@ export const resourceOutputSchema = {
278
278
  id: z.string(),
279
279
  gameId: z.string(),
280
280
  ownerId: z.string().nullable(),
281
- ownerType: z.enum(["game", "character"]),
281
+ ownerType: z.enum(["game", "character", "faction", "location"]),
282
282
  name: z.string(),
283
283
  description: z.string(),
284
284
  category: z.string().nullable(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-dmcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -37,11 +37,14 @@
37
37
  "test:coverage": "vitest run --coverage",
38
38
  "test:client": "cd client && npm run test",
39
39
  "test:all": "npm run test:run && npm run test:client -- run",
40
+ "test:acceptance": "npm run build && playwright test",
41
+ "test:acceptance:only": "playwright test",
40
42
  "lint": "eslint src/",
41
43
  "lint:fix": "eslint src/ --fix",
42
44
  "format": "prettier --write \"src/**/*.ts\"",
43
45
  "format:check": "prettier --check \"src/**/*.ts\"",
44
- "typecheck": "tsc --noEmit"
46
+ "typecheck": "tsc --noEmit",
47
+ "typecheck:e2e": "tsc --noEmit -p tsconfig.e2e.json"
45
48
  },
46
49
  "dependencies": {
47
50
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -53,6 +56,7 @@
53
56
  },
54
57
  "devDependencies": {
55
58
  "@eslint/js": "^9.39.2",
59
+ "@playwright/test": "^1.62.1",
56
60
  "@types/better-sqlite3": "^7.6.11",
57
61
  "@types/express": "^5.0.0",
58
62
  "@types/node": "^22.0.0",