run-dmcp 0.1.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.
Files changed (141) hide show
  1. package/README.md +101 -11
  2. package/dist/bin/run-dmcp.d.ts +2 -0
  3. package/dist/bin/run-dmcp.js +55 -0
  4. package/dist/db/connection.d.ts +32 -0
  5. package/dist/db/connection.js +38 -16
  6. package/dist/db/schema.d.ts +29 -1
  7. package/dist/db/schema.js +594 -10
  8. package/dist/http/server.js +25 -4
  9. package/dist/index.d.ts +69 -2
  10. package/dist/index.js +262 -92
  11. package/dist/mcp-server.d.ts +49 -0
  12. package/dist/mcp-server.js +127 -0
  13. package/dist/reader/turnReader.d.ts +185 -0
  14. package/dist/reader/turnReader.js +288 -0
  15. package/dist/register/batch.js +5 -79
  16. package/dist/register/mcp-resources.d.ts +9 -0
  17. package/dist/register/mcp-resources.js +16 -62
  18. package/dist/register/render.d.ts +17 -0
  19. package/dist/register/render.js +50 -0
  20. package/dist/register/resolve.d.ts +14 -0
  21. package/dist/register/resolve.js +102 -0
  22. package/dist/register/resources.js +13 -6
  23. package/dist/register/timeline.d.ts +2 -0
  24. package/dist/register/timeline.js +311 -0
  25. package/dist/rpg/index.d.ts +29 -0
  26. package/dist/rpg/index.js +55 -0
  27. package/dist/rpg/register/abilities.d.ts +2 -0
  28. package/dist/rpg/register/abilities.js +165 -0
  29. package/dist/rpg/register/batch.d.ts +2 -0
  30. package/dist/rpg/register/batch.js +92 -0
  31. package/dist/rpg/register/combat.d.ts +2 -0
  32. package/dist/rpg/register/combat.js +207 -0
  33. package/dist/rpg/register/mcp-prompts.d.ts +2 -0
  34. package/dist/rpg/register/mcp-prompts.js +684 -0
  35. package/dist/rpg/register/mcp-resources.d.ts +2 -0
  36. package/dist/rpg/register/mcp-resources.js +61 -0
  37. package/dist/rpg/register/quests.d.ts +2 -0
  38. package/dist/rpg/register/quests.js +118 -0
  39. package/dist/rpg/register/status.d.ts +2 -0
  40. package/dist/rpg/register/status.js +130 -0
  41. package/dist/rpg/register/tables.d.ts +2 -0
  42. package/dist/rpg/register/tables.js +146 -0
  43. package/dist/rpg/tools/ability.d.ts +48 -0
  44. package/dist/rpg/tools/ability.js +238 -0
  45. package/dist/rpg/tools/combat.d.ts +13 -0
  46. package/dist/rpg/tools/combat.js +195 -0
  47. package/dist/rpg/tools/dice.d.ts +23 -0
  48. package/dist/rpg/tools/dice.js +111 -0
  49. package/dist/rpg/tools/quest.d.ts +34 -0
  50. package/dist/rpg/tools/quest.js +164 -0
  51. package/dist/rpg/tools/status.d.ts +36 -0
  52. package/dist/rpg/tools/status.js +218 -0
  53. package/dist/rpg/tools/tables.d.ts +33 -0
  54. package/dist/rpg/tools/tables.js +209 -0
  55. package/dist/schemas/index.d.ts +12 -12
  56. package/dist/timeline/adjudication.d.ts +150 -0
  57. package/dist/timeline/adjudication.js +174 -0
  58. package/dist/timeline/changes.d.ts +108 -0
  59. package/dist/timeline/changes.js +169 -0
  60. package/dist/timeline/checkpoint.d.ts +69 -0
  61. package/dist/timeline/checkpoint.js +131 -0
  62. package/dist/timeline/clock.d.ts +89 -0
  63. package/dist/timeline/clock.js +173 -0
  64. package/dist/timeline/constrained.d.ts +220 -0
  65. package/dist/timeline/constrained.js +671 -0
  66. package/dist/timeline/export.d.ts +181 -0
  67. package/dist/timeline/export.js +339 -0
  68. package/dist/timeline/irreversible.d.ts +87 -0
  69. package/dist/timeline/irreversible.js +108 -0
  70. package/dist/timeline/kinds.d.ts +14 -0
  71. package/dist/timeline/kinds.js +22 -0
  72. package/dist/timeline/narration.d.ts +175 -0
  73. package/dist/timeline/narration.js +259 -0
  74. package/dist/timeline/projection.d.ts +97 -0
  75. package/dist/timeline/projection.js +330 -0
  76. package/dist/timeline/provenance.d.ts +66 -0
  77. package/dist/timeline/provenance.js +45 -0
  78. package/dist/timeline/registry.d.ts +95 -0
  79. package/dist/timeline/registry.js +124 -0
  80. package/dist/timeline/render.d.ts +121 -0
  81. package/dist/timeline/render.js +187 -0
  82. package/dist/timeline/replay.d.ts +86 -0
  83. package/dist/timeline/replay.js +126 -0
  84. package/dist/timeline/resolve.d.ts +262 -0
  85. package/dist/timeline/resolve.js +226 -0
  86. package/dist/timeline/schema.d.ts +13 -0
  87. package/dist/timeline/schema.js +264 -0
  88. package/dist/timeline/t.d.ts +80 -0
  89. package/dist/timeline/t.js +37 -0
  90. package/dist/tools/audio.js +13 -9
  91. package/dist/tools/constraint.d.ts +44 -80
  92. package/dist/tools/constraint.js +115 -124
  93. package/dist/tools/game.js +33 -1
  94. package/dist/tools/images.js +17 -10
  95. package/dist/tools/relationship.d.ts +83 -2
  96. package/dist/tools/relationship.js +139 -62
  97. package/dist/tools/resource.d.ts +33 -8
  98. package/dist/tools/resource.js +106 -153
  99. package/dist/tools/time.js +18 -3
  100. package/dist/types/index.d.ts +20 -2
  101. package/dist/utils/media-path.d.ts +52 -0
  102. package/dist/utils/media-path.js +106 -0
  103. package/dist/utils/output-schemas.d.ts +594 -3
  104. package/dist/utils/output-schemas.js +4 -1
  105. package/dist/utils/webui.d.ts +32 -0
  106. package/dist/utils/webui.js +54 -1
  107. package/package.json +25 -5
  108. package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
  109. package/dist/__tests__/engineVocabulary.test.js +0 -147
  110. package/dist/db/__tests__/connection.test.d.ts +0 -1
  111. package/dist/db/__tests__/connection.test.js +0 -72
  112. package/dist/db/__tests__/testDb.d.ts +0 -33
  113. package/dist/db/__tests__/testDb.js +0 -41
  114. package/dist/test-setup.d.ts +0 -1
  115. package/dist/test-setup.js +0 -13
  116. package/dist/tools/__tests__/audio.test.d.ts +0 -1
  117. package/dist/tools/__tests__/audio.test.js +0 -59
  118. package/dist/tools/__tests__/conserved.test.d.ts +0 -1
  119. package/dist/tools/__tests__/conserved.test.js +0 -488
  120. package/dist/tools/__tests__/constraint.test.d.ts +0 -1
  121. package/dist/tools/__tests__/constraint.test.js +0 -212
  122. package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
  123. package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
  124. package/dist/tools/__tests__/images.test.d.ts +0 -1
  125. package/dist/tools/__tests__/images.test.js +0 -59
  126. package/dist/tools/__tests__/relationship.test.d.ts +0 -1
  127. package/dist/tools/__tests__/relationship.test.js +0 -132
  128. package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
  129. package/dist/tools/__tests__/resource-constraints.test.js +0 -131
  130. package/dist/tools/__tests__/resource.test.d.ts +0 -1
  131. package/dist/tools/__tests__/resource.test.js +0 -190
  132. package/dist/tools/__tests__/time.test.d.ts +0 -1
  133. package/dist/tools/__tests__/time.test.js +0 -404
  134. package/dist/tools/__tests__/timers.test.d.ts +0 -1
  135. package/dist/tools/__tests__/timers.test.js +0 -426
  136. package/dist/tools/__tests__/world.test.d.ts +0 -1
  137. package/dist/tools/__tests__/world.test.js +0 -70
  138. package/dist/utils/__tests__/json.test.d.ts +0 -1
  139. package/dist/utils/__tests__/json.test.js +0 -55
  140. package/dist/utils/__tests__/validation.test.d.ts +0 -1
  141. package/dist/utils/__tests__/validation.test.js +0 -90
