run-dmcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -10
- package/dist/bin/run-dmcp.d.ts +2 -0
- package/dist/bin/run-dmcp.js +55 -0
- package/dist/db/connection.d.ts +32 -0
- package/dist/db/connection.js +38 -16
- package/dist/db/schema.d.ts +29 -1
- package/dist/db/schema.js +439 -7
- package/dist/http/server.js +3 -3
- package/dist/index.d.ts +36 -2
- package/dist/index.js +184 -92
- package/dist/mcp-server.d.ts +49 -0
- package/dist/mcp-server.js +127 -0
- package/dist/reader/turnReader.d.ts +185 -0
- package/dist/reader/turnReader.js +288 -0
- package/dist/register/batch.js +5 -79
- package/dist/register/mcp-resources.d.ts +9 -0
- package/dist/register/mcp-resources.js +16 -62
- package/dist/register/render.d.ts +17 -0
- package/dist/register/render.js +50 -0
- package/dist/register/resolve.d.ts +14 -0
- package/dist/register/resolve.js +102 -0
- package/dist/register/resources.js +11 -4
- package/dist/register/timeline.d.ts +2 -0
- package/dist/register/timeline.js +311 -0
- package/dist/rpg/index.d.ts +29 -0
- package/dist/rpg/index.js +55 -0
- package/dist/rpg/register/abilities.d.ts +2 -0
- package/dist/rpg/register/abilities.js +165 -0
- package/dist/rpg/register/batch.d.ts +2 -0
- package/dist/rpg/register/batch.js +92 -0
- package/dist/rpg/register/combat.d.ts +2 -0
- package/dist/rpg/register/combat.js +207 -0
- package/dist/rpg/register/mcp-prompts.d.ts +2 -0
- package/dist/rpg/register/mcp-prompts.js +684 -0
- package/dist/rpg/register/mcp-resources.d.ts +2 -0
- package/dist/rpg/register/mcp-resources.js +61 -0
- package/dist/rpg/register/quests.d.ts +2 -0
- package/dist/rpg/register/quests.js +118 -0
- package/dist/rpg/register/status.d.ts +2 -0
- package/dist/rpg/register/status.js +130 -0
- package/dist/rpg/register/tables.d.ts +2 -0
- package/dist/rpg/register/tables.js +146 -0
- package/dist/rpg/tools/ability.d.ts +48 -0
- package/dist/rpg/tools/ability.js +238 -0
- package/dist/rpg/tools/combat.d.ts +13 -0
- package/dist/rpg/tools/combat.js +195 -0
- package/dist/rpg/tools/dice.d.ts +23 -0
- package/dist/rpg/tools/dice.js +111 -0
- package/dist/rpg/tools/quest.d.ts +34 -0
- package/dist/rpg/tools/quest.js +164 -0
- package/dist/rpg/tools/status.d.ts +36 -0
- package/dist/rpg/tools/status.js +218 -0
- package/dist/rpg/tools/tables.d.ts +33 -0
- package/dist/rpg/tools/tables.js +209 -0
- package/dist/schemas/index.d.ts +12 -12
- package/dist/timeline/adjudication.d.ts +150 -0
- package/dist/timeline/adjudication.js +174 -0
- package/dist/timeline/changes.d.ts +100 -0
- package/dist/timeline/changes.js +161 -0
- package/dist/timeline/checkpoint.d.ts +69 -0
- package/dist/timeline/checkpoint.js +131 -0
- package/dist/timeline/clock.d.ts +89 -0
- package/dist/timeline/clock.js +173 -0
- package/dist/timeline/constrained.d.ts +220 -0
- package/dist/timeline/constrained.js +671 -0
- package/dist/timeline/export.d.ts +171 -0
- package/dist/timeline/export.js +329 -0
- package/dist/timeline/irreversible.d.ts +85 -0
- package/dist/timeline/irreversible.js +108 -0
- package/dist/timeline/kinds.d.ts +14 -0
- package/dist/timeline/kinds.js +22 -0
- package/dist/timeline/narration.d.ts +175 -0
- package/dist/timeline/narration.js +259 -0
- package/dist/timeline/projection.d.ts +97 -0
- package/dist/timeline/projection.js +330 -0
- package/dist/timeline/provenance.d.ts +66 -0
- package/dist/timeline/provenance.js +45 -0
- package/dist/timeline/registry.d.ts +95 -0
- package/dist/timeline/registry.js +124 -0
- package/dist/timeline/render.d.ts +121 -0
- package/dist/timeline/render.js +187 -0
- package/dist/timeline/replay.d.ts +64 -0
- package/dist/timeline/replay.js +104 -0
- package/dist/timeline/resolve.d.ts +262 -0
- package/dist/timeline/resolve.js +226 -0
- package/dist/timeline/schema.d.ts +13 -0
- package/dist/timeline/schema.js +262 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +31 -6
- package/dist/tools/resource.js +106 -153
- package/dist/types/index.d.ts +19 -1
- package/dist/utils/output-schemas.d.ts +593 -2
- package/dist/utils/output-schemas.js +3 -0
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +20 -4
- package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
- package/dist/__tests__/engineVocabulary.test.js +0 -147
- package/dist/db/__tests__/connection.test.d.ts +0 -1
- package/dist/db/__tests__/connection.test.js +0 -72
- package/dist/db/__tests__/testDb.d.ts +0 -33
- package/dist/db/__tests__/testDb.js +0 -41
- package/dist/test-setup.d.ts +0 -1
- package/dist/test-setup.js +0 -13
- package/dist/tools/__tests__/audio.test.d.ts +0 -1
- package/dist/tools/__tests__/audio.test.js +0 -59
- package/dist/tools/__tests__/conserved.test.d.ts +0 -1
- package/dist/tools/__tests__/conserved.test.js +0 -488
- package/dist/tools/__tests__/constraint.test.d.ts +0 -1
- package/dist/tools/__tests__/constraint.test.js +0 -212
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
- package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
- package/dist/tools/__tests__/images.test.d.ts +0 -1
- package/dist/tools/__tests__/images.test.js +0 -59
- package/dist/tools/__tests__/relationship.test.d.ts +0 -1
- package/dist/tools/__tests__/relationship.test.js +0 -132
- package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
- package/dist/tools/__tests__/resource-constraints.test.js +0 -131
- package/dist/tools/__tests__/resource.test.d.ts +0 -1
- package/dist/tools/__tests__/resource.test.js +0 -190
- package/dist/tools/__tests__/time.test.d.ts +0 -1
- package/dist/tools/__tests__/time.test.js +0 -404
- package/dist/tools/__tests__/timers.test.d.ts +0 -1
- package/dist/tools/__tests__/timers.test.js +0 -426
- package/dist/tools/__tests__/world.test.d.ts +0 -1
- package/dist/tools/__tests__/world.test.js +0 -70
- package/dist/utils/__tests__/json.test.d.ts +0 -1
- package/dist/utils/__tests__/json.test.js +0 -55
- package/dist/utils/__tests__/validation.test.d.ts +0 -1
- package/dist/utils/__tests__/validation.test.js +0 -90
|
@@ -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
|
+
}
|