run-dmcp 0.1.0 → 0.2.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 +76 -10
- package/dist/bin/run-dmcp.d.ts +2 -0
- package/dist/bin/run-dmcp.js +55 -0
- package/dist/db/connection.d.ts +32 -0
- package/dist/db/connection.js +38 -16
- package/dist/db/schema.d.ts +29 -1
- package/dist/db/schema.js +439 -7
- package/dist/http/server.js +3 -3
- package/dist/index.d.ts +36 -2
- package/dist/index.js +184 -92
- package/dist/mcp-server.d.ts +49 -0
- package/dist/mcp-server.js +127 -0
- package/dist/reader/turnReader.d.ts +185 -0
- package/dist/reader/turnReader.js +288 -0
- package/dist/register/batch.js +5 -79
- package/dist/register/mcp-resources.d.ts +9 -0
- package/dist/register/mcp-resources.js +16 -62
- package/dist/register/render.d.ts +17 -0
- package/dist/register/render.js +50 -0
- package/dist/register/resolve.d.ts +14 -0
- package/dist/register/resolve.js +102 -0
- package/dist/register/resources.js +11 -4
- package/dist/register/timeline.d.ts +2 -0
- package/dist/register/timeline.js +311 -0
- package/dist/rpg/index.d.ts +29 -0
- package/dist/rpg/index.js +55 -0
- package/dist/rpg/register/abilities.d.ts +2 -0
- package/dist/rpg/register/abilities.js +165 -0
- package/dist/rpg/register/batch.d.ts +2 -0
- package/dist/rpg/register/batch.js +92 -0
- package/dist/rpg/register/combat.d.ts +2 -0
- package/dist/rpg/register/combat.js +207 -0
- package/dist/rpg/register/mcp-prompts.d.ts +2 -0
- package/dist/rpg/register/mcp-prompts.js +684 -0
- package/dist/rpg/register/mcp-resources.d.ts +2 -0
- package/dist/rpg/register/mcp-resources.js +61 -0
- package/dist/rpg/register/quests.d.ts +2 -0
- package/dist/rpg/register/quests.js +118 -0
- package/dist/rpg/register/status.d.ts +2 -0
- package/dist/rpg/register/status.js +130 -0
- package/dist/rpg/register/tables.d.ts +2 -0
- package/dist/rpg/register/tables.js +146 -0
- package/dist/rpg/tools/ability.d.ts +48 -0
- package/dist/rpg/tools/ability.js +238 -0
- package/dist/rpg/tools/combat.d.ts +13 -0
- package/dist/rpg/tools/combat.js +195 -0
- package/dist/rpg/tools/dice.d.ts +23 -0
- package/dist/rpg/tools/dice.js +111 -0
- package/dist/rpg/tools/quest.d.ts +34 -0
- package/dist/rpg/tools/quest.js +164 -0
- package/dist/rpg/tools/status.d.ts +36 -0
- package/dist/rpg/tools/status.js +218 -0
- package/dist/rpg/tools/tables.d.ts +33 -0
- package/dist/rpg/tools/tables.js +209 -0
- package/dist/schemas/index.d.ts +12 -12
- package/dist/timeline/adjudication.d.ts +150 -0
- package/dist/timeline/adjudication.js +174 -0
- package/dist/timeline/changes.d.ts +100 -0
- package/dist/timeline/changes.js +161 -0
- package/dist/timeline/checkpoint.d.ts +69 -0
- package/dist/timeline/checkpoint.js +131 -0
- package/dist/timeline/clock.d.ts +89 -0
- package/dist/timeline/clock.js +173 -0
- package/dist/timeline/constrained.d.ts +220 -0
- package/dist/timeline/constrained.js +671 -0
- package/dist/timeline/export.d.ts +171 -0
- package/dist/timeline/export.js +329 -0
- package/dist/timeline/irreversible.d.ts +85 -0
- package/dist/timeline/irreversible.js +108 -0
- package/dist/timeline/kinds.d.ts +14 -0
- package/dist/timeline/kinds.js +22 -0
- package/dist/timeline/narration.d.ts +175 -0
- package/dist/timeline/narration.js +259 -0
- package/dist/timeline/projection.d.ts +97 -0
- package/dist/timeline/projection.js +330 -0
- package/dist/timeline/provenance.d.ts +66 -0
- package/dist/timeline/provenance.js +45 -0
- package/dist/timeline/registry.d.ts +95 -0
- package/dist/timeline/registry.js +124 -0
- package/dist/timeline/render.d.ts +121 -0
- package/dist/timeline/render.js +187 -0
- package/dist/timeline/replay.d.ts +64 -0
- package/dist/timeline/replay.js +104 -0
- package/dist/timeline/resolve.d.ts +262 -0
- package/dist/timeline/resolve.js +226 -0
- package/dist/timeline/schema.d.ts +13 -0
- package/dist/timeline/schema.js +262 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +31 -6
- package/dist/tools/resource.js +106 -153
- package/dist/types/index.d.ts +19 -1
- package/dist/utils/output-schemas.d.ts +593 -2
- package/dist/utils/output-schemas.js +3 -0
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +20 -4
- package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
- package/dist/__tests__/engineVocabulary.test.js +0 -147
- package/dist/db/__tests__/connection.test.d.ts +0 -1
- package/dist/db/__tests__/connection.test.js +0 -72
- package/dist/db/__tests__/testDb.d.ts +0 -33
- package/dist/db/__tests__/testDb.js +0 -41
- package/dist/test-setup.d.ts +0 -1
- package/dist/test-setup.js +0 -13
- package/dist/tools/__tests__/audio.test.d.ts +0 -1
- package/dist/tools/__tests__/audio.test.js +0 -59
- package/dist/tools/__tests__/conserved.test.d.ts +0 -1
- package/dist/tools/__tests__/conserved.test.js +0 -488
- package/dist/tools/__tests__/constraint.test.d.ts +0 -1
- package/dist/tools/__tests__/constraint.test.js +0 -212
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
- package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
- package/dist/tools/__tests__/images.test.d.ts +0 -1
- package/dist/tools/__tests__/images.test.js +0 -59
- package/dist/tools/__tests__/relationship.test.d.ts +0 -1
- package/dist/tools/__tests__/relationship.test.js +0 -132
- package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
- package/dist/tools/__tests__/resource-constraints.test.js +0 -131
- package/dist/tools/__tests__/resource.test.d.ts +0 -1
- package/dist/tools/__tests__/resource.test.js +0 -190
- package/dist/tools/__tests__/time.test.d.ts +0 -1
- package/dist/tools/__tests__/time.test.js +0 -404
- package/dist/tools/__tests__/timers.test.d.ts +0 -1
- package/dist/tools/__tests__/timers.test.js +0 -426
- package/dist/tools/__tests__/world.test.d.ts +0 -1
- package/dist/tools/__tests__/world.test.js +0 -70
- package/dist/utils/__tests__/json.test.d.ts +0 -1
- package/dist/utils/__tests__/json.test.js +0 -55
- package/dist/utils/__tests__/validation.test.d.ts +0 -1
- package/dist/utils/__tests__/validation.test.js +0 -90
package/dist/utils/webui.d.ts
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port the web UI listens on when nothing says otherwise.
|
|
3
|
+
*/
|
|
4
|
+
export declare const DEFAULT_HTTP_PORT = 3456;
|
|
5
|
+
/**
|
|
6
|
+
* Which port the web UI should listen on, read from an environment rather than
|
|
7
|
+
* from the ambient process, so the answer can be tested from a value.
|
|
8
|
+
*
|
|
9
|
+
* An out-of-range or unparseable DMCP_HTTP_PORT falls back to the default. The
|
|
10
|
+
* inherited code did `parseInt(value || "3456", 10)` and handed the NaN
|
|
11
|
+
* straight to `listen()`, which quietly bound a random port instead -- an
|
|
12
|
+
* operator who mistyped a port got a working server on an address they could
|
|
13
|
+
* not predict. `0` is kept, because that is the real way to ask for any free
|
|
14
|
+
* port.
|
|
15
|
+
*/
|
|
16
|
+
export declare function httpPortFromEnv(env?: NodeJS.ProcessEnv): number;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the executable should serve the web UI.
|
|
19
|
+
*
|
|
20
|
+
* The web UI is an application's feature, not a library's: a process that only
|
|
21
|
+
* needs the engine has no use for an admin page, and one that starts a server
|
|
22
|
+
* squats a port and cannot exit. Importing this package therefore starts
|
|
23
|
+
* nothing at all (see src/index.ts), and the shipped executable serves the
|
|
24
|
+
* web UI by default -- unless DMCP_NO_HTTP says not to, which is what a host
|
|
25
|
+
* that spawns this as an MCP subprocess sets for it.
|
|
26
|
+
*
|
|
27
|
+
* `0`, `false` and an empty value leave it on, because a launcher that writes
|
|
28
|
+
* `DMCP_NO_HTTP=${disabled ? 1 : 0}` means the web UI to run when it writes 0.
|
|
29
|
+
* These are literal tokens in a variable this project defines, not an attempt
|
|
30
|
+
* to read intent out of prose.
|
|
31
|
+
*/
|
|
32
|
+
export declare function webUiEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
1
33
|
/**
|
|
2
34
|
* Set the actual HTTP port after server starts
|
|
3
35
|
*/
|
package/dist/utils/webui.js
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
+
import { createLogger } from "./logger.js";
|
|
2
|
+
const log = createLogger("webui");
|
|
3
|
+
/**
|
|
4
|
+
* Port the web UI listens on when nothing says otherwise.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_HTTP_PORT = 3456;
|
|
7
|
+
/**
|
|
8
|
+
* Which port the web UI should listen on, read from an environment rather than
|
|
9
|
+
* from the ambient process, so the answer can be tested from a value.
|
|
10
|
+
*
|
|
11
|
+
* An out-of-range or unparseable DMCP_HTTP_PORT falls back to the default. The
|
|
12
|
+
* inherited code did `parseInt(value || "3456", 10)` and handed the NaN
|
|
13
|
+
* straight to `listen()`, which quietly bound a random port instead -- an
|
|
14
|
+
* operator who mistyped a port got a working server on an address they could
|
|
15
|
+
* not predict. `0` is kept, because that is the real way to ask for any free
|
|
16
|
+
* port.
|
|
17
|
+
*/
|
|
18
|
+
export function httpPortFromEnv(env = process.env) {
|
|
19
|
+
const raw = env.DMCP_HTTP_PORT;
|
|
20
|
+
if (!raw)
|
|
21
|
+
return DEFAULT_HTTP_PORT;
|
|
22
|
+
const port = Number(raw);
|
|
23
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
24
|
+
log.warn("DMCP_HTTP_PORT is not a port number; using the default", {
|
|
25
|
+
value: raw,
|
|
26
|
+
default: DEFAULT_HTTP_PORT,
|
|
27
|
+
});
|
|
28
|
+
return DEFAULT_HTTP_PORT;
|
|
29
|
+
}
|
|
30
|
+
return port;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether the executable should serve the web UI.
|
|
34
|
+
*
|
|
35
|
+
* The web UI is an application's feature, not a library's: a process that only
|
|
36
|
+
* needs the engine has no use for an admin page, and one that starts a server
|
|
37
|
+
* squats a port and cannot exit. Importing this package therefore starts
|
|
38
|
+
* nothing at all (see src/index.ts), and the shipped executable serves the
|
|
39
|
+
* web UI by default -- unless DMCP_NO_HTTP says not to, which is what a host
|
|
40
|
+
* that spawns this as an MCP subprocess sets for it.
|
|
41
|
+
*
|
|
42
|
+
* `0`, `false` and an empty value leave it on, because a launcher that writes
|
|
43
|
+
* `DMCP_NO_HTTP=${disabled ? 1 : 0}` means the web UI to run when it writes 0.
|
|
44
|
+
* These are literal tokens in a variable this project defines, not an attempt
|
|
45
|
+
* to read intent out of prose.
|
|
46
|
+
*/
|
|
47
|
+
export function webUiEnabled(env = process.env) {
|
|
48
|
+
const raw = env.DMCP_NO_HTTP;
|
|
49
|
+
if (!raw)
|
|
50
|
+
return true;
|
|
51
|
+
const value = raw.trim().toLowerCase();
|
|
52
|
+
return value === "0" || value === "false";
|
|
53
|
+
}
|
|
1
54
|
/**
|
|
2
55
|
* Actual port the HTTP server is running on (set at runtime)
|
|
3
56
|
*/
|
|
@@ -12,7 +65,7 @@ export function setHttpPort(port) {
|
|
|
12
65
|
* Get the base URL for the HTTP web UI
|
|
13
66
|
*/
|
|
14
67
|
export function getWebUiBaseUrl() {
|
|
15
|
-
const port = actualHttpPort ??
|
|
68
|
+
const port = actualHttpPort ?? httpPortFromEnv();
|
|
16
69
|
return `http://localhost:${port}`;
|
|
17
70
|
}
|
|
18
71
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "run-dmcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -10,15 +10,27 @@
|
|
|
10
10
|
},
|
|
11
11
|
"type": "module",
|
|
12
12
|
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./rpg": {
|
|
20
|
+
"types": "./dist/rpg/index.d.ts",
|
|
21
|
+
"default": "./dist/rpg/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
13
25
|
"bin": {
|
|
14
|
-
"run-dmcp": "dist/
|
|
26
|
+
"run-dmcp": "dist/bin/run-dmcp.js"
|
|
15
27
|
},
|
|
16
28
|
"scripts": {
|
|
17
29
|
"build": "tsc && npm run build:client",
|
|
18
30
|
"build:server": "tsc",
|
|
19
31
|
"build:client": "cd client && npm run build",
|
|
20
|
-
"start": "node dist/
|
|
21
|
-
"dev": "tsx src/
|
|
32
|
+
"start": "node dist/bin/run-dmcp.js",
|
|
33
|
+
"dev": "tsx src/bin/run-dmcp.ts",
|
|
22
34
|
"dev:client": "cd client && npm run dev",
|
|
23
35
|
"test": "vitest",
|
|
24
36
|
"test:run": "vitest run",
|
|
@@ -69,6 +81,10 @@
|
|
|
69
81
|
],
|
|
70
82
|
"files": [
|
|
71
83
|
"dist/",
|
|
84
|
+
"!dist/**/__tests__/**",
|
|
85
|
+
"!dist/**/*.test.*",
|
|
86
|
+
"!dist/**/*.spec.*",
|
|
87
|
+
"!dist/test-setup.*",
|
|
72
88
|
"README.md",
|
|
73
89
|
"LICENSE"
|
|
74
90
|
]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
// No client's vocabulary reaches the engine.
|
|
2
|
-
//
|
|
3
|
-
// run-dmcp exists to serve more than one piece of interactive fiction. Two
|
|
4
|
-
// consumers were in the room when it was designed and they are deliberately
|
|
5
|
-
// unlike each other: a geopolitical roleplay whose world advances one turn at a
|
|
6
|
-
// time, and a music-video pipeline whose units have duration and no player at
|
|
7
|
-
// all. Neither may become the reason the engine exists.
|
|
8
|
-
//
|
|
9
|
-
// That intention cannot survive as a rule people remember. The predecessor's
|
|
10
|
-
// own history shows why: `resource_history` and `relationship_history` were
|
|
11
|
-
// added because somebody needed them, and nothing generalised the idea, so the
|
|
12
|
-
// concept of versioning existed for exactly two tables for the life of the
|
|
13
|
-
// project. Good intentions do not generalise; tests do.
|
|
14
|
-
//
|
|
15
|
-
// So the line is a test. It runs in CI on every push, against the actual
|
|
16
|
-
// tracked file list, and it fails when a specific client's language appears in
|
|
17
|
-
// the engine — which is the first observable symptom of "engine with two
|
|
18
|
-
// consumers" decaying into "one client's library with a second bolted on".
|
|
19
|
-
//
|
|
20
|
-
// WHAT THIS IS NOT: a check that the engine is free of narrative vocabulary.
|
|
21
|
-
// Dice, combat, quests and factions are inherited and generic — they belong to
|
|
22
|
-
// the RPG layer above the core, not to any one client. The forbidden list is
|
|
23
|
-
// specifically the vocabulary of the two named consumers, plus terms that could
|
|
24
|
-
// only have come from one of them.
|
|
25
|
-
//
|
|
26
|
-
// WHERE FIXTURES COME FROM: grain, treasury, population. A throwaway vocabulary
|
|
27
|
-
// for exercising the mechanism, never a starter set anyone should build on.
|
|
28
|
-
import { describe, it, expect } from "vitest";
|
|
29
|
-
import { execFileSync } from "node:child_process";
|
|
30
|
-
import { readFileSync } from "node:fs";
|
|
31
|
-
import { resolve, basename } from "node:path";
|
|
32
|
-
const REPO_ROOT = resolve(__dirname, "..", "..");
|
|
33
|
-
/**
|
|
34
|
-
* The design brief is excluded, and only it.
|
|
35
|
-
*
|
|
36
|
-
* That document's entire job is to explain where the boundary between engine
|
|
37
|
-
* and consumer falls, and it cannot do that without naming the consumers on
|
|
38
|
-
* either side of it. Excluding the explanation of a rule is not a hole in the
|
|
39
|
-
* rule. Excluding anything else would be.
|
|
40
|
-
*/
|
|
41
|
-
const EXCLUDED_PATHS = new Set(["docs/DESIGN.md"]);
|
|
42
|
-
function scannedFiles() {
|
|
43
|
-
return execFileSync("git", ["ls-files"], { cwd: REPO_ROOT, encoding: "utf8" })
|
|
44
|
-
.split("\n")
|
|
45
|
-
.filter(Boolean)
|
|
46
|
-
.filter((f) => !EXCLUDED_PATHS.has(f))
|
|
47
|
-
.filter((f) => basename(f) !== "engineVocabulary.test.ts")
|
|
48
|
-
.filter((f) => !/package-lock\.json$/.test(f))
|
|
49
|
-
.filter((f) => !/\.(png|jpe?g|gif|webp|ico|svg|woff2?|ttf|otf|mp3|wav|onnx|pth|bin)$/i.test(f));
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Each entry carries why, because a bare list of forbidden words is exactly the
|
|
53
|
-
* thing a future contributor deletes when it gets in their way.
|
|
54
|
-
*
|
|
55
|
-
* Note the shapes chosen. `\baccords?\b` and not `\baccord` — the latter also
|
|
56
|
-
* matches "accordance" and "according", which are ordinary English. `chunk_id`
|
|
57
|
-
* and not `chunk` — a chunked read is a legitimate generic idea. No bare
|
|
58
|
-
* `shot`: the tree already says "in one shot" and `"wide shot"` as an image
|
|
59
|
-
* framing, both of which are fine. A rule that cries wolf gets deleted, and
|
|
60
|
-
* then it is protecting nothing.
|
|
61
|
-
*/
|
|
62
|
-
const FORBIDDEN = [
|
|
63
|
-
{
|
|
64
|
-
pattern: /\bDEFCON\b/i,
|
|
65
|
-
what: "a consumer's escalation ladder",
|
|
66
|
-
why: "one game's bounded resource. The engine has `bounded` constraints; it has never heard of a ladder.",
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
pattern: /\bprestige\b/i,
|
|
70
|
-
what: "a consumer's conserved resource",
|
|
71
|
-
why: "the engine offers conserved resource SETS. Which resource is conserved, and among whom, is the game's.",
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
pattern: /\bflashpoints?\b/i,
|
|
75
|
-
what: "a consumer's contested location",
|
|
76
|
-
why: "the engine has entities and locations. 'Flashpoint' is a geopolitical reading of one.",
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
pattern: /\bcohesion\b/i,
|
|
80
|
-
what: "a consumer's per-location scalar",
|
|
81
|
-
why: "a resource on a location. The name belongs to the game that decided what it measures.",
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
pattern: /\bcompellence\b|\bARMED_STRIKE\b|\bPRESTIGE_CONTEST\b|\bFLASHPOINT_SEIZURE\b/,
|
|
85
|
-
what: "a consumer's mechanic names",
|
|
86
|
-
why: "mechanics are registered BY a game (design §5.2a). The engine dispatches them and never learns what they mean.",
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
pattern: /\baccords?\b/i,
|
|
90
|
-
what: "a consumer's negotiated agreement",
|
|
91
|
-
why: "the engine has facts with obligations. 'Accord' is one game's word for a bundle of them.",
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
pattern: /\bseats?\b/i,
|
|
95
|
-
what: "a consumer's player positions",
|
|
96
|
-
why: "the engine has entities and (eventually) per-entity visibility. Seats are how one game partitions them.",
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
pattern: /\bcrises\b|\bcrisis\b/i,
|
|
100
|
-
what: "a consumer's timed pressure",
|
|
101
|
-
why: "the engine has scheduled events with on-expiry consequences. What makes one a crisis is the game's fiction.",
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
pattern: /\blyrics?\b|\bchorus\b|\bverses?\b|\bstoryboards?\b/i,
|
|
105
|
-
what: "the other consumer's source material",
|
|
106
|
-
why: "the engine renders state at t. That the state was authored against a song is not its business.",
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
pattern: /\bchunk_id\b|\bchunkId\b|\bshot_id\b|\bshotId\b|\bshot lines?\b/i,
|
|
110
|
-
what: "the other consumer's unit identifiers",
|
|
111
|
-
why: "and these are the ones that must never appear, because that client's own rule is that an index " +
|
|
112
|
-
"into re-segmentable units is the wrong axis (design §5.1). The engine takes `t`, never a unit id.",
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
pattern: /\bgeopolitic(s|al)\b/i,
|
|
116
|
-
what: "the domain of one consumer",
|
|
117
|
-
why: "if the engine describes itself by one client's genre, the second client is already a guest.",
|
|
118
|
-
},
|
|
119
|
-
];
|
|
120
|
-
describe("no client's vocabulary reaches the engine", () => {
|
|
121
|
-
const files = scannedFiles();
|
|
122
|
-
it("scans a meaningful number of tracked files (guard against a vacuous pass)", () => {
|
|
123
|
-
expect(files.length).toBeGreaterThan(50);
|
|
124
|
-
});
|
|
125
|
-
it.each(FORBIDDEN)("contains no $what", ({ pattern, why }) => {
|
|
126
|
-
const offenders = [];
|
|
127
|
-
for (const file of files) {
|
|
128
|
-
let contents;
|
|
129
|
-
try {
|
|
130
|
-
contents = readFileSync(resolve(REPO_ROOT, file), "utf8");
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
contents.split("\n").forEach((line, i) => {
|
|
136
|
-
if (pattern.test(line))
|
|
137
|
-
offenders.push(`${file}:${i + 1}: ${line.trim().slice(0, 100)}`);
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
expect(offenders, `${why}\n\n${offenders.join("\n")}`).toEqual([]);
|
|
141
|
-
});
|
|
142
|
-
it("keeps the design brief excluded, and nothing else", () => {
|
|
143
|
-
// If this list ever grows, the rule is being routed around rather than
|
|
144
|
-
// enforced. One exclusion, for the document that explains the rule.
|
|
145
|
-
expect([...EXCLUDED_PATHS]).toEqual(["docs/DESIGN.md"]);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { createTestDb, destroyTestDb } from './testDb.js';
|
|
3
|
-
import { withTransaction, getDatabase } from '../connection.js';
|
|
4
|
-
import { createGame } from '../../tools/game.js';
|
|
5
|
-
import { createResource, getResource } from '../../tools/resource.js';
|
|
6
|
-
describe('withTransaction', () => {
|
|
7
|
-
let gameId;
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
createTestDb();
|
|
10
|
-
gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
|
|
11
|
-
});
|
|
12
|
-
afterEach(() => {
|
|
13
|
-
destroyTestDb();
|
|
14
|
-
});
|
|
15
|
-
it('commits all writes performed inside the callback when it succeeds', () => {
|
|
16
|
-
const result = withTransaction(() => {
|
|
17
|
-
const a = createResource({ gameId, ownerType: 'game', name: 'grain', value: 1 });
|
|
18
|
-
const b = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 2 });
|
|
19
|
-
return { a, b };
|
|
20
|
-
});
|
|
21
|
-
expect(getResource(result.a.id)?.value).toBe(1);
|
|
22
|
-
expect(getResource(result.b.id)?.value).toBe(2);
|
|
23
|
-
});
|
|
24
|
-
it('returns the callback return value', () => {
|
|
25
|
-
const value = withTransaction(() => 42);
|
|
26
|
-
expect(value).toBe(42);
|
|
27
|
-
});
|
|
28
|
-
it('rolls back every write in the callback when it throws, leaving no partial writes', () => {
|
|
29
|
-
const first = createResource({ gameId, ownerType: 'game', name: 'grain', value: 1 });
|
|
30
|
-
expect(() => {
|
|
31
|
-
withTransaction(() => {
|
|
32
|
-
// This write should succeed at the SQLite level...
|
|
33
|
-
createResource({ gameId, ownerType: 'game', name: 'treasury', value: 2 });
|
|
34
|
-
// ...but the transaction as a whole should never commit because of this throw.
|
|
35
|
-
throw new Error('boom');
|
|
36
|
-
});
|
|
37
|
-
}).toThrow('boom');
|
|
38
|
-
// The pre-existing resource is untouched.
|
|
39
|
-
expect(getResource(first.id)?.value).toBe(1);
|
|
40
|
-
// The resource created inside the doomed transaction must not exist.
|
|
41
|
-
const db = getDatabase();
|
|
42
|
-
const rows = db.prepare('SELECT * FROM resources WHERE name = ?').all('treasury');
|
|
43
|
-
expect(rows).toHaveLength(0);
|
|
44
|
-
});
|
|
45
|
-
it('propagates the original error when rolling back', () => {
|
|
46
|
-
class CustomError extends Error {
|
|
47
|
-
}
|
|
48
|
-
expect(() => withTransaction(() => {
|
|
49
|
-
createResource({ gameId, ownerType: 'game', name: 'grain', value: 1 });
|
|
50
|
-
throw new CustomError('custom failure');
|
|
51
|
-
})).toThrow(CustomError);
|
|
52
|
-
});
|
|
53
|
-
it('rolls back a mid-operation failure across two dependent writes (debit/credit style)', () => {
|
|
54
|
-
const from = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
|
|
55
|
-
const to = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 0 });
|
|
56
|
-
const shouldFailBeforeCredit = true;
|
|
57
|
-
expect(() => {
|
|
58
|
-
withTransaction(() => {
|
|
59
|
-
const db = getDatabase();
|
|
60
|
-
db.prepare('UPDATE resources SET value = value - 10 WHERE id = ?').run(from.id);
|
|
61
|
-
// Simulate a failure that happens after the first write but before the second.
|
|
62
|
-
if (shouldFailBeforeCredit) {
|
|
63
|
-
throw new Error('credit step failed');
|
|
64
|
-
}
|
|
65
|
-
db.prepare('UPDATE resources SET value = value + 10 WHERE id = ?').run(to.id);
|
|
66
|
-
});
|
|
67
|
-
}).toThrow('credit step failed');
|
|
68
|
-
// Neither side of the transfer should have taken effect.
|
|
69
|
-
expect(getResource(from.id)?.value).toBe(100);
|
|
70
|
-
expect(getResource(to.id)?.value).toBe(0);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type Database from "better-sqlite3";
|
|
2
|
-
/**
|
|
3
|
-
* Test fixture: gives each test a clean, fully migrated, isolated SQLite
|
|
4
|
-
* database, backed by an in-memory database rather than a file on disk.
|
|
5
|
-
*
|
|
6
|
-
* Why this is needed: `getDatabase()` in `src/db/connection.ts` caches a
|
|
7
|
-
* single database handle at module scope. Without resetting it, every test
|
|
8
|
-
* in a process would share one connection and bleed state into each other.
|
|
9
|
-
*
|
|
10
|
-
* Usage:
|
|
11
|
-
* let db: Database.Database;
|
|
12
|
-
* beforeEach(() => {
|
|
13
|
-
* db = createTestDb();
|
|
14
|
-
* });
|
|
15
|
-
* afterEach(() => {
|
|
16
|
-
* destroyTestDb();
|
|
17
|
-
* });
|
|
18
|
-
*
|
|
19
|
-
* Call `destroyTestDb()` in `afterEach` (not just before the next
|
|
20
|
-
* `createTestDb()`) so a failed/skipped test doesn't leak its connection
|
|
21
|
-
* into whichever test happens to run next.
|
|
22
|
-
*/
|
|
23
|
-
export declare function createTestDb(): Database.Database;
|
|
24
|
-
/**
|
|
25
|
-
* Tears down the database created by `createTestDb()`.
|
|
26
|
-
*
|
|
27
|
-
* Deliberately does NOT delete `DMCP_DB_PATH`. `src/test-setup.ts` sets it
|
|
28
|
-
* process-wide as a safety net so that any `getDatabase()` call -- including
|
|
29
|
-
* one made outside this fixture, e.g. at module scope in a test file -- can
|
|
30
|
-
* never open the real on-disk database. Clearing it here would punch a hole
|
|
31
|
-
* in that net for every test that ran after the first teardown.
|
|
32
|
-
*/
|
|
33
|
-
export declare function destroyTestDb(): void;
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { closeDatabase, getDatabase } from "../connection.js";
|
|
2
|
-
import { initializeSchema } from "../schema.js";
|
|
3
|
-
/**
|
|
4
|
-
* Test fixture: gives each test a clean, fully migrated, isolated SQLite
|
|
5
|
-
* database, backed by an in-memory database rather than a file on disk.
|
|
6
|
-
*
|
|
7
|
-
* Why this is needed: `getDatabase()` in `src/db/connection.ts` caches a
|
|
8
|
-
* single database handle at module scope. Without resetting it, every test
|
|
9
|
-
* in a process would share one connection and bleed state into each other.
|
|
10
|
-
*
|
|
11
|
-
* Usage:
|
|
12
|
-
* let db: Database.Database;
|
|
13
|
-
* beforeEach(() => {
|
|
14
|
-
* db = createTestDb();
|
|
15
|
-
* });
|
|
16
|
-
* afterEach(() => {
|
|
17
|
-
* destroyTestDb();
|
|
18
|
-
* });
|
|
19
|
-
*
|
|
20
|
-
* Call `destroyTestDb()` in `afterEach` (not just before the next
|
|
21
|
-
* `createTestDb()`) so a failed/skipped test doesn't leak its connection
|
|
22
|
-
* into whichever test happens to run next.
|
|
23
|
-
*/
|
|
24
|
-
export function createTestDb() {
|
|
25
|
-
process.env.DMCP_DB_PATH = ":memory:";
|
|
26
|
-
const db = getDatabase();
|
|
27
|
-
initializeSchema();
|
|
28
|
-
return db;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Tears down the database created by `createTestDb()`.
|
|
32
|
-
*
|
|
33
|
-
* Deliberately does NOT delete `DMCP_DB_PATH`. `src/test-setup.ts` sets it
|
|
34
|
-
* process-wide as a safety net so that any `getDatabase()` call -- including
|
|
35
|
-
* one made outside this fixture, e.g. at module scope in a test file -- can
|
|
36
|
-
* never open the real on-disk database. Clearing it here would punch a hole
|
|
37
|
-
* in that net for every test that ran after the first teardown.
|
|
38
|
-
*/
|
|
39
|
-
export function destroyTestDb() {
|
|
40
|
-
closeDatabase();
|
|
41
|
-
}
|
package/dist/test-setup.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/test-setup.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
// Vitest setupFiles entry (see vitest.config.ts): a process-wide safety net
|
|
2
|
-
// guaranteeing no test can ever open the real on-disk database.
|
|
3
|
-
//
|
|
4
|
-
// `getDatabase()` resolves DMCP_DB_PATH lazily, so the per-test fixture in
|
|
5
|
-
// src/db/__tests__/testDb.ts is normally sufficient. This exists for the
|
|
6
|
-
// cases the fixture cannot cover -- a `getDatabase()` reached at module scope,
|
|
7
|
-
// or from code a test imports before its own `beforeEach` runs. A setupFiles
|
|
8
|
-
// module is imported before the test file itself, so this lands first.
|
|
9
|
-
//
|
|
10
|
-
// Nothing should ever `delete` this env var mid-run; doing so re-opens the
|
|
11
|
-
// hole for every subsequent test.
|
|
12
|
-
process.env.DMCP_DB_PATH = ":memory:";
|
|
13
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
-
import { createTestDb, destroyTestDb } from '../../db/__tests__/testDb.js';
|
|
4
|
-
import { getDatabase } from '../../db/connection.js';
|
|
5
|
-
import { createGame } from '../game.js';
|
|
6
|
-
import { createCharacter } from '../character.js';
|
|
7
|
-
import { getAudio, setPrimaryAudio } from '../audio.js';
|
|
8
|
-
// Inserts a stored_audio row directly via SQL so these tests can exercise
|
|
9
|
-
// setPrimaryAudio() without going through storeAudio()'s file I/O / network
|
|
10
|
-
// fetch, which is unrelated to the atomicity behavior under test here.
|
|
11
|
-
function insertStoredAudio(params) {
|
|
12
|
-
const db = getDatabase();
|
|
13
|
-
const id = uuidv4();
|
|
14
|
-
db.prepare(`
|
|
15
|
-
INSERT INTO stored_audio (id, game_id, entity_id, entity_type, file_path, file_size, mime_type, source, is_primary, created_at)
|
|
16
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
17
|
-
`).run(id, params.gameId, params.entityId, params.entityType, `${id}.mp3`, 100, 'audio/mpeg', 'uploaded', params.isPrimary ? 1 : 0, new Date().toISOString());
|
|
18
|
-
return id;
|
|
19
|
-
}
|
|
20
|
-
describe('setPrimaryAudio', () => {
|
|
21
|
-
let gameId;
|
|
22
|
-
let characterId;
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
createTestDb();
|
|
25
|
-
gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
|
|
26
|
-
characterId = createCharacter({ gameId, name: 'Narrator', isPlayer: false }).id;
|
|
27
|
-
});
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
destroyTestDb();
|
|
30
|
-
});
|
|
31
|
-
it('unsets the previous primary and sets the new one', () => {
|
|
32
|
-
const first = insertStoredAudio({ gameId, entityId: characterId, entityType: 'character', isPrimary: true });
|
|
33
|
-
const second = insertStoredAudio({ gameId, entityId: characterId, entityType: 'character', isPrimary: false });
|
|
34
|
-
setPrimaryAudio(second);
|
|
35
|
-
expect(getAudio(first)?.isPrimary).toBe(false);
|
|
36
|
-
expect(getAudio(second)?.isPrimary).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
it('leaves NO partial write when the second update fails mid-operation: the old primary stays primary', () => {
|
|
39
|
-
const first = insertStoredAudio({ gameId, entityId: characterId, entityType: 'character', isPrimary: true });
|
|
40
|
-
const second = insertStoredAudio({ gameId, entityId: characterId, entityType: 'character', isPrimary: false });
|
|
41
|
-
// Fault injection: make the UPDATE that sets `second` as primary fail,
|
|
42
|
-
// simulating a mid-operation failure between the unset and the set.
|
|
43
|
-
const db = getDatabase();
|
|
44
|
-
db.exec(`
|
|
45
|
-
CREATE TRIGGER fail_set_new_primary_audio
|
|
46
|
-
BEFORE UPDATE ON stored_audio
|
|
47
|
-
WHEN NEW.id = '${second}' AND NEW.is_primary = 1
|
|
48
|
-
BEGIN
|
|
49
|
-
SELECT RAISE(ABORT, 'simulated failure');
|
|
50
|
-
END;
|
|
51
|
-
`);
|
|
52
|
-
expect(() => setPrimaryAudio(second)).toThrow();
|
|
53
|
-
// Without a real transaction, the first UPDATE (unsetting `first`)
|
|
54
|
-
// would have already committed, leaving NO primary at all. With a
|
|
55
|
-
// transaction, `first` must still be primary.
|
|
56
|
-
expect(getAudio(first)?.isPrimary).toBe(true);
|
|
57
|
-
expect(getAudio(second)?.isPrimary).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|