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,108 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ import { assertT } from "./t.js";
3
+ import { openingEventId } from "./provenance.js";
4
+ function toIrreversibleFact(row, gameId) {
5
+ assertT(row.valid_from_t);
6
+ return {
7
+ factId: row.id,
8
+ entityId: row.entity_id,
9
+ key: row.key,
10
+ value: row.value,
11
+ validFromT: row.valid_from_t,
12
+ openedByEventId: openingEventId(gameId, row.entity_id, row.valid_from_t),
13
+ };
14
+ }
15
+ /**
16
+ * Marks the currently-open fact for `(entityId, key)` irreversible.
17
+ *
18
+ * Throws, naming what's missing, rather than silently doing nothing, in
19
+ * exactly two cases:
20
+ * - the entity does not exist;
21
+ * - there is no open fact for that key. Irreversibility can only attach to
22
+ * a fact that currently holds -- "say what is, never what is absent"
23
+ * (hard rule 3) applies here too: there is no honest way to declare the
24
+ * irreversibility of an absence.
25
+ *
26
+ * The flip itself is a single `UPDATE facts SET irreversible = 1 WHERE id =
27
+ * ?`, which the amended `timeline_facts_immutable` latch (schema.ts)
28
+ * permits as the one legal transition. That single statement is also what
29
+ * makes this naturally idempotent: calling it again while the same fact is
30
+ * still open re-sends the identical 1 -> 1 update, which the latch treats as
31
+ * a no-op (not a change at all, so it never reaches the "reject anything but
32
+ * 0 -> 1" branch), and this function returns the same record either way.
33
+ */
34
+ export function declareIrreversible(params) {
35
+ const db = getDatabase();
36
+ const entity = db.prepare(`SELECT game_id FROM entities WHERE id = ?`).get(params.entityId);
37
+ if (!entity) {
38
+ throw new Error(`timeline: cannot declare irreversibility for unknown entity '${params.entityId}'`);
39
+ }
40
+ // ORDER BY / LIMIT for the same reason replay.ts orders its fact query:
41
+ // two facts open at once for one key is malformed data (checkpoint.ts
42
+ // reports it as `duplicate-fact`), and this module has no business
43
+ // resolving it -- but which one it picks must not depend on query-plan
44
+ // order. Same tiebreak as irreversibleFactFor below, so the two functions
45
+ // can never disagree about which row they mean.
46
+ const open = db
47
+ .prepare(`SELECT id, entity_id, key, value, valid_from_t FROM facts
48
+ WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL
49
+ ORDER BY valid_from_t DESC, id DESC
50
+ LIMIT 1`)
51
+ .get(params.entityId, params.key);
52
+ if (!open) {
53
+ throw new Error(`timeline: entity '${params.entityId}' has no open fact for key '${params.key}' -- ` +
54
+ `irreversibility can only be declared for a fact that currently holds, never for an absence`);
55
+ }
56
+ db.prepare(`UPDATE facts SET irreversible = 1 WHERE id = ?`).run(open.id);
57
+ return toIrreversibleFact(open, entity.game_id);
58
+ }
59
+ /**
60
+ * The irreversible fact currently governing `(entityId, key)`, or `null` if
61
+ * none has been declared. Looks at every row with `irreversible = 1` for
62
+ * that key -- open or closed -- the same set `timeline_facts_irreversible`
63
+ * (schema.ts) tests, so this can never report "none" for a key the guard
64
+ * would in fact refuse to contradict. When more than one such row exists
65
+ * (only possible after the key was closed and reopened at the SAME value,
66
+ * per rule 1 -- a reopen at a different value is refused outright), the
67
+ * most recently opened one is returned, ordered deterministically
68
+ * (`valid_from_t` then `id`) rather than left to query-plan order.
69
+ */
70
+ export function irreversibleFactFor(entityId, key) {
71
+ const db = getDatabase();
72
+ const entity = db.prepare(`SELECT game_id FROM entities WHERE id = ?`).get(entityId);
73
+ if (!entity)
74
+ return null;
75
+ const row = db
76
+ .prepare(`SELECT id, entity_id, key, value, valid_from_t FROM facts
77
+ WHERE entity_id = ? AND key = ? AND irreversible = 1
78
+ ORDER BY valid_from_t DESC, id DESC
79
+ LIMIT 1`)
80
+ .get(entityId, key);
81
+ if (!row)
82
+ return null;
83
+ return toIrreversibleFact(row, entity.game_id);
84
+ }
85
+ /**
86
+ * Every irreversible fact belonging to `gameId`, optionally narrowed to one
87
+ * entity. A listing, not a verdict (hard rule 2) -- there is no summary
88
+ * count, no "is this game safe" flag, just the rows. Scoped to `gameId` via
89
+ * a join on `entities` rather than trusting a caller-supplied entity list,
90
+ * so one game's declarations can never leak into another's listing.
91
+ */
92
+ export function listIrreversibleFacts(params) {
93
+ const db = getDatabase();
94
+ let query = `
95
+ SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value, f.valid_from_t AS valid_from_t
96
+ FROM facts f
97
+ JOIN entities e ON e.id = f.entity_id
98
+ WHERE e.game_id = ? AND f.irreversible = 1
99
+ `;
100
+ const args = [params.gameId];
101
+ if (params.entityId !== undefined) {
102
+ query += ` AND f.entity_id = ?`;
103
+ args.push(params.entityId);
104
+ }
105
+ query += ` ORDER BY f.valid_from_t, f.id`;
106
+ const rows = db.prepare(query).all(...args);
107
+ return rows.map((row) => toIrreversibleFact(row, params.gameId));
108
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The timeline's entity-kind vocabulary -- and the single owner of it.
3
+ *
4
+ * These are exactly the projected tables of design §8's "Core, and this is
5
+ * the correction" row: entity/property concepts (games, characters,
6
+ * locations, items, resources, relationships, factions, secrets), not
7
+ * narrative furniture. Issue #2's projection registry imports `EntityKind`,
8
+ * so a typo in a caller is a compile error before it can ever become a
9
+ * constraint violation -- the `entity_kinds` table and the FK on
10
+ * `entities.kind` (see schema.ts) are the backstop for anything that
11
+ * reaches the database without going through a typed caller.
12
+ */
13
+ export declare const ENTITY_KINDS: readonly ["game", "character", "location", "item", "resource", "relationship", "faction", "secret"];
14
+ export type EntityKind = (typeof ENTITY_KINDS)[number];
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The timeline's entity-kind vocabulary -- and the single owner of it.
3
+ *
4
+ * These are exactly the projected tables of design §8's "Core, and this is
5
+ * the correction" row: entity/property concepts (games, characters,
6
+ * locations, items, resources, relationships, factions, secrets), not
7
+ * narrative furniture. Issue #2's projection registry imports `EntityKind`,
8
+ * so a typo in a caller is a compile error before it can ever become a
9
+ * constraint violation -- the `entity_kinds` table and the FK on
10
+ * `entities.kind` (see schema.ts) are the backstop for anything that
11
+ * reaches the database without going through a typed caller.
12
+ */
13
+ export const ENTITY_KINDS = [
14
+ "game",
15
+ "character",
16
+ "location",
17
+ "item",
18
+ "resource",
19
+ "relationship",
20
+ "faction",
21
+ "secret",
22
+ ];
@@ -0,0 +1,175 @@
1
+ import { type T } from "./t.js";
2
+ import type { EntityKind } from "./kinds.js";
3
+ import { type FactProvenance } from "./provenance.js";
4
+ /**
5
+ * The outbound half of authority (design §5.2b/§5.2c, GitHub issues #11 and
6
+ * #12): "Here is what is true; depict it, do not argue with it." One
7
+ * consumer enforces this live, inside one session, resolution preceding
8
+ * narration in one conversation -- its world advances a turn at a time,
9
+ * with a player making uncertain decisions right up until resolution says
10
+ * what actually happened. The other cannot: its units have duration and
11
+ * everything in them is already known in advance, and its narrator output
12
+ * is generated once, reviewed by a human, committed as a file, and
13
+ * rendered hours later by a process that must never call a model. There is
14
+ * no live moment inside that pipeline for a constraint to be checked at --
15
+ * enforcement there is a lint over a finished artifact, at a different
16
+ * point in time from generation entirely.
17
+ *
18
+ * **This is why the whole module is built around ONE serialized structure
19
+ * and TWO enforcement points, not a live handshake.** `narrationConstraintAt`
20
+ * is the only function here that touches the database; everything below it
21
+ * -- `Claim`, `Contradiction`, `contradictions()` -- operates purely on the
22
+ * plain-data shape `narrationConstraintAt` returns, with no database call
23
+ * anywhere in that path. That is not an implementation detail worth noting
24
+ * in passing: it is the entire reason the second consumer above can use
25
+ * this at all (§5.2b's "the condition that decides whether it can use it").
26
+ * See `narration.test.ts`'s "works on a JSON-rehydrated object with the
27
+ * database closed" test, which proves the property by actually closing the
28
+ * database mid-test and running `contradictions` on a
29
+ * `JSON.parse(JSON.stringify(...))` round trip.
30
+ *
31
+ * **Prohibitions are derived and structural, never authored and lexical**
32
+ * (hard rule 5, §5.2b, and the four recorded instances of this exact
33
+ * mistake across two codebases catalogued in root CLAUDE.md). This module
34
+ * has no `mustNotSay`, no `avoid`, no `forbidden`, no `negativePrompt`, no
35
+ * phrase list, and no severity or `isValid`/`ok`/`passed` field anywhere.
36
+ * `Claim` deliberately has no `text` field and never will: turning prose
37
+ * into a structured claim is the CALLER's job, downstream of this object
38
+ * (hard rule 4 -- the engine compares a claim against facts, never text
39
+ * against a word list). Negation is unconstructable here because the type
40
+ * that would carry it -- a negative fact, a forbidden-value list -- was
41
+ * simply never given a field to live in, not because something scans for
42
+ * one and rejects it.
43
+ *
44
+ * §5.2c's one hop of causality travels on every fact via `FactProvenance`
45
+ * (provenance.ts), the same shape `IrreversibleFact` (irreversible.ts) now
46
+ * extends -- there is exactly one owner of "the fact plus the event that
47
+ * opened it" in this codebase, not a second copy grown for this module.
48
+ */
49
+ export declare const NARRATION_CONSTRAINT_FORMAT_VERSION = 1;
50
+ /**
51
+ * One fact that holds, carried in positive form -- design §7's "say what
52
+ * is, never what is absent" applied to a whole serialized object rather
53
+ * than to a single rendered sentence. `validToT` is carried, unlike
54
+ * `replay.ts`'s `ReplayedFact` (which deliberately omits it: at a replayed
55
+ * instant a fact is simply open), because `contradictions()` below needs
56
+ * the fact's FULL half-open interval to decide whether a claim at some
57
+ * OTHER `t` would contradict it -- the prohibition is derived over
58
+ * `[validFromT, validToT)`, not only evaluated at the instant this object
59
+ * was built. `null` means still open at serialization time.
60
+ */
61
+ export interface ConstraintFact extends FactProvenance {
62
+ entityKind: EntityKind;
63
+ entityName: string | null;
64
+ /** null while still open at serialization time. Carried because the
65
+ * prohibition is derived over a half-open interval; see `contradictions`. */
66
+ validToT: T | null;
67
+ irreversible: boolean;
68
+ }
69
+ /** The frozen artifact itself -- one caller's declared `t`, and every fact
70
+ * that constrains what may be truthfully asserted at it. */
71
+ export interface NarrationConstraint {
72
+ formatVersion: number;
73
+ gameId: string;
74
+ t: T;
75
+ mustHonor: ConstraintFact[];
76
+ }
77
+ /**
78
+ * The constraint that holds for `gameId` at `t`, optionally narrowed to
79
+ * `entityIds`. The only function in this module that touches the database
80
+ * -- see the module doc comment for why that boundary is load-bearing.
81
+ *
82
+ * Includes, for the game (§5.2b, §5.2c):
83
+ *
84
+ * 1. every fact valid at `t` on an entity alive at `t` -- the EXACT
85
+ * half-open predicates `replay.ts` uses (`replay()`'s own doc comment
86
+ * spells out why both must be half-open and identical everywhere):
87
+ * alive: created_at_t <= t AND (destroyed_at_t IS NULL OR destroyed_at_t > t)
88
+ * valid: valid_from_t <= t AND (valid_to_t IS NULL OR valid_to_t > t)
89
+ * `replay.ts`'s own `ALIVE_AT_T` constant is module-private and out of
90
+ * scope for this module to import (it would mean editing replay.ts,
91
+ * which this change does not touch) -- so the predicate text is
92
+ * reproduced here character-for-character rather than paraphrased,
93
+ * which is what "EXACT" in the task brief means in practice.
94
+ *
95
+ * 2. every fact with `irreversible = 1` and `valid_from_t <= t`, EVEN IF
96
+ * CLOSED, and EVEN IF its entity was destroyed. This is not a hedge --
97
+ * it is the entire motivating failure of design §5.2b and root
98
+ * CLAUDE.md's hard rule 3: an irreversible fact is prohibited for
99
+ * every `t' >= its valid_from_t` regardless of what has happened to
100
+ * the entity or the fact's own interval since, so a destroyed island
101
+ * cannot quietly exist again just because the fact that destroyed it
102
+ * closed, or the entity itself was later deleted. Entities are
103
+ * append-only (`timeline_entities_no_delete`, schema.ts) and facts are
104
+ * append-only (`timeline_facts_no_delete`), so both rows are always
105
+ * still there to join against -- name and kind survive destruction.
106
+ *
107
+ * The two sets are combined with a single SQL `OR` inside one query rather
108
+ * than two queries merged in JS, because that makes de-duplication free: a
109
+ * physical fact row satisfies the combined WHERE clause at most once, so a
110
+ * fact that is BOTH currently valid AND irreversible (the common case --
111
+ * most irreversible facts are also the currently-open truth) can never
112
+ * appear twice in `mustHonor` the way a naive UNION of two result sets
113
+ * would risk.
114
+ *
115
+ * Deterministic, total ordering ending in the primary key
116
+ * (`valid_from_t`, `entity_id`, `key`, `id`) -- `export.ts`'s own
117
+ * discipline, copied for the same reason: two calls over the same world
118
+ * must `JSON.stringify` byte-identically, and an ORDER BY that does not
119
+ * bottom out at a column SQLite guarantees is unique leaves the tie broken
120
+ * by unspecified query-plan order instead.
121
+ */
122
+ export declare function narrationConstraintAt(params: {
123
+ gameId: string;
124
+ t: T;
125
+ entityIds?: readonly string[];
126
+ }): NarrationConstraint;
127
+ /**
128
+ * A structured assertion a caller wishes to check. Deliberately has NO
129
+ * `text` field and never will: the engine compares a claim against facts,
130
+ * never text against a word list (hard rule 4). Turning prose into claims
131
+ * is the caller's half of the contract, downstream of this object.
132
+ */
133
+ export interface Claim {
134
+ entityId: string;
135
+ key: string;
136
+ value: string | number;
137
+ /** Where on the axis this claim asserts its value. */
138
+ t: T;
139
+ }
140
+ /** One derived contradiction: the claim, and the fact it contradicts, with
141
+ * that fact's one hop of causality attached. A row, never a verdict. */
142
+ export interface Contradiction {
143
+ claim: Claim;
144
+ fact: ConstraintFact;
145
+ }
146
+ /**
147
+ * Derives every contradiction between `claims` and the facts `constraint`
148
+ * says must be honored. Pure -- no database call anywhere in this
149
+ * function, which is the whole point (module doc comment above): the
150
+ * artifact-lint consumer runs this hours later on a JSON file, offline,
151
+ * with no engine and no model in the loop.
152
+ *
153
+ * For each `(claim, fact)` pair matching on `entityId` AND `key`, where the
154
+ * values differ (`valuesMatch` above):
155
+ *
156
+ * - fact `irreversible`: a contradiction iff
157
+ * `compareT(claim.t, fact.validFromT) >= 0` -- mirroring
158
+ * `timeline_facts_irreversible` (schema.ts) EXACTLY, `>=` included. The
159
+ * trigger's own comment explains why: `valid_from_t` IS the instant the
160
+ * new value takes effect, so "prohibited for all t' > t" (§5.2b's prose)
161
+ * is stated at the row level as `>=` against the OLD fact's
162
+ * `valid_from_t`. Getting this boundary wrong in either direction is
163
+ * exactly the failure §12 exists to prevent -- a checker one increment
164
+ * looser or stricter than the trigger it is supposed to mirror would
165
+ * silently diverge from what the engine itself actually enforces.
166
+ * - fact NOT irreversible: a contradiction iff `validFromT <= claim.t`
167
+ * AND (`validToT === null` OR `claim.t < validToT`) -- the identical
168
+ * half-open reading `replay()` uses for "valid at t".
169
+ *
170
+ * A claim naming an entity/key with no fact in `mustHonor` yields NOTHING:
171
+ * the engine is silent about what it does not know, and silence is not a
172
+ * verdict (hard rule 2, §5.5's "the engine records the decision, it does
173
+ * not make it" -- applied here to what it has never recorded at all).
174
+ */
175
+ export declare function contradictions(constraint: NarrationConstraint, claims: readonly Claim[]): Contradiction[];
@@ -0,0 +1,259 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ import { assertT, compareT } from "./t.js";
3
+ import { openingEventId } from "./provenance.js";
4
+ /**
5
+ * The outbound half of authority (design §5.2b/§5.2c, GitHub issues #11 and
6
+ * #12): "Here is what is true; depict it, do not argue with it." One
7
+ * consumer enforces this live, inside one session, resolution preceding
8
+ * narration in one conversation -- its world advances a turn at a time,
9
+ * with a player making uncertain decisions right up until resolution says
10
+ * what actually happened. The other cannot: its units have duration and
11
+ * everything in them is already known in advance, and its narrator output
12
+ * is generated once, reviewed by a human, committed as a file, and
13
+ * rendered hours later by a process that must never call a model. There is
14
+ * no live moment inside that pipeline for a constraint to be checked at --
15
+ * enforcement there is a lint over a finished artifact, at a different
16
+ * point in time from generation entirely.
17
+ *
18
+ * **This is why the whole module is built around ONE serialized structure
19
+ * and TWO enforcement points, not a live handshake.** `narrationConstraintAt`
20
+ * is the only function here that touches the database; everything below it
21
+ * -- `Claim`, `Contradiction`, `contradictions()` -- operates purely on the
22
+ * plain-data shape `narrationConstraintAt` returns, with no database call
23
+ * anywhere in that path. That is not an implementation detail worth noting
24
+ * in passing: it is the entire reason the second consumer above can use
25
+ * this at all (§5.2b's "the condition that decides whether it can use it").
26
+ * See `narration.test.ts`'s "works on a JSON-rehydrated object with the
27
+ * database closed" test, which proves the property by actually closing the
28
+ * database mid-test and running `contradictions` on a
29
+ * `JSON.parse(JSON.stringify(...))` round trip.
30
+ *
31
+ * **Prohibitions are derived and structural, never authored and lexical**
32
+ * (hard rule 5, §5.2b, and the four recorded instances of this exact
33
+ * mistake across two codebases catalogued in root CLAUDE.md). This module
34
+ * has no `mustNotSay`, no `avoid`, no `forbidden`, no `negativePrompt`, no
35
+ * phrase list, and no severity or `isValid`/`ok`/`passed` field anywhere.
36
+ * `Claim` deliberately has no `text` field and never will: turning prose
37
+ * into a structured claim is the CALLER's job, downstream of this object
38
+ * (hard rule 4 -- the engine compares a claim against facts, never text
39
+ * against a word list). Negation is unconstructable here because the type
40
+ * that would carry it -- a negative fact, a forbidden-value list -- was
41
+ * simply never given a field to live in, not because something scans for
42
+ * one and rejects it.
43
+ *
44
+ * §5.2c's one hop of causality travels on every fact via `FactProvenance`
45
+ * (provenance.ts), the same shape `IrreversibleFact` (irreversible.ts) now
46
+ * extends -- there is exactly one owner of "the fact plus the event that
47
+ * opened it" in this codebase, not a second copy grown for this module.
48
+ */
49
+ export const NARRATION_CONSTRAINT_FORMAT_VERSION = 1;
50
+ function toConstraintFact(row, gameId) {
51
+ assertT(row.valid_from_t);
52
+ if (row.valid_to_t !== null)
53
+ assertT(row.valid_to_t);
54
+ return {
55
+ factId: row.id,
56
+ entityId: row.entity_id,
57
+ key: row.key,
58
+ value: row.value,
59
+ validFromT: row.valid_from_t,
60
+ validToT: row.valid_to_t,
61
+ irreversible: Boolean(row.irreversible),
62
+ entityKind: row.entity_kind,
63
+ entityName: row.entity_name,
64
+ openedByEventId: openingEventId(gameId, row.entity_id, row.valid_from_t),
65
+ };
66
+ }
67
+ /**
68
+ * The constraint that holds for `gameId` at `t`, optionally narrowed to
69
+ * `entityIds`. The only function in this module that touches the database
70
+ * -- see the module doc comment for why that boundary is load-bearing.
71
+ *
72
+ * Includes, for the game (§5.2b, §5.2c):
73
+ *
74
+ * 1. every fact valid at `t` on an entity alive at `t` -- the EXACT
75
+ * half-open predicates `replay.ts` uses (`replay()`'s own doc comment
76
+ * spells out why both must be half-open and identical everywhere):
77
+ * alive: created_at_t <= t AND (destroyed_at_t IS NULL OR destroyed_at_t > t)
78
+ * valid: valid_from_t <= t AND (valid_to_t IS NULL OR valid_to_t > t)
79
+ * `replay.ts`'s own `ALIVE_AT_T` constant is module-private and out of
80
+ * scope for this module to import (it would mean editing replay.ts,
81
+ * which this change does not touch) -- so the predicate text is
82
+ * reproduced here character-for-character rather than paraphrased,
83
+ * which is what "EXACT" in the task brief means in practice.
84
+ *
85
+ * 2. every fact with `irreversible = 1` and `valid_from_t <= t`, EVEN IF
86
+ * CLOSED, and EVEN IF its entity was destroyed. This is not a hedge --
87
+ * it is the entire motivating failure of design §5.2b and root
88
+ * CLAUDE.md's hard rule 3: an irreversible fact is prohibited for
89
+ * every `t' >= its valid_from_t` regardless of what has happened to
90
+ * the entity or the fact's own interval since, so a destroyed island
91
+ * cannot quietly exist again just because the fact that destroyed it
92
+ * closed, or the entity itself was later deleted. Entities are
93
+ * append-only (`timeline_entities_no_delete`, schema.ts) and facts are
94
+ * append-only (`timeline_facts_no_delete`), so both rows are always
95
+ * still there to join against -- name and kind survive destruction.
96
+ *
97
+ * The two sets are combined with a single SQL `OR` inside one query rather
98
+ * than two queries merged in JS, because that makes de-duplication free: a
99
+ * physical fact row satisfies the combined WHERE clause at most once, so a
100
+ * fact that is BOTH currently valid AND irreversible (the common case --
101
+ * most irreversible facts are also the currently-open truth) can never
102
+ * appear twice in `mustHonor` the way a naive UNION of two result sets
103
+ * would risk.
104
+ *
105
+ * Deterministic, total ordering ending in the primary key
106
+ * (`valid_from_t`, `entity_id`, `key`, `id`) -- `export.ts`'s own
107
+ * discipline, copied for the same reason: two calls over the same world
108
+ * must `JSON.stringify` byte-identically, and an ORDER BY that does not
109
+ * bottom out at a column SQLite guarantees is unique leaves the tie broken
110
+ * by unspecified query-plan order instead.
111
+ */
112
+ export function narrationConstraintAt(params) {
113
+ const { gameId, t } = params;
114
+ assertT(t);
115
+ // An explicitly empty entityIds list narrows to nothing, not to
116
+ // "unnarrowed" -- and short-circuiting here also sidesteps an invalid
117
+ // `IN ()` in the SQL below, which SQLite (correctly) does not accept as
118
+ // "matches nothing".
119
+ if (params.entityIds !== undefined && params.entityIds.length === 0) {
120
+ return { formatVersion: NARRATION_CONSTRAINT_FORMAT_VERSION, gameId, t, mustHonor: [] };
121
+ }
122
+ const db = getDatabase();
123
+ const entityIds = params.entityIds;
124
+ const entityFilter = entityIds !== undefined ? ` AND f.entity_id IN (${entityIds.map(() => "?").join(",")})` : "";
125
+ const rows = db
126
+ .prepare(`SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value,
127
+ f.valid_from_t AS valid_from_t, f.valid_to_t AS valid_to_t, f.irreversible AS irreversible,
128
+ e.kind AS entity_kind, e.name AS entity_name
129
+ FROM facts f
130
+ JOIN entities e ON e.id = f.entity_id
131
+ WHERE e.game_id = ?
132
+ AND (
133
+ (e.created_at_t <= ? AND (e.destroyed_at_t IS NULL OR e.destroyed_at_t > ?)
134
+ AND f.valid_from_t <= ? AND (f.valid_to_t IS NULL OR f.valid_to_t > ?))
135
+ OR
136
+ (f.irreversible = 1 AND f.valid_from_t <= ?)
137
+ )
138
+ ${entityFilter}
139
+ ORDER BY f.valid_from_t, f.entity_id, f.key, f.id`)
140
+ .all(gameId, t, t, t, t, t, ...(entityIds ?? []));
141
+ const mustHonor = rows.map((row) => toConstraintFact(row, gameId));
142
+ return { formatVersion: NARRATION_CONSTRAINT_FORMAT_VERSION, gameId, t, mustHonor };
143
+ }
144
+ /**
145
+ * Whether `claimValue` and `factValue` name the same value -- the highest-
146
+ * risk detail in this whole module, per the task brief. `resources.value`
147
+ * is a REAL column, and the projection triggers CAST every projected value
148
+ * to TEXT (`CAST(NEW.value AS TEXT)`, projection.ts), so a fact for a
149
+ * numeric resource does NOT read the JS-formatted "20" -- it reads
150
+ * whatever SQLite's own REAL-to-TEXT cast produces, typically "20.0" (see
151
+ * `castedTextForm`'s doc comment in constrained.ts, and
152
+ * `narration.test.ts`'s test that reads this string from a real database
153
+ * rather than guessing it). A caller naturally holds a plain JS number.
154
+ * Comparing "20" against "20.0" as bare strings would raise a false
155
+ * contradiction -- exactly the "checker gets satisfied instead of
156
+ * understood" failure design §5.2c/issue #12 exists to prevent, one layer
157
+ * up from causality: getting the CHECK itself wrong is worse than getting
158
+ * the reviewer's context for it wrong.
159
+ *
160
+ * So: if BOTH sides parse as finite numbers (trimmed, non-empty), compare
161
+ * numerically with `===` and no epsilon -- the engine picks no tolerance,
162
+ * the same policy `compareT` and every other axis comparison in this
163
+ * codebase takes (no fuzzing what "equal" means). Otherwise compare as
164
+ * exact strings, which is correct for every non-numeric fact key ("status"
165
+ * = "destroyed" vs "rebuilt" has no numeric reading to fall back to).
166
+ */
167
+ function valuesMatch(claimValue, factValue) {
168
+ const claimText = String(claimValue);
169
+ const claimNumber = parseFiniteNumber(claimText);
170
+ const factNumber = parseFiniteNumber(factValue);
171
+ if (claimNumber !== null && factNumber !== null) {
172
+ return claimNumber === factNumber;
173
+ }
174
+ return claimText === factValue;
175
+ }
176
+ function parseFiniteNumber(text) {
177
+ const trimmed = text.trim();
178
+ if (trimmed.length === 0)
179
+ return null;
180
+ const parsed = Number(trimmed);
181
+ return Number.isFinite(parsed) ? parsed : null;
182
+ }
183
+ /**
184
+ * Derives every contradiction between `claims` and the facts `constraint`
185
+ * says must be honored. Pure -- no database call anywhere in this
186
+ * function, which is the whole point (module doc comment above): the
187
+ * artifact-lint consumer runs this hours later on a JSON file, offline,
188
+ * with no engine and no model in the loop.
189
+ *
190
+ * For each `(claim, fact)` pair matching on `entityId` AND `key`, where the
191
+ * values differ (`valuesMatch` above):
192
+ *
193
+ * - fact `irreversible`: a contradiction iff
194
+ * `compareT(claim.t, fact.validFromT) >= 0` -- mirroring
195
+ * `timeline_facts_irreversible` (schema.ts) EXACTLY, `>=` included. The
196
+ * trigger's own comment explains why: `valid_from_t` IS the instant the
197
+ * new value takes effect, so "prohibited for all t' > t" (§5.2b's prose)
198
+ * is stated at the row level as `>=` against the OLD fact's
199
+ * `valid_from_t`. Getting this boundary wrong in either direction is
200
+ * exactly the failure §12 exists to prevent -- a checker one increment
201
+ * looser or stricter than the trigger it is supposed to mirror would
202
+ * silently diverge from what the engine itself actually enforces.
203
+ * - fact NOT irreversible: a contradiction iff `validFromT <= claim.t`
204
+ * AND (`validToT === null` OR `claim.t < validToT`) -- the identical
205
+ * half-open reading `replay()` uses for "valid at t".
206
+ *
207
+ * A claim naming an entity/key with no fact in `mustHonor` yields NOTHING:
208
+ * the engine is silent about what it does not know, and silence is not a
209
+ * verdict (hard rule 2, §5.5's "the engine records the decision, it does
210
+ * not make it" -- applied here to what it has never recorded at all).
211
+ */
212
+ export function contradictions(constraint, claims) {
213
+ // REFUSE A FORMAT THIS BUILD DOES NOT KNOW, rather than lint it anyway.
214
+ // This function's whole purpose is to run far from the engine that produced
215
+ // its input -- a different process, a different machine, hours or days
216
+ // later (module doc comment above) -- which means it is exactly the kind of
217
+ // code that meets a file written by a NEWER engine than itself. If a later
218
+ // format version ever adds something that bears on whether a claim
219
+ // contradicts a fact, an older checker reading it as v1 would not error; it
220
+ // would silently return FEWER contradictions and report a clean artifact.
221
+ // A checker that fails loudly can be fixed; a checker that quietly passes
222
+ // everything gets trusted, and design §5.2c/issue #12's whole finding is
223
+ // that a check nobody can argue with gets satisfied instead of understood.
224
+ // Refusing an unknown version keeps "this artifact was checked" from ever
225
+ // meaning "this artifact was checked by something that understood it."
226
+ //
227
+ // Deliberately `!==`, not `>`: a version this build has never heard of is
228
+ // unreadable whether it is newer or older, and guessing which direction is
229
+ // safe is how a compatibility window becomes a silent one.
230
+ if (constraint.formatVersion !== NARRATION_CONSTRAINT_FORMAT_VERSION) {
231
+ throw new Error(`narration constraint: format version ${constraint.formatVersion} is not the version this build ` +
232
+ `understands (${NARRATION_CONSTRAINT_FORMAT_VERSION}); refusing to check claims against it rather ` +
233
+ `than reporting a clean result it cannot justify`);
234
+ }
235
+ const found = [];
236
+ for (const claim of claims) {
237
+ for (const fact of constraint.mustHonor) {
238
+ if (fact.entityId !== claim.entityId || fact.key !== claim.key)
239
+ continue;
240
+ if (valuesMatch(claim.value, fact.value))
241
+ continue;
242
+ // Every comparison on the axis goes through `compareT` (t.ts), never a
243
+ // bare `<=` -- not because the two differ today (`compareT` is `a - b`,
244
+ // so they are identical for the finite numbers `assertT` admits) but
245
+ // because `t` is a declared axis with one owner of what ordering means
246
+ // on it, and a module that hand-rolls the comparison in one branch and
247
+ // delegates it in the other is one axis change away from the two
248
+ // branches disagreeing.
249
+ const disagrees = fact.irreversible
250
+ ? compareT(claim.t, fact.validFromT) >= 0
251
+ : compareT(fact.validFromT, claim.t) <= 0 &&
252
+ (fact.validToT === null || compareT(claim.t, fact.validToT) < 0);
253
+ if (disagrees) {
254
+ found.push({ claim, fact });
255
+ }
256
+ }
257
+ }
258
+ return found;
259
+ }