@@ -0,0 +1,124 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ /**
3
+ * The read side of the resource-constraint registry (design §5.3 / §5.4
4
+ * option (C)): declarative, opt-in, server-enforced invariants on numeric
5
+ * fact keys, keyed on `(entityId, factKey)` rather than on an entity id
6
+ * alone.
7
+ *
8
+ * This lives in src/timeline/ -- not src/tools/constraint.ts, where the code
9
+ * below used to live -- so that the generic write choke point Phase 3
10
+ * introduces next (also under src/timeline/) can consult it WITHOUT
11
+ * importing anything from src/tools/. That import direction is what would
12
+ * otherwise close a cycle: src/tools/constraint.ts already imports
13
+ * src/tools/resource.ts (for getResource()), and the future choke point
14
+ * will need to check what's declared here before it writes a fact -- if the
15
+ * read side still lived in tools/constraint.ts, that edge would run
16
+ * tools/resource.ts -> timeline/<choke point> -> tools/constraint.ts, and
17
+ * tools/constraint.ts already sits downstream of tools/resource.ts. Moving
18
+ * the read side here breaks that cycle before the choke point exists to hit
19
+ * it.
20
+ *
21
+ * Everything that WRITES `resource_constraints` -- insertConstraint() and
22
+ * the declare*() functions, and all the validation that goes with them --
23
+ * stays in src/tools/constraint.ts, which imports the accessors below
24
+ * rather than duplicating the query against resource_constraint_members.
25
+ */
26
+ /** Absolute tolerance for floating-point sum comparisons on 'conserved'
27
+ * constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
28
+ * so repeated addition/subtraction across many transfers can drift by a
29
+ * few ULPs. This is large enough to absorb that drift over realistic
30
+ * transfer volumes while still catching an actual logic bug (which would
31
+ * typically desync the sum by a whole `amount`, not a fraction of one). */
32
+ export const CONSERVED_SUM_EPSILON = 1e-6;
33
+ export class ConstraintViolationError extends Error {
34
+ constraintKind;
35
+ resourceId;
36
+ contradictedFact;
37
+ constructor(constraintKind, resourceId, message,
38
+ /** Design decision #7 / §5.2c's one hop of causality, attached as typed
39
+ * data and not only baked into `message` -- a reviewer at a fired check
40
+ * has to decide "is the fact wrong or is the claim wrong," and parsing
41
+ * that back out of a sentence is exactly the shape §5.2c exists to
42
+ * prevent. Only ever set when `constraintKind === "irreversible"`;
43
+ * every other family in this union has no contradicted fact to attach,
44
+ * so `undefined` is the correct default rather than a fourth sentinel
45
+ * value. */
46
+ contradictedFact) {
47
+ super(message);
48
+ this.constraintKind = constraintKind;
49
+ this.resourceId = resourceId;
50
+ this.contradictedFact = contradictedFact;
51
+ this.name = "ConstraintViolationError";
52
+ }
53
+ }
54
+ /** The resource ids belonging to a constraint, in insertion order. Exported
55
+ * alongside ConstraintRow/rowToConstraint for the same reason. */
56
+ export function memberIdsFor(constraintId) {
57
+ const db = getDatabase();
58
+ const rows = db
59
+ .prepare(`SELECT resource_id FROM resource_constraint_members WHERE constraint_id = ? ORDER BY rowid`)
60
+ .all(constraintId);
61
+ return rows.map((r) => r.resource_id);
62
+ }
63
+ export function rowToConstraint(row) {
64
+ return {
65
+ id: row.id,
66
+ gameId: row.game_id,
67
+ kind: row.kind,
68
+ resourceIds: memberIdsFor(row.id),
69
+ direction: row.direction,
70
+ total: row.total,
71
+ factKey: row.fact_key,
72
+ createdAt: row.created_at,
73
+ };
74
+ }
75
+ /**
76
+ * Shared query underlying every constraint lookup by entity id, so the JOIN
77
+ * against resource_constraint_members is written exactly once. `factKey`
78
+ * omitted means "any key" -- today's getConstraintsForResource() behaviour
79
+ * (src/tools/constraint.ts), preserved for the one caller that still needs
80
+ * it. Every OTHER caller must pass a factKey; see constraintsFor() below.
81
+ */
82
+ function queryConstraintsForEntity(entityId, factKey) {
83
+ const db = getDatabase();
84
+ const conditions = ["rcm.resource_id = ?"];
85
+ const args = [entityId];
86
+ if (factKey !== undefined) {
87
+ conditions.push("rc.fact_key = ?");
88
+ args.push(factKey);
89
+ }
90
+ const rows = db
91
+ .prepare(`SELECT rc.* FROM resource_constraints rc
92
+ JOIN resource_constraint_members rcm ON rcm.constraint_id = rc.id
93
+ WHERE ${conditions.join(" AND ")}
94
+ ORDER BY rc.created_at`)
95
+ .all(...args);
96
+ return rows.map(rowToConstraint);
97
+ }
98
+ /**
99
+ * Every constraint governing `(entityId, factKey)`, ordered by
100
+ * `created_at`. This is the whole point of Phase 3 step 1: a constraint
101
+ * declared on one fact key of an entity must never be visible when a
102
+ * different fact key of that SAME entity is asked about, even though today
103
+ * every constraint happens to govern the same key ('value').
104
+ */
105
+ export function constraintsFor(entityId, factKey) {
106
+ return queryConstraintsForEntity(entityId, factKey);
107
+ }
108
+ /**
109
+ * Every constraint governing `entityId`, regardless of fact key. Exists
110
+ * only so getConstraintsForResource() (src/tools/constraint.ts) can keep
111
+ * its pre-Phase-3 "all keys" behaviour without a second copy of the JOIN
112
+ * above -- new callers should prefer constraintsFor(), which cannot
113
+ * accidentally forget to scope by key.
114
+ */
115
+ export function allConstraintsForEntity(entityId) {
116
+ return queryConstraintsForEntity(entityId);
117
+ }
118
+ /** The 'conserved' constraint governing `(entityId, factKey)`, or `null` if
119
+ * none is declared. At most one can exist for a given key: declareConservedConstraint()
120
+ * (src/tools/constraint.ts) rejects overlapping conserved membership on the
121
+ * same key. */
122
+ export function conservedConstraintFor(entityId, factKey) {
123
+ return constraintsFor(entityId, factKey).find((c) => c.kind === "conserved") ?? null;
124
+ }
@@ -0,0 +1,121 @@
1
+ import { type T } from "./t.js";
2
+ import type { EntityKind } from "./kinds.js";
3
+ /**
4
+ * The engine's state-to-text projection (design §7/§8, GitHub issue #16):
5
+ * "say what IS true, never what is absent." *"The grain stores are full and
6
+ * the treasury coffers overflow"* -- never *"the grain stores are no longer
7
+ * empty."*
8
+ *
9
+ * **The rule is enforced AT CONSTRUCTION, not by scanning generated text.**
10
+ * `createStateRenderer` validates a caller-injected `RenderVocabulary` once,
11
+ * up front, and every guard below exists to make negation UNCONSTRUCTABLE
12
+ * rather than detected (root CLAUDE.md hard rule 4, design §7's hazard
13
+ * paragraph): a `VocabularyEntry` has exactly two fields, `noun` and
14
+ * `adjectives`, both positive by construction -- there is no free-text
15
+ * sentence field, no template string with holes, and no field a negation
16
+ * could be written into. This module never scans generated text, never
17
+ * matches a word against a list, and never inspects any string this
18
+ * codebase did not itself define as an object key (hard rule 4's "a token
19
+ * WE defined" exception, applied here to the literal `noun`/`adjectives`
20
+ * check in `resolveEntry` below).
21
+ *
22
+ * **Mechanism is core; the vocabulary is injected by each caller.** This
23
+ * module ships NOT ONE vocabulary entry, example or default -- a vocabulary
24
+ * rich enough to render a real world contains client-specific nouns, and
25
+ * either sitting in this file would fail `engineVocabulary.test.ts` on day
26
+ * one, correctly (design §7's closing paragraph, §10). `RenderVocabulary` is
27
+ * a parameter type, never a value exported from here.
28
+ *
29
+ * **Output is generated ONLY from facts that hold at `t`.** The sole source
30
+ * of state is `replay({gameId, t})` (replay.ts) -- there is no diff, no
31
+ * set-complement against another `t`, no "expected keys minus present
32
+ * keys." A fact that does not hold at `t` produces NOTHING: not a phrase
33
+ * about its absence, not an "unknown," not a placeholder. Silence. A fact
34
+ * that DOES hold but has no vocabulary entry is reported as a row in
35
+ * `unnamed` -- a caller learns its vocabulary is too thin from a row, never
36
+ * from invented or negated text (the vocabulary-richness contract, task
37
+ * brief / design §7).
38
+ *
39
+ * **No transition/differential API.** There is no `renderChange(before,
40
+ * after)` and nothing here takes two `t`s -- that is precisely the shape
41
+ * that produces "no longer." State at one `t`, full stop
42
+ * (`render.test.ts`'s "module export surface" test locks the runtime export
43
+ * list to exactly `createStateRenderer` so a second, differential entry
44
+ * point cannot be added silently).
45
+ */
46
+ /**
47
+ * A positive concrete noun naming a thing that is present, with optional
48
+ * positive adjectives qualifying it. This is the ENTIRE vocabulary
49
+ * language: there is no sentence field, no connective, no place for a
50
+ * negation to live. `resolveEntry` below refuses any object carrying a key
51
+ * other than these two -- the guard that stops an `avoid`/`unless`/`negate`
52
+ * field from being bolted on later.
53
+ */
54
+ export interface VocabularyEntry {
55
+ /** A positive concrete noun naming a thing that is present. Required, non-empty after trim. */
56
+ noun: string;
57
+ /** Optional positive adjectives qualifying that noun. Each non-empty after trim. */
58
+ adjectives?: readonly string[];
59
+ }
60
+ /**
61
+ * Keyed by fact `key`, then by fact `value`. Both exact matches -- no
62
+ * pattern matching, no globs, no fallback entry, no wildcard key (hard rule
63
+ * 4: this module never matches meaning, only exact keys it was handed).
64
+ */
65
+ export type RenderVocabulary = Readonly<Record<string, Readonly<Record<string, VocabularyEntry>>>>;
66
+ /** One fact that held at `t` and was named by the injected vocabulary. */
67
+ export interface RenderedNoun {
68
+ entityId: string;
69
+ entityKind: EntityKind;
70
+ entityName: string | null;
71
+ key: string;
72
+ value: string;
73
+ noun: string;
74
+ adjectives: readonly string[];
75
+ /** The noun and its adjectives joined -- the ONLY text this module composes. */
76
+ phrase: string;
77
+ }
78
+ /**
79
+ * A fact that held at `t` and had no vocabulary entry. A row, never a
80
+ * verdict (root CLAUDE.md hard rule 2) -- there is deliberately no
81
+ * `isComplete`/coverage/severity field anywhere near this shape. Carries no
82
+ * `noun`/`adjectives`/`phrase`: there is nothing to say about it, and this
83
+ * module never invents something to say.
84
+ */
85
+ export interface UnnamedFact {
86
+ entityId: string;
87
+ entityKind: EntityKind;
88
+ entityName: string | null;
89
+ key: string;
90
+ value: string;
91
+ }
92
+ /** Everything the projection could say about a game at `t`, and everything it could not. */
93
+ export interface RenderedState {
94
+ gameId: string;
95
+ t: T;
96
+ nouns: RenderedNoun[];
97
+ unnamed: UnnamedFact[];
98
+ }
99
+ export interface StateRenderer {
100
+ render(params: {
101
+ gameId: string;
102
+ t: T;
103
+ }): RenderedState;
104
+ }
105
+ /**
106
+ * Builds a `StateRenderer` over a caller-injected `vocabulary`. Validation
107
+ * happens ONCE here, over the whole vocabulary, never lazily per `render`
108
+ * call -- a vocabulary that would be invalid for entity #4,000 is refused
109
+ * before entity #1 is ever rendered, and every `render()` call after
110
+ * construction reuses the same validated, frozen internal copy rather than
111
+ * re-checking anything.
112
+ *
113
+ * Throws (see `resolveVocabulary`/`resolveEntry` above) on: an entry whose
114
+ * `noun` is missing, not a string, or empty/whitespace after trim; any
115
+ * `adjectives` element that is not a string or is empty/whitespace after
116
+ * trim; an entry object carrying any key other than `noun`/`adjectives`
117
+ * (named in the error); and an empty vocabulary.
118
+ */
119
+ export declare function createStateRenderer(params: {
120
+ vocabulary: RenderVocabulary;
121
+ }): StateRenderer;
@@ -0,0 +1,187 @@
1
+ import { replay } from "./replay.js";
2
+ /**
3
+ * Exactly the fields `VocabularyEntry` declares. This is a literal check
4
+ * against identifiers THIS MODULE defined in a type THIS MODULE specified
5
+ * -- explicitly permitted by hard rule 4 -- and it is the load-bearing
6
+ * guard named in the task brief: it is what stops a caller (or a future
7
+ * editor of a caller's vocabulary file) from bolting an `avoid:`, an
8
+ * `unless:`, or a `negate:` field onto an entry six months from now. Any
9
+ * key outside this set is refused, by name, at construction.
10
+ */
11
+ const ALLOWED_ENTRY_FIELDS = new Set(["noun", "adjectives"]);
12
+ function describeValue(value) {
13
+ if (value === null)
14
+ return "null";
15
+ if (value === undefined)
16
+ return "undefined";
17
+ if (Array.isArray(value))
18
+ return "an array";
19
+ const type = typeof value;
20
+ if (type === "object")
21
+ return "an object";
22
+ return `a ${type} (${JSON.stringify(value)})`;
23
+ }
24
+ /**
25
+ * Validates and defensively copies one `(key, value) -> entry` mapping.
26
+ * Never returns a reference into the caller's own object graph -- every
27
+ * field is read once and copied into a fresh, frozen object -- so a caller
28
+ * that holds onto its original vocabulary object and mutates it after
29
+ * construction (adding a forbidden field, blanking a noun) can never affect
30
+ * what a renderer built from it produces. `render.test.ts`'s
31
+ * "frozen/defensive-copy behaviour" test proves this by mutating the
32
+ * original object after `createStateRenderer` returns and asserting the
33
+ * renderer's output is unchanged.
34
+ */
35
+ function resolveEntry(entry, key, value) {
36
+ const path = `["${key}"]["${value}"]`;
37
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
38
+ throw new Error(`render vocabulary: entry at ${path} must be an object with a "noun" field, got ${describeValue(entry)}`);
39
+ }
40
+ const record = entry;
41
+ for (const field of Object.keys(record)) {
42
+ if (!ALLOWED_ENTRY_FIELDS.has(field)) {
43
+ throw new Error(`render vocabulary: entry at ${path} carries an unrecognized field "${field}". A vocabulary entry may ` +
44
+ `only declare "noun" and "adjectives" -- this refusal is what stops a forbidden field (an "avoid", a ` +
45
+ `"negate", a "mustNotSay") from being bolted onto the vocabulary later (root CLAUDE.md hard rules 4 ` +
46
+ `and 5, design §7).`);
47
+ }
48
+ }
49
+ if (typeof record.noun !== "string" || record.noun.trim().length === 0) {
50
+ throw new Error(`render vocabulary: entry at ${path}.noun must be a non-empty string, got ${describeValue(record.noun)}`);
51
+ }
52
+ let adjectives = [];
53
+ if (record.adjectives !== undefined) {
54
+ if (!Array.isArray(record.adjectives)) {
55
+ throw new Error(`render vocabulary: entry at ${path}.adjectives must be an array of strings, got ` +
56
+ `${describeValue(record.adjectives)}`);
57
+ }
58
+ adjectives = record.adjectives.map((adjective, index) => {
59
+ if (typeof adjective !== "string" || adjective.trim().length === 0) {
60
+ throw new Error(`render vocabulary: entry at ${path}.adjectives[${index}] must be a non-empty string, got ` +
61
+ `${describeValue(adjective)}`);
62
+ }
63
+ return adjective;
64
+ });
65
+ }
66
+ return Object.freeze({ noun: record.noun, adjectives: Object.freeze(adjectives) });
67
+ }
68
+ /**
69
+ * Validates the WHOLE vocabulary once, at construction (never lazily per
70
+ * render -- see `createStateRenderer`), and returns a frozen, defensively
71
+ * copied internal form. Every reachable value is copied by primitive field,
72
+ * never by object reference, so nothing in the returned structure can be
73
+ * reached and mutated through the caller's original `vocabulary` argument.
74
+ */
75
+ function resolveVocabulary(vocabulary) {
76
+ if (typeof vocabulary !== "object" || vocabulary === null || Array.isArray(vocabulary)) {
77
+ throw new Error(`render vocabulary: vocabulary must be an object mapping fact keys to value->entry maps, got ` +
78
+ `${describeValue(vocabulary)}`);
79
+ }
80
+ const resolved = {};
81
+ let entryCount = 0;
82
+ for (const key of Object.keys(vocabulary)) {
83
+ const valuesForKey = vocabulary[key];
84
+ if (typeof valuesForKey !== "object" || valuesForKey === null || Array.isArray(valuesForKey)) {
85
+ throw new Error(`render vocabulary: vocabulary["${key}"] must be an object mapping fact values to vocabulary entries, ` +
86
+ `got ${describeValue(valuesForKey)}`);
87
+ }
88
+ const resolvedValues = {};
89
+ for (const value of Object.keys(valuesForKey)) {
90
+ const entry = valuesForKey[value];
91
+ resolvedValues[value] = resolveEntry(entry, key, value);
92
+ entryCount++;
93
+ }
94
+ resolved[key] = Object.freeze(resolvedValues);
95
+ }
96
+ // A renderer that can name nothing is a configuration error, not a
97
+ // silent no-op -- an empty vocabulary would render every real fact into
98
+ // `unnamed` forever, which is a caller mistake worth refusing loudly
99
+ // rather than a legitimate "narrows to nothing" the way an empty
100
+ // `entityIds` list is elsewhere in this codebase (narration.ts).
101
+ if (entryCount === 0) {
102
+ throw new Error("render vocabulary: an empty vocabulary can name nothing at all -- that is a configuration error, not a " +
103
+ "silent no-op. Supply at least one (key, value) -> { noun, adjectives? } entry.");
104
+ }
105
+ return Object.freeze(resolved);
106
+ }
107
+ /** The noun and its adjectives joined -- the ONLY text this module composes. */
108
+ function composePhrase(entry) {
109
+ return [...entry.adjectives, entry.noun].join(" ");
110
+ }
111
+ /**
112
+ * Renders one game's world at `t`. Reads `replay({gameId, t})` -- the sole
113
+ * source of state -- and nothing else.
114
+ *
115
+ * Ordering is deterministic and total, bottoming out at columns SQLite
116
+ * guarantees unique, copying `narration.ts`/`export.ts`'s discipline:
117
+ * `replay()` already orders entities by `(createdAtT, id)`, and `id` is the
118
+ * entities primary key, so that half is already a total order. Within one
119
+ * entity, interval versioning guarantees at most one fact per `key` is
120
+ * valid at a single `t` (replay.ts's own doc comment), so sorting that
121
+ * entity's fact keys ascending is itself a total order over that entity's
122
+ * facts -- no tie a fact `key` could produce. Two renders of the same world
123
+ * therefore produce identical arrays; `render.test.ts`'s determinism test
124
+ * checks this with `JSON.stringify` equality, the same proof `narration.ts`
125
+ * uses.
126
+ */
127
+ function renderState(vocabulary, params) {
128
+ const snapshot = replay({ gameId: params.gameId, t: params.t });
129
+ const nouns = [];
130
+ const unnamed = [];
131
+ for (const entity of snapshot.entities) {
132
+ const keys = Object.keys(entity.facts).sort();
133
+ for (const key of keys) {
134
+ const fact = entity.facts[key];
135
+ const entry = vocabulary[key]?.[fact.value];
136
+ if (entry) {
137
+ nouns.push({
138
+ entityId: entity.id,
139
+ entityKind: entity.kind,
140
+ entityName: entity.name,
141
+ key,
142
+ value: fact.value,
143
+ noun: entry.noun,
144
+ adjectives: entry.adjectives,
145
+ phrase: composePhrase(entry),
146
+ });
147
+ }
148
+ else {
149
+ // A fact with no vocabulary entry is OMITTED as text, but REPORTED
150
+ // as a row -- the vocabulary-richness contract (task brief, design
151
+ // §7): the engine records the gap, never invents a noun, never
152
+ // negates, and passes no judgement on it (root CLAUDE.md hard rule
153
+ // 2 -- no isComplete, no coverage percentage, no severity).
154
+ unnamed.push({
155
+ entityId: entity.id,
156
+ entityKind: entity.kind,
157
+ entityName: entity.name,
158
+ key,
159
+ value: fact.value,
160
+ });
161
+ }
162
+ }
163
+ }
164
+ return { gameId: snapshot.gameId, t: snapshot.t, nouns, unnamed };
165
+ }
166
+ /**
167
+ * Builds a `StateRenderer` over a caller-injected `vocabulary`. Validation
168
+ * happens ONCE here, over the whole vocabulary, never lazily per `render`
169
+ * call -- a vocabulary that would be invalid for entity #4,000 is refused
170
+ * before entity #1 is ever rendered, and every `render()` call after
171
+ * construction reuses the same validated, frozen internal copy rather than
172
+ * re-checking anything.
173
+ *
174
+ * Throws (see `resolveVocabulary`/`resolveEntry` above) on: an entry whose
175
+ * `noun` is missing, not a string, or empty/whitespace after trim; any
176
+ * `adjectives` element that is not a string or is empty/whitespace after
177
+ * trim; an entry object carrying any key other than `noun`/`adjectives`
178
+ * (named in the error); and an empty vocabulary.
179
+ */
180
+ export function createStateRenderer(params) {
181
+ const vocabulary = resolveVocabulary(params.vocabulary);
182
+ return {
183
+ render(renderParams) {
184
+ return renderState(vocabulary, renderParams);
185
+ },
186
+ };
187
+ }
@@ -0,0 +1,86 @@
1
+ import { type T } from "./t.js";
2
+ import type { EntityKind } from "./kinds.js";
3
+ /**
4
+ * One fact as it read at the replayed `t`. `validFromT` is carried because
5
+ * design §5.2c's one hop of causality needs the moment a fact opened --
6
+ * nothing more than that is in scope here. In particular there is no
7
+ * `validToT`: at the replayed instant the fact is simply open, and whether
8
+ * or when it later closes is not a property of *this* snapshot.
9
+ */
10
+ export interface ReplayedFact {
11
+ value: string;
12
+ validFromT: T;
13
+ }
14
+ /**
15
+ * One entity as it read at the replayed `t`, with every fact that was valid
16
+ * then. `facts` is keyed by `key` because at most one fact per key can be
17
+ * valid at a single `t` -- interval versioning (schema.ts) guarantees that
18
+ * for well-formed data, and the query below is written so a duplicate key
19
+ * (malformed data) resolves deterministically rather than by map-insertion
20
+ * order.
21
+ */
22
+ export interface ReplayedEntity {
23
+ id: string;
24
+ kind: EntityKind;
25
+ name: string | null;
26
+ createdAtT: T;
27
+ facts: Record<string, ReplayedFact>;
28
+ }
29
+ /** A full world snapshot: every entity alive at `t`, with every fact valid at `t`. */
30
+ export interface Snapshot {
31
+ gameId: string;
32
+ t: T;
33
+ entities: ReplayedEntity[];
34
+ }
35
+ /**
36
+ * `replay(t)` -- design §5.1's "whole feature": the state of one game's
37
+ * world at any point in its recorded history, not only at "now".
38
+ *
39
+ * Half-open intervals, everywhere and identically (design §5.1, and the
40
+ * architecture note's flagged likeliest bug):
41
+ *
42
+ * alive at t: created_at_t <= t AND (destroyed_at_t IS NULL OR destroyed_at_t > t)
43
+ * valid at t: valid_from_t <= t AND (valid_to_t IS NULL OR valid_to_t > t)
44
+ *
45
+ * A fact valid [a, b) is present at a and gone at b -- b itself belongs to
46
+ * whatever superseded it, never to both. A zero-width interval (valid_from_t
47
+ * == valid_to_t, which two writes at one declared t produce) is therefore
48
+ * invisible at every t: it opens and closes at the same instant, so the
49
+ * "> t" half of its own close condition is never satisfied at the t it
50
+ * would need to be visible at.
51
+ *
52
+ * Exactly two queries, never one per entity -- this runs over a whole
53
+ * world. The first finds who was alive; the second finds what was true of
54
+ * them, via a JOIN back to `entities` rather than a per-entity-id `IN (...)`
55
+ * list: this snapshot is meant to run over an entire world, and binding one
56
+ * parameter per entity would both hit SQLite's bound-variable limit on a
57
+ * large one and make every distinct entity count its own SQL text, which
58
+ * defeats better-sqlite3's prepared-statement cache. Fixed SQL, two binds
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.
82
+ */
83
+ export declare function replay(params: {
84
+ gameId: string;
85
+ t: T;
86
+ }): Snapshot;
@@ -0,0 +1,126 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ import { assertT } from "./t.js";
3
+ /**
4
+ * The "alive at t" half of both queries below, as one shared string.
5
+ *
6
+ * This has to be a single constant, not two hand-matched copies, because
7
+ * the whole point of replay is that "who was alive" and "what was true of
8
+ * them" describe the same instant. Two copies would compile and pass review
9
+ * right up until someone edited one -- silently reintroducing entities whose
10
+ * facts vanished, or facts belonging to nobody the snapshot admits exists.
11
+ * Always used against an `entities` row aliased `e` (see both queries).
12
+ */
13
+ const ALIVE_AT_T = "e.created_at_t <= ? AND (e.destroyed_at_t IS NULL OR e.destroyed_at_t > ?)";
14
+ /**
15
+ * `replay(t)` -- design §5.1's "whole feature": the state of one game's
16
+ * world at any point in its recorded history, not only at "now".
17
+ *
18
+ * Half-open intervals, everywhere and identically (design §5.1, and the
19
+ * architecture note's flagged likeliest bug):
20
+ *
21
+ * alive at t: created_at_t <= t AND (destroyed_at_t IS NULL OR destroyed_at_t > t)
22
+ * valid at t: valid_from_t <= t AND (valid_to_t IS NULL OR valid_to_t > t)
23
+ *
24
+ * A fact valid [a, b) is present at a and gone at b -- b itself belongs to
25
+ * whatever superseded it, never to both. A zero-width interval (valid_from_t
26
+ * == valid_to_t, which two writes at one declared t produce) is therefore
27
+ * invisible at every t: it opens and closes at the same instant, so the
28
+ * "> t" half of its own close condition is never satisfied at the t it
29
+ * would need to be visible at.
30
+ *
31
+ * Exactly two queries, never one per entity -- this runs over a whole
32
+ * world. The first finds who was alive; the second finds what was true of
33
+ * them, via a JOIN back to `entities` rather than a per-entity-id `IN (...)`
34
+ * list: this snapshot is meant to run over an entire world, and binding one
35
+ * parameter per entity would both hit SQLite's bound-variable limit on a
36
+ * large one and make every distinct entity count its own SQL text, which
37
+ * defeats better-sqlite3's prepared-statement cache. Fixed SQL, two binds
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.
61
+ */
62
+ export function replay(params) {
63
+ const { gameId, t } = params;
64
+ // A Date, a string, NaN or +-Infinity gets refused here, loudly, before
65
+ // it can silently compare unequal to every row and produce a confidently
66
+ // wrong empty snapshot.
67
+ assertT(t);
68
+ const db = getDatabase();
69
+ // Query 1 of 2: which entities were alive at t, in the caller-diffable
70
+ // order the design calls for (created_at_t, then id -- id as the
71
+ // tiebreaker for entities created at the same t, so the order never
72
+ // depends on SQLite's unspecified tie behavior).
73
+ const entityRows = db
74
+ .prepare(`SELECT e.id, e.kind, e.name, e.created_at_t
75
+ FROM entities e
76
+ WHERE e.game_id = ?
77
+ AND ${ALIVE_AT_T}
78
+ ORDER BY e.created_at_t, e.id`)
79
+ .all(gameId, t, t);
80
+ const entities = entityRows.map((row) => ({
81
+ id: row.id,
82
+ kind: row.kind,
83
+ name: row.name,
84
+ createdAtT: row.created_at_t,
85
+ facts: {},
86
+ }));
87
+ // Nothing alive means nothing that could own a fact. Not load-bearing for
88
+ // correctness the way it was before the JOIN -- query 2 below is valid
89
+ // SQL and would themselves return zero rows -- but it is still a genuine
90
+ // short-circuit: an empty game skips a JOIN entirely rather than running
91
+ // it to find nothing.
92
+ if (entities.length === 0) {
93
+ return { gameId, t, entities };
94
+ }
95
+ const byId = new Map(entities.map((entity) => [entity.id, entity]));
96
+ // Query 2 of 2: every fact valid at t, joined back to `entities` so
97
+ // "valid" is scoped to exactly the entities `ALIVE_AT_T` says were alive
98
+ // -- the same predicate, not a hand-matched copy of it. Ordered by
99
+ // valid_from_t so that if malformed data ever puts two facts for the same
100
+ // key in the visible window at once -- which correct writers never
101
+ // produce -- the one opened later (the one that superseded the other) is
102
+ // the one left standing, rather than whichever row SQLite happened to
103
+ // return first.
104
+ const factRows = db
105
+ .prepare(`SELECT f.entity_id, f.key, f.value, f.valid_from_t
106
+ FROM facts f
107
+ JOIN entities e ON e.id = f.entity_id
108
+ WHERE e.game_id = ?
109
+ AND ${ALIVE_AT_T}
110
+ AND f.valid_from_t <= ?
111
+ AND (f.valid_to_t IS NULL OR f.valid_to_t > ?)
112
+ ORDER BY f.valid_from_t`)
113
+ .all(gameId, t, t, t, t);
114
+ for (const factRow of factRows) {
115
+ const entity = byId.get(factRow.entity_id);
116
+ // Cannot happen: every fact row came from a JOIN against entities e
117
+ // filtered by the same game_id and ALIVE_AT_T as query 1, so its
118
+ // entity_id is always a key already in byId. Guarded anyway rather than
119
+ // asserted, because a snapshot query has no business throwing over its
120
+ // own bookkeeping.
121
+ if (!entity)
122
+ continue;
123
+ entity.facts[factRow.key] = { value: factRow.value, validFromT: factRow.valid_from_t };
124
+ }
125
+ return { gameId, t, entities };
126
+ }