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 +32 -8
- package/dist/db/schema.js +170 -18
- package/dist/http/server.js +22 -1
- package/dist/index.d.ts +34 -1
- package/dist/index.js +79 -1
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.js +1 -1
- package/dist/register/resources.js +2 -2
- package/dist/timeline/changes.d.ts +8 -0
- package/dist/timeline/changes.js +8 -0
- package/dist/timeline/export.d.ts +10 -0
- package/dist/timeline/export.js +10 -0
- package/dist/timeline/irreversible.d.ts +2 -0
- package/dist/timeline/replay.d.ts +22 -0
- package/dist/timeline/replay.js +22 -0
- package/dist/timeline/schema.js +2 -0
- package/dist/tools/audio.js +13 -9
- package/dist/tools/game.js +33 -1
- package/dist/tools/images.js +17 -10
- package/dist/tools/resource.d.ts +2 -2
- package/dist/tools/time.js +18 -3
- package/dist/types/index.d.ts +1 -1
- package/dist/utils/media-path.d.ts +52 -0
- package/dist/utils/media-path.js +106 -0
- package/dist/utils/output-schemas.d.ts +5 -5
- package/dist/utils/output-schemas.js +1 -1
- package/package.json +6 -2
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.
|
|
16
|
-
|
|
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.
|
|
22
|
-
value can move only through an adjudicating call —
|
|
23
|
-
|
|
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
|
|
30
|
-
the consuming application rather than into `node_modules`, and a consumer can bring up its own
|
|
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
|
-
|
|
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
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
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'
|
package/dist/http/server.js
CHANGED
|
@@ -583,7 +583,28 @@ export function createHttpServer(_port = 3456) {
|
|
|
583
583
|
next();
|
|
584
584
|
return;
|
|
585
585
|
}
|
|
586
|
-
|
|
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";
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -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.
|
|
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
|
package/dist/mcp-server.js
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/timeline/changes.js
CHANGED
|
@@ -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
|
package/dist/timeline/export.js
CHANGED
|
@@ -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;
|
package/dist/timeline/replay.js
CHANGED
|
@@ -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;
|
package/dist/timeline/schema.js
CHANGED
|
@@ -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.
|
package/dist/tools/audio.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/game.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/tools/images.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
385
|
+
const oldFullPath = mediaPathWithin(getImagesDir(), current.filePath);
|
|
384
386
|
const ext = current.filePath.split(".").pop() || "png";
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/resource.d.ts
CHANGED
|
@@ -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[];
|
package/dist/tools/time.js
CHANGED
|
@@ -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
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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",
|