run-dmcp 0.1.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/LICENSE +22 -0
- package/README.md +79 -0
- package/dist/__tests__/engineVocabulary.test.d.ts +1 -0
- package/dist/__tests__/engineVocabulary.test.js +147 -0
- package/dist/db/__tests__/connection.test.d.ts +1 -0
- package/dist/db/__tests__/connection.test.js +72 -0
- package/dist/db/__tests__/testDb.d.ts +33 -0
- package/dist/db/__tests__/testDb.js +41 -0
- package/dist/db/connection.d.ts +22 -0
- package/dist/db/connection.js +107 -0
- package/dist/db/schema.d.ts +1 -0
- package/dist/db/schema.js +725 -0
- package/dist/events/emitter.d.ts +22 -0
- package/dist/events/emitter.js +71 -0
- package/dist/http/server.d.ts +3 -0
- package/dist/http/server.js +649 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +92 -0
- package/dist/register/abilities.d.ts +2 -0
- package/dist/register/abilities.js +165 -0
- package/dist/register/audio.d.ts +2 -0
- package/dist/register/audio.js +326 -0
- package/dist/register/batch.d.ts +2 -0
- package/dist/register/batch.js +343 -0
- package/dist/register/character.d.ts +2 -0
- package/dist/register/character.js +324 -0
- package/dist/register/combat.d.ts +2 -0
- package/dist/register/combat.js +207 -0
- package/dist/register/core.d.ts +2 -0
- package/dist/register/core.js +1040 -0
- package/dist/register/display.d.ts +2 -0
- package/dist/register/display.js +263 -0
- package/dist/register/factions.d.ts +2 -0
- package/dist/register/factions.js +186 -0
- package/dist/register/images.d.ts +2 -0
- package/dist/register/images.js +400 -0
- package/dist/register/inventory.d.ts +2 -0
- package/dist/register/inventory.js +115 -0
- package/dist/register/mcp-prompts.d.ts +2 -0
- package/dist/register/mcp-prompts.js +684 -0
- package/dist/register/mcp-resources.d.ts +2 -0
- package/dist/register/mcp-resources.js +335 -0
- package/dist/register/narrative.d.ts +2 -0
- package/dist/register/narrative.js +242 -0
- package/dist/register/notes.d.ts +2 -0
- package/dist/register/notes.js +170 -0
- package/dist/register/pause.d.ts +2 -0
- package/dist/register/pause.js +580 -0
- package/dist/register/quests.d.ts +2 -0
- package/dist/register/quests.js +118 -0
- package/dist/register/relationships.d.ts +2 -0
- package/dist/register/relationships.js +147 -0
- package/dist/register/resources.d.ts +2 -0
- package/dist/register/resources.js +277 -0
- package/dist/register/secrets.d.ts +2 -0
- package/dist/register/secrets.js +192 -0
- package/dist/register/status.d.ts +2 -0
- package/dist/register/status.js +130 -0
- package/dist/register/tables.d.ts +2 -0
- package/dist/register/tables.js +146 -0
- package/dist/register/tags.d.ts +2 -0
- package/dist/register/tags.js +114 -0
- package/dist/register/time.d.ts +2 -0
- package/dist/register/time.js +281 -0
- package/dist/register/world.d.ts +2 -0
- package/dist/register/world.js +127 -0
- package/dist/schemas/index.d.ts +921 -0
- package/dist/schemas/index.js +121 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +13 -0
- package/dist/tools/__tests__/audio.test.d.ts +1 -0
- package/dist/tools/__tests__/audio.test.js +59 -0
- package/dist/tools/__tests__/conserved.test.d.ts +1 -0
- package/dist/tools/__tests__/conserved.test.js +488 -0
- package/dist/tools/__tests__/constraint.test.d.ts +1 -0
- package/dist/tools/__tests__/constraint.test.js +212 -0
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +1 -0
- package/dist/tools/__tests__/expiry-consequences.test.js +110 -0
- package/dist/tools/__tests__/images.test.d.ts +1 -0
- package/dist/tools/__tests__/images.test.js +59 -0
- package/dist/tools/__tests__/relationship.test.d.ts +1 -0
- package/dist/tools/__tests__/relationship.test.js +132 -0
- package/dist/tools/__tests__/resource-constraints.test.d.ts +1 -0
- package/dist/tools/__tests__/resource-constraints.test.js +131 -0
- package/dist/tools/__tests__/resource.test.d.ts +1 -0
- package/dist/tools/__tests__/resource.test.js +190 -0
- package/dist/tools/__tests__/time.test.d.ts +1 -0
- package/dist/tools/__tests__/time.test.js +404 -0
- package/dist/tools/__tests__/timers.test.d.ts +1 -0
- package/dist/tools/__tests__/timers.test.js +426 -0
- package/dist/tools/__tests__/world.test.d.ts +1 -0
- package/dist/tools/__tests__/world.test.js +70 -0
- package/dist/tools/ability.d.ts +48 -0
- package/dist/tools/ability.js +238 -0
- package/dist/tools/audio.d.ts +24 -0
- package/dist/tools/audio.js +365 -0
- package/dist/tools/character.d.ts +70 -0
- package/dist/tools/character.js +309 -0
- package/dist/tools/combat.d.ts +13 -0
- package/dist/tools/combat.js +195 -0
- package/dist/tools/constraint.d.ts +132 -0
- package/dist/tools/constraint.js +269 -0
- package/dist/tools/dice.d.ts +23 -0
- package/dist/tools/dice.js +111 -0
- package/dist/tools/display.d.ts +120 -0
- package/dist/tools/display.js +528 -0
- package/dist/tools/faction.d.ts +61 -0
- package/dist/tools/faction.js +269 -0
- package/dist/tools/game.d.ts +96 -0
- package/dist/tools/game.js +526 -0
- package/dist/tools/image-prompt.d.ts +49 -0
- package/dist/tools/image-prompt.js +479 -0
- package/dist/tools/images.d.ts +47 -0
- package/dist/tools/images.js +449 -0
- package/dist/tools/inventory.d.ts +20 -0
- package/dist/tools/inventory.js +145 -0
- package/dist/tools/narrative.d.ts +58 -0
- package/dist/tools/narrative.js +237 -0
- package/dist/tools/notes.d.ts +41 -0
- package/dist/tools/notes.js +220 -0
- package/dist/tools/pause.d.ts +110 -0
- package/dist/tools/pause.js +1254 -0
- package/dist/tools/quest.d.ts +34 -0
- package/dist/tools/quest.js +164 -0
- package/dist/tools/relationship.d.ts +74 -0
- package/dist/tools/relationship.js +324 -0
- package/dist/tools/resource.d.ts +93 -0
- package/dist/tools/resource.js +374 -0
- package/dist/tools/rules.d.ts +4 -0
- package/dist/tools/rules.js +30 -0
- package/dist/tools/secrets.d.ts +49 -0
- package/dist/tools/secrets.js +195 -0
- package/dist/tools/status.d.ts +36 -0
- package/dist/tools/status.js +218 -0
- package/dist/tools/tables.d.ts +33 -0
- package/dist/tools/tables.js +209 -0
- package/dist/tools/tags.d.ts +52 -0
- package/dist/tools/tags.js +176 -0
- package/dist/tools/time.d.ts +33 -0
- package/dist/tools/time.js +276 -0
- package/dist/tools/timers.d.ts +41 -0
- package/dist/tools/timers.js +215 -0
- package/dist/tools/world.d.ts +78 -0
- package/dist/tools/world.js +331 -0
- package/dist/types/index.d.ts +969 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/__tests__/json.test.d.ts +1 -0
- package/dist/utils/__tests__/json.test.js +55 -0
- package/dist/utils/__tests__/validation.test.d.ts +1 -0
- package/dist/utils/__tests__/validation.test.js +90 -0
- package/dist/utils/errors.d.ts +44 -0
- package/dist/utils/errors.js +121 -0
- package/dist/utils/json.d.ts +9 -0
- package/dist/utils/json.js +23 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.js +50 -0
- package/dist/utils/output-schemas.d.ts +594 -0
- package/dist/utils/output-schemas.js +331 -0
- package/dist/utils/tool-annotations.d.ts +147 -0
- package/dist/utils/tool-annotations.js +98 -0
- package/dist/utils/validation.d.ts +34 -0
- package/dist/utils/validation.js +52 -0
- package/dist/utils/verbosity.d.ts +57 -0
- package/dist/utils/verbosity.js +67 -0
- package/dist/utils/webui.d.ts +20 -0
- package/dist/utils/webui.js +35 -0
- package/package.json +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Shawn Rushefsky (DMCP, the project this continues)
|
|
4
|
+
Copyright (c) 2026 Derek Ferguson (run-dmcp)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# run-dmcp
|
|
2
|
+
|
|
3
|
+
**An MCP server for LLM-run interactive fiction where the server owns what is true — including when
|
|
4
|
+
it was true.**
|
|
5
|
+
|
|
6
|
+
> The model classifies and narrates. The server resolves, remembers, and can say what the world
|
|
7
|
+
> looked like at any point in its history. The narrator may be as florid as it likes about *the how*;
|
|
8
|
+
> it can never lie about *the what*, or about *the when*.
|
|
9
|
+
|
|
10
|
+
Both halves of that are deliberate reversals of the design this project continues, in which the model
|
|
11
|
+
holds full adjudication discretion over a world that only ever stores *now*.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
**0.1.0 — the foundation, not the thesis.** What ships today is the predecessor's engine plus four
|
|
16
|
+
pieces of generic mechanism that were built for it and offered back to it:
|
|
17
|
+
|
|
18
|
+
- **Atomicity** — `withTransaction()` wired into the multi-write operations that were running
|
|
19
|
+
non-atomically.
|
|
20
|
+
- **Declarative constraints** — resources can be declared `bounded`, `monotonic` or `resolve_only`,
|
|
21
|
+
and the store enforces it rather than trusting every caller.
|
|
22
|
+
- **Conserved resource sets** — a set of resources can be declared conserved, with an atomic transfer
|
|
23
|
+
that never silently clamps.
|
|
24
|
+
- **On-expiry consequences** — scheduled events and timers can carry a consequence that actually
|
|
25
|
+
lands when they expire, rather than expiring into nothing.
|
|
26
|
+
|
|
27
|
+
The timeline that gives this project its reason to exist — interval-versioned facts, `replay(t)`,
|
|
28
|
+
irreversibility, `changes_within` — is designed and accepted but **not yet built**. See
|
|
29
|
+
[docs/DESIGN.md](docs/DESIGN.md), which is the authority, and §11 for the order things land in.
|
|
30
|
+
|
|
31
|
+
## Provenance
|
|
32
|
+
|
|
33
|
+
This continues [DMCP](https://github.com/shawnrushefsky/dmcp) by Shawn Rushefsky (MIT), whose last
|
|
34
|
+
commit was 2026-01-06. This repository carries its full history rather than a squashed root: the
|
|
35
|
+
first commit here is his, from 2025-12-30.
|
|
36
|
+
|
|
37
|
+
It is **not** a GitHub fork, deliberately — a repository inside another's fork network is discoverable
|
|
38
|
+
only as "a fork of" and cannot own its issue tracker. The four offers listed above stand as open pull
|
|
39
|
+
requests against the original and remain valid there; they are merged here because this project
|
|
40
|
+
cannot wait on a repository that has not moved in seven months.
|
|
41
|
+
|
|
42
|
+
MIT, and his copyright notice travels with the code. See [LICENSE](LICENSE).
|
|
43
|
+
|
|
44
|
+
## The one rule that shapes everything else
|
|
45
|
+
|
|
46
|
+
This engine serves more than one piece of interactive fiction. Two consumers were in the room when it
|
|
47
|
+
was designed, chosen to be unlike each other: one whose world advances a turn at a time and has a
|
|
48
|
+
player making uncertain decisions, and one with no player at all, whose units have *duration* and are
|
|
49
|
+
entirely known in advance.
|
|
50
|
+
|
|
51
|
+
Neither may become the reason the engine exists. That intention does not survive as a rule people
|
|
52
|
+
remember, so it is a test: `src/__tests__/engineVocabulary.test.ts` fails CI when a specific
|
|
53
|
+
consumer's language appears anywhere in the engine. Fixtures use grain, treasury and population — a
|
|
54
|
+
throwaway vocabulary for exercising mechanism, never a starter set.
|
|
55
|
+
|
|
56
|
+
**Core membership is "generic, with at least one real caller"** — not "needed by every consumer", and
|
|
57
|
+
not "sounds general". Nothing enters the core against an imagined client.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm ci
|
|
63
|
+
cd client && npm ci && cd ..
|
|
64
|
+
|
|
65
|
+
npm run lint # eslint
|
|
66
|
+
npm run typecheck # tsc --noEmit
|
|
67
|
+
npm run test:run # vitest, one shot
|
|
68
|
+
npm run build # tsc + client build
|
|
69
|
+
|
|
70
|
+
npm run dev # tsx src/index.ts
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
CI runs lint, typecheck, tests and build on every push, for both the server and the client.
|
|
74
|
+
|
|
75
|
+
Local MCP inspection: `npx @modelcontextprotocol/inspector node dist/index.js`
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
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;
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export declare function getDatabase(): Database.Database;
|
|
3
|
+
export declare function closeDatabase(): void;
|
|
4
|
+
/**
|
|
5
|
+
* Execute multiple operations atomically within a single SQLite transaction.
|
|
6
|
+
*
|
|
7
|
+
* better-sqlite3 transactions are synchronous: `fn` must not be `async` and
|
|
8
|
+
* must not contain `await`. Pass a plain function that only performs
|
|
9
|
+
* synchronous statement executions. If `fn` throws, better-sqlite3 rolls
|
|
10
|
+
* back everything it did and the original error propagates -- no partial
|
|
11
|
+
* writes are left behind. If `fn` returns normally, the transaction commits
|
|
12
|
+
* and its return value is passed through.
|
|
13
|
+
*/
|
|
14
|
+
export declare function withTransaction<T>(fn: () => T): T;
|
|
15
|
+
/**
|
|
16
|
+
* Get the current database path (useful for debugging/logging).
|
|
17
|
+
*/
|
|
18
|
+
export declare function getDatabasePath(): string;
|
|
19
|
+
/**
|
|
20
|
+
* Get the current data directory (for images and other media).
|
|
21
|
+
*/
|
|
22
|
+
export declare function getDataDir(): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
import { mkdirSync, existsSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { createLogger } from "../utils/logger.js";
|
|
7
|
+
const log = createLogger("db");
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
/**
|
|
11
|
+
* Resolve database path with the following priority:
|
|
12
|
+
* 1. DMCP_DB_PATH environment variable (absolute path)
|
|
13
|
+
* 2. XDG_DATA_HOME/dmcp/games.db (Linux/macOS standard)
|
|
14
|
+
* 3. Fallback to ./data/games.db (relative to project)
|
|
15
|
+
*/
|
|
16
|
+
function resolveDataPath() {
|
|
17
|
+
// Priority 1: Explicit environment variable
|
|
18
|
+
if (process.env.DMCP_DB_PATH) {
|
|
19
|
+
const dbPath = process.env.DMCP_DB_PATH;
|
|
20
|
+
const dataDir = dirname(dbPath);
|
|
21
|
+
log.info("Using database path from DMCP_DB_PATH", { dbPath });
|
|
22
|
+
return { dataDir, dbPath };
|
|
23
|
+
}
|
|
24
|
+
// Priority 2: XDG Base Directory spec
|
|
25
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
|
|
26
|
+
const xdgDataDir = join(xdgDataHome, "dmcp");
|
|
27
|
+
const xdgDbPath = join(xdgDataDir, "games.db");
|
|
28
|
+
// Use XDG path if it exists or if we're not in a development context
|
|
29
|
+
if (existsSync(xdgDataDir) || !existsSync(join(__dirname, "..", "..", "package.json"))) {
|
|
30
|
+
log.info("Using XDG data directory", { dbPath: xdgDbPath });
|
|
31
|
+
return { dataDir: xdgDataDir, dbPath: xdgDbPath };
|
|
32
|
+
}
|
|
33
|
+
// Priority 3: Fallback to project-relative path (development)
|
|
34
|
+
const fallbackDataDir = join(__dirname, "..", "..", "data");
|
|
35
|
+
const fallbackDbPath = join(fallbackDataDir, "games.db");
|
|
36
|
+
log.debug("Using project-relative data directory", { dbPath: fallbackDbPath });
|
|
37
|
+
return { dataDir: fallbackDataDir, dbPath: fallbackDbPath };
|
|
38
|
+
}
|
|
39
|
+
// Resolved lazily (on first getDatabase() call after startup, or after every
|
|
40
|
+
// closeDatabase()) rather than once at module-import time. This makes
|
|
41
|
+
// DMCP_DB_PATH re-readable at any point before the first real connection is
|
|
42
|
+
// opened -- in particular it lets tests point each database at a fresh
|
|
43
|
+
// in-memory instance without fighting ES module caching.
|
|
44
|
+
let db = null;
|
|
45
|
+
let DATA_DIR;
|
|
46
|
+
let DB_PATH;
|
|
47
|
+
function ensurePathsResolved() {
|
|
48
|
+
if (DATA_DIR === undefined || DB_PATH === undefined) {
|
|
49
|
+
const resolved = resolveDataPath();
|
|
50
|
+
DATA_DIR = resolved.dataDir;
|
|
51
|
+
DB_PATH = resolved.dbPath;
|
|
52
|
+
}
|
|
53
|
+
return { dataDir: DATA_DIR, dbPath: DB_PATH };
|
|
54
|
+
}
|
|
55
|
+
export function getDatabase() {
|
|
56
|
+
if (!db) {
|
|
57
|
+
const { dataDir, dbPath } = ensurePathsResolved();
|
|
58
|
+
// In-memory databases have no directory or file to create on disk.
|
|
59
|
+
if (dbPath !== ":memory:" && !existsSync(dataDir)) {
|
|
60
|
+
mkdirSync(dataDir, { recursive: true });
|
|
61
|
+
log.info("Created data directory", { path: dataDir });
|
|
62
|
+
}
|
|
63
|
+
db = new Database(dbPath);
|
|
64
|
+
db.pragma("journal_mode = WAL");
|
|
65
|
+
db.pragma("foreign_keys = ON");
|
|
66
|
+
log.info("Database connection established", { path: dbPath });
|
|
67
|
+
}
|
|
68
|
+
return db;
|
|
69
|
+
}
|
|
70
|
+
export function closeDatabase() {
|
|
71
|
+
if (db) {
|
|
72
|
+
db.close();
|
|
73
|
+
db = null;
|
|
74
|
+
log.info("Database connection closed");
|
|
75
|
+
}
|
|
76
|
+
// Forget the resolved paths too, so a subsequent getDatabase() call
|
|
77
|
+
// re-reads DMCP_DB_PATH instead of reusing a stale value. This is what
|
|
78
|
+
// lets tests swap DMCP_DB_PATH between runs and get an isolated database.
|
|
79
|
+
DATA_DIR = undefined;
|
|
80
|
+
DB_PATH = undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Execute multiple operations atomically within a single SQLite transaction.
|
|
84
|
+
*
|
|
85
|
+
* better-sqlite3 transactions are synchronous: `fn` must not be `async` and
|
|
86
|
+
* must not contain `await`. Pass a plain function that only performs
|
|
87
|
+
* synchronous statement executions. If `fn` throws, better-sqlite3 rolls
|
|
88
|
+
* back everything it did and the original error propagates -- no partial
|
|
89
|
+
* writes are left behind. If `fn` returns normally, the transaction commits
|
|
90
|
+
* and its return value is passed through.
|
|
91
|
+
*/
|
|
92
|
+
export function withTransaction(fn) {
|
|
93
|
+
const database = getDatabase();
|
|
94
|
+
return database.transaction(fn)();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Get the current database path (useful for debugging/logging).
|
|
98
|
+
*/
|
|
99
|
+
export function getDatabasePath() {
|
|
100
|
+
return ensurePathsResolved().dbPath;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get the current data directory (for images and other media).
|
|
104
|
+
*/
|
|
105
|
+
export function getDataDir() {
|
|
106
|
+
return ensurePathsResolved().dataDir;
|
|
107
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function initializeSchema(): void;
|