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,169 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ import { assertT, compareT } from "./t.js";
3
+ /**
4
+ * The deterministic total order every `changesWithin` result is sorted
5
+ * into: `t` ascending, then a tiebreaker chain that can never itself tie,
6
+ * so two rows can never come back in a different order across two runs of
7
+ * the same query (design §6's reproducibility depends on this for anyone
8
+ * freezing an artifact from these rows).
9
+ *
10
+ * Tiebreak chain, in order:
11
+ * 1. `t` (via `compareT`, the one comparator this codebase orders `t`
12
+ * through).
13
+ * 2. `kind` -- "event" sorts before "fact"; fixed and arbitrary, but
14
+ * fixed is all determinism requires.
15
+ * 3. The row's own id (`eventId` or `factId`) -- both are primary keys,
16
+ * so this alone would already be unique EXCEPT for one case:
17
+ * 4. `endpoint` -- a zero-width fact interval (`validFromT === validToT`)
18
+ * produces two rows sharing both `t` and `factId`; only `endpoint`
19
+ * ("closed" < "opened") separates them.
20
+ */
21
+ function compareChanges(a, b) {
22
+ const byT = compareT(a.t, b.t);
23
+ if (byT !== 0)
24
+ return byT;
25
+ if (a.kind !== b.kind) {
26
+ return a.kind < b.kind ? -1 : 1;
27
+ }
28
+ if (a.kind === "event" && b.kind === "event") {
29
+ return a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0;
30
+ }
31
+ // Both "fact" at this point (the `a.kind !== b.kind` branch above already
32
+ // returned otherwise), but TypeScript can't narrow a discriminated union
33
+ // through two independent variables -- assert what's already established.
34
+ const factA = a;
35
+ const factB = b;
36
+ if (factA.factId !== factB.factId) {
37
+ return factA.factId < factB.factId ? -1 : 1;
38
+ }
39
+ return factA.endpoint < factB.endpoint ? -1 : factA.endpoint > factB.endpoint ? 1 : 0;
40
+ }
41
+ /**
42
+ * `changesWithin(t0, t1)` -- design §5.5's "because units have duration":
43
+ * every event and fact-interval transition recorded in one game's history
44
+ * during the half-open window `[t0, t1)`.
45
+ *
46
+ * Half-open, matching `replay.ts`'s intervals exactly and for the same
47
+ * reason (design §5.1): `t0` is in, `t1` is not. An event at exactly `t1`,
48
+ * or a fact endpoint landing exactly at `t1`, belongs to whatever window
49
+ * starts there, never to this one.
50
+ *
51
+ * `t1 === t0` is a legal empty window (returns zero rows, refused nowhere).
52
+ * `t1 < t0` is refused loudly, naming both values, before either query
53
+ * runs -- silently returning zero rows for a caller's off-by-one would be
54
+ * far more expensive to track down than a thrown error naming the mistake.
55
+ *
56
+ * Exactly two queries, never one per entity -- same reasoning as
57
+ * `replay()`: this runs over a whole game's history, and one prepared
58
+ * statement per entity would both hit SQLite's bound-variable limit on a
59
+ * large game and defeat better-sqlite3's prepared-statement cache. Facts
60
+ * are scoped to the game via a JOIN to `entities` on `entity_id` (there is
61
+ * no FK-enforced game_id on `facts` itself, and `facts.entity_id` is a real
62
+ * foreign key with referential integrity -- see the task briefing on why
63
+ * that JOIN, not a raw string match, is the identity axis to scope on).
64
+ * Events carry `game_id` directly and need no join.
65
+ *
66
+ * Deliberately NOT filtered by entity aliveness. `replay(t)` answers "what
67
+ * was true at an instant" and needs "alive at t" to make that meaningful;
68
+ * this answers "what transitions were recorded in a window", and a
69
+ * transition belonging to an entity that was later destroyed is still a
70
+ * transition that was recorded -- destroying the entity afterward doesn't
71
+ * retroactively un-happen it. Filtering these rows by aliveness would be
72
+ * exactly the kind of policy this module isn't allowed to have an opinion
73
+ * on (see `ChangeSet`'s doc comment).
74
+ *
75
+ * DECISION(#18): changesWithin() returns every transition, whoever could observe it.
76
+ *
77
+ * Omniscient for the same reason and by the same decision as `replay()` --
78
+ * see its doc comment for the argument. Every transition in the window is
79
+ * returned regardless of which principal could have observed it, and a
80
+ * later per-principal filter arrives as one predicate on the two queries
81
+ * below (issue #18).
82
+ */
83
+ export function changesWithin(params) {
84
+ const { gameId, t0, t1 } = params;
85
+ // A Date, a string, NaN or +-Infinity gets refused here, loudly, on
86
+ // either bound, before it can silently compare unequal to every row and
87
+ // produce a confidently wrong empty result.
88
+ assertT(t0);
89
+ assertT(t1);
90
+ // Through `compareT`, not a bare `<`, for the reason t.ts gives: it is the
91
+ // one comparator everything in the timeline that ORDERS `t` goes through,
92
+ // so a future axis can never introduce a second notion of "later" that
93
+ // this guard alone disagrees with. Same shape clock.ts uses for its own
94
+ // never-run-backwards refusals.
95
+ if (compareT(t1, t0) < 0) {
96
+ throw new Error(`timeline: changesWithin requires t1 >= t0, got t0=${t0}, t1=${t1}`);
97
+ }
98
+ const db = getDatabase();
99
+ const changes = [];
100
+ // Query 1 of 2: events at_t in [t0, t1), scoped directly by game_id.
101
+ const eventRows = db
102
+ .prepare(`SELECT id, at_t, kind, description, causes
103
+ FROM events
104
+ WHERE game_id = ?
105
+ AND at_t >= ?
106
+ AND at_t < ?`)
107
+ .all(gameId, t0, t1);
108
+ for (const row of eventRows) {
109
+ changes.push({
110
+ kind: "event",
111
+ t: row.at_t,
112
+ eventId: row.id,
113
+ eventKind: row.kind,
114
+ description: row.description,
115
+ causes: row.causes,
116
+ });
117
+ }
118
+ // Query 2 of 2: every fact whose valid_from_t OR valid_to_t landed in the
119
+ // window, joined back to entities so scoping is by game_id via the real
120
+ // FK-backed identity chain rather than a column on `facts` itself. Same
121
+ // half-open predicate as query 1, applied independently to each endpoint
122
+ // -- a fact can have one endpoint in the window and the other outside it
123
+ // (that's the "opened before t0, closes inside" / "opens inside, still
124
+ // open" cases below), so this is deliberately an OR over two half-open
125
+ // checks, not one range check over the whole interval.
126
+ const factRows = db
127
+ .prepare(`SELECT f.id, f.entity_id, f.key, f.value, f.valid_from_t, f.valid_to_t
128
+ FROM facts f
129
+ JOIN entities e ON e.id = f.entity_id
130
+ WHERE e.game_id = ?
131
+ AND (
132
+ (f.valid_from_t >= ? AND f.valid_from_t < ?)
133
+ OR (f.valid_to_t IS NOT NULL AND f.valid_to_t >= ? AND f.valid_to_t < ?)
134
+ )`)
135
+ .all(gameId, t0, t1, t0, t1);
136
+ for (const row of factRows) {
137
+ const opensInWindow = row.valid_from_t >= t0 && row.valid_from_t < t1;
138
+ const closesInWindow = row.valid_to_t !== null && row.valid_to_t >= t0 && row.valid_to_t < t1;
139
+ if (opensInWindow) {
140
+ changes.push({
141
+ kind: "fact",
142
+ t: row.valid_from_t,
143
+ factId: row.id,
144
+ entityId: row.entity_id,
145
+ factKey: row.key,
146
+ value: row.value,
147
+ endpoint: "opened",
148
+ validFromT: row.valid_from_t,
149
+ validToT: row.valid_to_t,
150
+ });
151
+ }
152
+ if (closesInWindow) {
153
+ changes.push({
154
+ kind: "fact",
155
+ // row.valid_to_t is not null here (closesInWindow already checked).
156
+ t: row.valid_to_t,
157
+ factId: row.id,
158
+ entityId: row.entity_id,
159
+ factKey: row.key,
160
+ value: row.value,
161
+ endpoint: "closed",
162
+ validFromT: row.valid_from_t,
163
+ validToT: row.valid_to_t,
164
+ });
165
+ }
166
+ }
167
+ changes.sort(compareChanges);
168
+ return { gameId, t0, t1, changes };
169
+ }
@@ -0,0 +1,69 @@
1
+ import type { EntityKind } from "./kinds.js";
2
+ /**
3
+ * One row where the live tables and the replayed timeline disagree.
4
+ *
5
+ * This is a record, not a verdict -- hard rule 2 (design §5.5, §13): the
6
+ * engine reports what it found and the caller decides what it means. There
7
+ * is no `isClean`, no severity, no summary string, and `timelineDivergences`
8
+ * returning `[]` is itself the only "everything matched" this module ever
9
+ * says. The Phase 1 checkpoint (design §11, §13) is the caller that treats a
10
+ * non-empty result as a stop condition; that policy lives in the test, not
11
+ * here.
12
+ *
13
+ * - `missing-entity`: a live row exists with no entity of `kind` alive at
14
+ * `now` for its id.
15
+ * - `missing-row`: an entity of `kind` is alive at `now` with no live row.
16
+ * - `value`: `key` (a column of `table`) disagrees between the live row and
17
+ * the fact valid at `now` -- covers a value mismatch, a non-NULL column
18
+ * with no fact, and a fact present where the column is NULL, all as the
19
+ * same shape (see `timelineDivergences`'s doc comment for why one
20
+ * comparison catches all three).
21
+ * - `duplicate-fact`: more than one fact is valid at `now` for the same
22
+ * `(entityId, key)`. Checked directly against `facts`, never through
23
+ * `replay()`'s `Record<string, ReplayedFact>` -- a duplicate would
24
+ * collapse into that Record silently, so this is the one divergence a
25
+ * checkpoint that only called `replay()` could never see.
26
+ */
27
+ export interface Divergence {
28
+ reason: "missing-entity" | "missing-row" | "value" | "duplicate-fact";
29
+ table: string;
30
+ kind: EntityKind;
31
+ entityId: string;
32
+ key?: string;
33
+ live?: string | null;
34
+ replayed?: string | null;
35
+ }
36
+ /**
37
+ * Design §13's Phase 1 stop condition, made checkable: does `replay(now)`
38
+ * reproduce the live projected tables, exactly, for `gameId`?
39
+ *
40
+ * This calls `replay()` -- it does not re-derive "alive at t" or "valid at
41
+ * t" from `entities`/`facts` itself. Re-implementing those predicates here
42
+ * would make this a test of a second copy of the logic, not of the query
43
+ * issue #4 exists to check; the whole reason a checkpoint is worth having is
44
+ * that it exercises the same code path a real caller would. `liveColumns` is
45
+ * imported from `projection.ts` for the same reason -- one owner for the
46
+ * fact-key list, so this can never compare a different set of columns than
47
+ * the triggers actually project.
48
+ *
49
+ * `now` is read from `timeline_clock.current_t` via `currentStoryTime` --
50
+ * never a caller-supplied `t` -- because "current state" (design §11's
51
+ * checkpoint) means *this game's* current position on its own timeline, not
52
+ * an arbitrary point a test happened to pick.
53
+ *
54
+ * Every live column is compared as `CAST(... AS TEXT)`, produced by SQLite
55
+ * on both sides of every comparison (this side, and inside `replay()`'s own
56
+ * queries and the projection triggers that wrote the facts in the first
57
+ * place). Comparing in JS would invent divergences that are not there:
58
+ * `CAST(100.0 AS TEXT)` is `'100.0'` in SQLite, but better-sqlite3 hands the
59
+ * same REAL column to JS as the number `100` -- see timeline-architecture.md
60
+ * for the measured behaviour. A single strict-equality check between the
61
+ * live `CAST` string (or `null`) and the replayed fact's value (or `null`
62
+ * when no fact is open) is then enough to catch all three `value` shapes at
63
+ * once: a live NULL with an open fact, a non-NULL live column with no open
64
+ * fact, and two non-NULL values that simply disagree -- there is no reason
65
+ * to special-case any of them, because `null !== "x"` is already true for
66
+ * every combination that should diverge and false for every one that
67
+ * shouldn't.
68
+ */
69
+ export declare function timelineDivergences(gameId: string): Divergence[];
@@ -0,0 +1,131 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ import { currentStoryTime } from "./clock.js";
3
+ import { replay } from "./replay.js";
4
+ import { PROJECTED_TABLES, liveColumns } from "./projection.js";
5
+ /**
6
+ * Design §13's Phase 1 stop condition, made checkable: does `replay(now)`
7
+ * reproduce the live projected tables, exactly, for `gameId`?
8
+ *
9
+ * This calls `replay()` -- it does not re-derive "alive at t" or "valid at
10
+ * t" from `entities`/`facts` itself. Re-implementing those predicates here
11
+ * would make this a test of a second copy of the logic, not of the query
12
+ * issue #4 exists to check; the whole reason a checkpoint is worth having is
13
+ * that it exercises the same code path a real caller would. `liveColumns` is
14
+ * imported from `projection.ts` for the same reason -- one owner for the
15
+ * fact-key list, so this can never compare a different set of columns than
16
+ * the triggers actually project.
17
+ *
18
+ * `now` is read from `timeline_clock.current_t` via `currentStoryTime` --
19
+ * never a caller-supplied `t` -- because "current state" (design §11's
20
+ * checkpoint) means *this game's* current position on its own timeline, not
21
+ * an arbitrary point a test happened to pick.
22
+ *
23
+ * Every live column is compared as `CAST(... AS TEXT)`, produced by SQLite
24
+ * on both sides of every comparison (this side, and inside `replay()`'s own
25
+ * queries and the projection triggers that wrote the facts in the first
26
+ * place). Comparing in JS would invent divergences that are not there:
27
+ * `CAST(100.0 AS TEXT)` is `'100.0'` in SQLite, but better-sqlite3 hands the
28
+ * same REAL column to JS as the number `100` -- see timeline-architecture.md
29
+ * for the measured behaviour. A single strict-equality check between the
30
+ * live `CAST` string (or `null`) and the replayed fact's value (or `null`
31
+ * when no fact is open) is then enough to catch all three `value` shapes at
32
+ * once: a live NULL with an open fact, a non-NULL live column with no open
33
+ * fact, and two non-NULL values that simply disagree -- there is no reason
34
+ * to special-case any of them, because `null !== "x"` is already true for
35
+ * every combination that should diverge and false for every one that
36
+ * shouldn't.
37
+ */
38
+ export function timelineDivergences(gameId) {
39
+ const db = getDatabase();
40
+ // No clock row means nothing has ever been declared or written for this
41
+ // game -- there is no `now` to replay to, and (by construction: every
42
+ // projection trigger bootstraps a clock row on its game's first insert)
43
+ // no live row anywhere could exist for it either. Nothing to compare,
44
+ // nothing to diverge.
45
+ const clock = currentStoryTime(gameId);
46
+ if (!clock)
47
+ return [];
48
+ const now = clock.t;
49
+ const snapshot = replay({ gameId, t: now });
50
+ // Index the snapshot by (kind, id) once, rather than scanning the whole
51
+ // snapshot per projected table -- PROJECTED_TABLES has one row per kind,
52
+ // and this runs once per checkpoint call, not once per entity.
53
+ const aliveByKind = new Map();
54
+ for (const entity of snapshot.entities) {
55
+ let byId = aliveByKind.get(entity.kind);
56
+ if (!byId) {
57
+ byId = new Map();
58
+ aliveByKind.set(entity.kind, byId);
59
+ }
60
+ byId.set(entity.id, entity);
61
+ }
62
+ const divergences = [];
63
+ for (const row of PROJECTED_TABLES) {
64
+ const cols = liveColumns(db, row.table);
65
+ // Every column CAST(... AS TEXT) in the same SELECT that fetches the
66
+ // live row -- see this function's doc comment for why that CAST has to
67
+ // happen in SQL rather than after the row reaches JS.
68
+ const columnList = cols.map((col) => `CAST(${col} AS TEXT) AS "${col}"`).join(", ");
69
+ const liveRows = db
70
+ .prepare(`SELECT id, ${columnList} FROM ${row.table} WHERE ${row.gameIdColumn} = ?`)
71
+ .all(gameId);
72
+ const aliveOfKind = aliveByKind.get(row.kind) ?? new Map();
73
+ const liveIds = new Set();
74
+ for (const liveRow of liveRows) {
75
+ const id = liveRow.id;
76
+ liveIds.add(id);
77
+ const entity = aliveOfKind.get(id);
78
+ if (!entity) {
79
+ divergences.push({ reason: "missing-entity", table: row.table, kind: row.kind, entityId: id });
80
+ continue;
81
+ }
82
+ for (const col of cols) {
83
+ const liveValue = liveRow[col];
84
+ const fact = entity.facts[col];
85
+ const replayedValue = fact ? fact.value : null;
86
+ if (liveValue !== replayedValue) {
87
+ divergences.push({
88
+ reason: "value",
89
+ table: row.table,
90
+ kind: row.kind,
91
+ entityId: id,
92
+ key: col,
93
+ live: liveValue,
94
+ replayed: replayedValue,
95
+ });
96
+ }
97
+ }
98
+ }
99
+ for (const id of aliveOfKind.keys()) {
100
+ if (!liveIds.has(id)) {
101
+ divergences.push({ reason: "missing-row", table: row.table, kind: row.kind, entityId: id });
102
+ }
103
+ }
104
+ }
105
+ // duplicate-fact: the one divergence replay() itself could never surface,
106
+ // because its Record<string, ReplayedFact> is keyed by `key` -- a second
107
+ // fact valid at `now` for the same (entity_id, key) would just overwrite
108
+ // the first in that Record rather than raise anything. Checked directly
109
+ // against `facts`, scoped to this game's own entities via the join.
110
+ const tableByKind = new Map(PROJECTED_TABLES.map((r) => [r.kind, r.table]));
111
+ const duplicateRows = db
112
+ .prepare(`SELECT f.entity_id AS entityId, f.key AS key, e.kind AS kind
113
+ FROM facts f
114
+ JOIN entities e ON e.id = f.entity_id
115
+ WHERE e.game_id = ?
116
+ AND f.valid_from_t <= ?
117
+ AND (f.valid_to_t IS NULL OR f.valid_to_t > ?)
118
+ GROUP BY f.entity_id, f.key
119
+ HAVING COUNT(*) > 1`)
120
+ .all(gameId, now, now);
121
+ for (const dup of duplicateRows) {
122
+ divergences.push({
123
+ reason: "duplicate-fact",
124
+ table: tableByKind.get(dup.kind) ?? dup.kind,
125
+ kind: dup.kind,
126
+ entityId: dup.entityId,
127
+ key: dup.key,
128
+ });
129
+ }
130
+ return divergences;
131
+ }
@@ -0,0 +1,89 @@
1
+ import { type T, type TimeAxis } from "./t.js";
2
+ /**
3
+ * One game's declared position on its own timeline: where `t` currently
4
+ * sits, and the axis it is measured on. What `currentStoryTime` /
5
+ * `declareTimeAxis` / `setStoryTime` all read and write, one row per game in
6
+ * `timeline_clock` (schema.ts).
7
+ */
8
+ export interface StoryTime {
9
+ gameId: string;
10
+ t: T;
11
+ axis: TimeAxis;
12
+ }
13
+ /** Current position on a game's timeline, or null if nothing has ever been declared or written for it. */
14
+ export declare function currentStoryTime(gameId: string): StoryTime | null;
15
+ /**
16
+ * Declares (or re-declares) the axis a game's `t` moves along. This is the
17
+ * one place §14's property (hard rule 6) is enforced at runtime rather than
18
+ * merely documented by `TimeAxis`'s missing fourth variant (t.ts):
19
+ *
20
+ * - No clock row yet: create one at `startAt ?? 0` on the requested axis.
21
+ * This is the path a caller uses BEFORE creating anything for this game,
22
+ * so its world starts at its own origin -- 0.0 seconds, turn 0, whatever
23
+ * the caller's own axis calls zero -- rather than partway up the
24
+ * engine's append ordinal, which is what every game gets by default the
25
+ * moment its first entity is written with no axis declared (see
26
+ * projection.ts).
27
+ * - Re-declaring the IDENTICAL axis (same `kind`, and for `elapsed` /
28
+ * `counter` the same `unit`) is a no-op unless `startAt` is supplied, in
29
+ * which case it must be `>= current_t` -- `t` never runs backwards, full
30
+ * stop, even when the axis itself is not changing.
31
+ * - Declaring a DIFFERENT axis over a clock still on the default
32
+ * `sequence` axis is allowed -- nothing has committed to `sequence`
33
+ * meaning anything yet, it is just what every game gets before its
34
+ * owner says otherwise -- but `startAt` (defaulting to the current `t`)
35
+ * must still be `>= current_t`, so `t` never runs backwards across the
36
+ * change either.
37
+ * - Declaring a DIFFERENT axis over a clock already on a non-`sequence`
38
+ * axis is refused outright. An axis is fixed for the life of a game's
39
+ * timeline once it has been chosen for real: swapping it mid-timeline is
40
+ * precisely what re-segmenting a caller's own units does to the meaning
41
+ * of every `t` already recorded (§14) -- the one runtime action that
42
+ * reproduces that failure, and this is where it is refused rather than
43
+ * silently reinterpreting history. Declaring `sequence` back over a
44
+ * declared axis is refused by this exact same rule: `sequence` is a
45
+ * `kind` like any other here, not a neutral "no axis" state to fall back
46
+ * to.
47
+ *
48
+ * Known limit, pinned by a test in clock.test.ts (not desired behaviour): a
49
+ * game created through `createGame` already has its own creation write
50
+ * recorded on the default `sequence` axis before any caller can act --
51
+ * `createGame` has no parameter for a caller-supplied id, so there is no id
52
+ * to declare an axis against before that write lands. The practical effect
53
+ * is that such a game's declared axis has a floor above zero (`startAt`
54
+ * below the game's current `t` at declaration time is refused, by the
55
+ * backwards-`t` rule above, with that floor named in the message). The fix
56
+ * is to move the origin UP to the floor, never to shift the caller's own
57
+ * numbers down to fit -- silently offsetting an axis to fit is exactly the
58
+ * failure §14 exists to prevent, so this limit is enforced the same loud way
59
+ * every other one here is. This lifts only if `createGame` (or an
60
+ * equivalent) gains a caller-supplied id, which is a separate issue.
61
+ */
62
+ export declare function declareTimeAxis(params: {
63
+ gameId: string;
64
+ axis: TimeAxis;
65
+ startAt?: T;
66
+ }): StoryTime;
67
+ /**
68
+ * Moves a game's `t` forward. This is the caller's own hand on the clock --
69
+ * it exists only for a declared, non-`sequence` axis, because those are the
70
+ * only axes whose writes do not already advance `t` for themselves
71
+ * (projection.ts advances `current_t` on every write, but only `WHERE
72
+ * axis_kind = 'sequence'`; a declared `elapsed` or `counter` axis sits still
73
+ * between calls here by construction).
74
+ *
75
+ * Three refusals, none of them advisory:
76
+ * - no clock row for `gameId`: nothing has declared or written anything
77
+ * for this game yet, so there is no `t` to move.
78
+ * - the axis is still `sequence`: the append ordinal belongs to the
79
+ * engine, not to a caller positioning `t` by hand. This is the sharpest
80
+ * form of "make the wrong axis awkward to supply" the API has -- a
81
+ * caller who reaches for this on a default game is told to say what its
82
+ * axis actually is first, not handed a silent success that means
83
+ * nothing.
84
+ * - `t` would move backwards under `compareT`.
85
+ */
86
+ export declare function setStoryTime(params: {
87
+ gameId: string;
88
+ t: T;
89
+ }): StoryTime;
@@ -0,0 +1,173 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ import { assertT, compareT } from "./t.js";
3
+ /**
4
+ * `timeline_clock.axis_unit` always holds a string, including for
5
+ * `sequence` -- the generated projection triggers (projection.ts) write
6
+ * `'write'` there when they lazily bootstrap a clock row for a game that
7
+ * never called `declareTimeAxis`. The `TimeAxis` type has no `unit` field on
8
+ * `sequence` because a caller never chooses or reads it; this function
9
+ * exists so that fact is expressed once, not re-derived at every call site.
10
+ */
11
+ function axisToRow(axis) {
12
+ return axis.kind === "sequence" ? { kind: "sequence", unit: "write" } : axis;
13
+ }
14
+ function rowToAxis(row) {
15
+ return row.axis_kind === "sequence"
16
+ ? { kind: "sequence" }
17
+ : { kind: row.axis_kind, unit: row.axis_unit };
18
+ }
19
+ /** Human-readable axis, for error messages only -- never parsed back. */
20
+ function describeAxis(axis) {
21
+ return axis.kind === "sequence" ? "sequence (the engine's append ordinal)" : `${axis.kind}(${axis.unit})`;
22
+ }
23
+ /** `null` for `sequence` (no unit exists to compare), else the declared unit. */
24
+ function axisUnitOrNull(axis) {
25
+ return axis.kind === "sequence" ? null : axis.unit;
26
+ }
27
+ function sameAxis(a, b) {
28
+ return a.kind === b.kind && axisUnitOrNull(a) === axisUnitOrNull(b);
29
+ }
30
+ function readClock(gameId) {
31
+ return getDatabase()
32
+ .prepare(`SELECT game_id, current_t, axis_kind, axis_unit, declared_at FROM timeline_clock WHERE game_id = ?`)
33
+ .get(gameId);
34
+ }
35
+ /** Current position on a game's timeline, or null if nothing has ever been declared or written for it. */
36
+ export function currentStoryTime(gameId) {
37
+ const row = readClock(gameId);
38
+ if (!row)
39
+ return null;
40
+ return { gameId, t: row.current_t, axis: rowToAxis(row) };
41
+ }
42
+ /**
43
+ * Declares (or re-declares) the axis a game's `t` moves along. This is the
44
+ * one place §14's property (hard rule 6) is enforced at runtime rather than
45
+ * merely documented by `TimeAxis`'s missing fourth variant (t.ts):
46
+ *
47
+ * - No clock row yet: create one at `startAt ?? 0` on the requested axis.
48
+ * This is the path a caller uses BEFORE creating anything for this game,
49
+ * so its world starts at its own origin -- 0.0 seconds, turn 0, whatever
50
+ * the caller's own axis calls zero -- rather than partway up the
51
+ * engine's append ordinal, which is what every game gets by default the
52
+ * moment its first entity is written with no axis declared (see
53
+ * projection.ts).
54
+ * - Re-declaring the IDENTICAL axis (same `kind`, and for `elapsed` /
55
+ * `counter` the same `unit`) is a no-op unless `startAt` is supplied, in
56
+ * which case it must be `>= current_t` -- `t` never runs backwards, full
57
+ * stop, even when the axis itself is not changing.
58
+ * - Declaring a DIFFERENT axis over a clock still on the default
59
+ * `sequence` axis is allowed -- nothing has committed to `sequence`
60
+ * meaning anything yet, it is just what every game gets before its
61
+ * owner says otherwise -- but `startAt` (defaulting to the current `t`)
62
+ * must still be `>= current_t`, so `t` never runs backwards across the
63
+ * change either.
64
+ * - Declaring a DIFFERENT axis over a clock already on a non-`sequence`
65
+ * axis is refused outright. An axis is fixed for the life of a game's
66
+ * timeline once it has been chosen for real: swapping it mid-timeline is
67
+ * precisely what re-segmenting a caller's own units does to the meaning
68
+ * of every `t` already recorded (§14) -- the one runtime action that
69
+ * reproduces that failure, and this is where it is refused rather than
70
+ * silently reinterpreting history. Declaring `sequence` back over a
71
+ * declared axis is refused by this exact same rule: `sequence` is a
72
+ * `kind` like any other here, not a neutral "no axis" state to fall back
73
+ * to.
74
+ *
75
+ * Known limit, pinned by a test in clock.test.ts (not desired behaviour): a
76
+ * game created through `createGame` already has its own creation write
77
+ * recorded on the default `sequence` axis before any caller can act --
78
+ * `createGame` has no parameter for a caller-supplied id, so there is no id
79
+ * to declare an axis against before that write lands. The practical effect
80
+ * is that such a game's declared axis has a floor above zero (`startAt`
81
+ * below the game's current `t` at declaration time is refused, by the
82
+ * backwards-`t` rule above, with that floor named in the message). The fix
83
+ * is to move the origin UP to the floor, never to shift the caller's own
84
+ * numbers down to fit -- silently offsetting an axis to fit is exactly the
85
+ * failure §14 exists to prevent, so this limit is enforced the same loud way
86
+ * every other one here is. This lifts only if `createGame` (or an
87
+ * equivalent) gains a caller-supplied id, which is a separate issue.
88
+ */
89
+ export function declareTimeAxis(params) {
90
+ const { gameId, axis } = params;
91
+ if (params.startAt !== undefined)
92
+ assertT(params.startAt);
93
+ const db = getDatabase();
94
+ const existing = readClock(gameId);
95
+ if (!existing) {
96
+ const t = params.startAt ?? 0;
97
+ const row = axisToRow(axis);
98
+ db.prepare(`INSERT INTO timeline_clock (game_id, current_t, axis_kind, axis_unit, declared_at) VALUES (?, ?, ?, ?, ?)`).run(gameId, t, row.kind, row.unit, new Date().toISOString());
99
+ return { gameId, t, axis };
100
+ }
101
+ const existingAxis = rowToAxis(existing);
102
+ if (sameAxis(existingAxis, axis)) {
103
+ if (params.startAt === undefined) {
104
+ // No-op: the identical axis, no requested move.
105
+ return { gameId, t: existing.current_t, axis: existingAxis };
106
+ }
107
+ if (compareT(params.startAt, existing.current_t) < 0) {
108
+ throw new Error(`timeline: cannot declare startAt ${params.startAt} for game '${gameId}' -- ` +
109
+ `t never runs backwards, and its current t is ${existing.current_t}. ` +
110
+ `Pass startAt >= ${existing.current_t}, or omit startAt to leave t where it is.`);
111
+ }
112
+ db.prepare(`UPDATE timeline_clock SET current_t = ? WHERE game_id = ?`).run(params.startAt, gameId);
113
+ return { gameId, t: params.startAt, axis: existingAxis };
114
+ }
115
+ if (existingAxis.kind !== "sequence") {
116
+ throw new Error(`timeline: game '${gameId}' already declared axis ${describeAxis(existingAxis)}; ` +
117
+ `an axis is fixed for the life of a game's timeline once declared, and cannot become ` +
118
+ `${describeAxis(axis)} now. Swapping axes mid-timeline would silently reattach every ` +
119
+ `already-recorded t to a different meaning (design §14) -- start a new game if this one ` +
120
+ `truly needs a different axis, or re-declare ${describeAxis(existingAxis)} if that is what was meant.`);
121
+ }
122
+ const startAt = params.startAt ?? existing.current_t;
123
+ if (compareT(startAt, existing.current_t) < 0) {
124
+ throw new Error(`timeline: cannot declare axis ${describeAxis(axis)} starting at ${startAt} for game '${gameId}' -- ` +
125
+ `t never runs backwards, even across an axis change, and its current t is ${existing.current_t}. ` +
126
+ `Pass startAt >= ${existing.current_t}.`);
127
+ }
128
+ const newRow = axisToRow(axis);
129
+ db.prepare(`UPDATE timeline_clock SET current_t = ?, axis_kind = ?, axis_unit = ?, declared_at = ? WHERE game_id = ?`).run(startAt, newRow.kind, newRow.unit, new Date().toISOString(), gameId);
130
+ return { gameId, t: startAt, axis };
131
+ }
132
+ /**
133
+ * Moves a game's `t` forward. This is the caller's own hand on the clock --
134
+ * it exists only for a declared, non-`sequence` axis, because those are the
135
+ * only axes whose writes do not already advance `t` for themselves
136
+ * (projection.ts advances `current_t` on every write, but only `WHERE
137
+ * axis_kind = 'sequence'`; a declared `elapsed` or `counter` axis sits still
138
+ * between calls here by construction).
139
+ *
140
+ * Three refusals, none of them advisory:
141
+ * - no clock row for `gameId`: nothing has declared or written anything
142
+ * for this game yet, so there is no `t` to move.
143
+ * - the axis is still `sequence`: the append ordinal belongs to the
144
+ * engine, not to a caller positioning `t` by hand. This is the sharpest
145
+ * form of "make the wrong axis awkward to supply" the API has -- a
146
+ * caller who reaches for this on a default game is told to say what its
147
+ * axis actually is first, not handed a silent success that means
148
+ * nothing.
149
+ * - `t` would move backwards under `compareT`.
150
+ */
151
+ export function setStoryTime(params) {
152
+ assertT(params.t);
153
+ const { gameId, t } = params;
154
+ const existing = readClock(gameId);
155
+ if (!existing) {
156
+ throw new Error(`timeline: game '${gameId}' has no timeline clock yet -- nothing has been declared or written ` +
157
+ `for it. Call declare_time_axis first (or write something through the normal tools, which ` +
158
+ `bootstraps the default sequence axis), then set_story_time.`);
159
+ }
160
+ const axis = rowToAxis(existing);
161
+ if (axis.kind === "sequence") {
162
+ throw new Error(`timeline: game '${gameId}' is still on the default sequence axis -- the engine's own append ` +
163
+ `ordinal, which advances one tick per write and does not take a caller-supplied position. ` +
164
+ `Call declare_time_axis first with the axis this game's t actually is (elapsed or counter); ` +
165
+ `set_story_time will work once a non-sequence axis is declared.`);
166
+ }
167
+ if (compareT(t, existing.current_t) < 0) {
168
+ throw new Error(`timeline: cannot set story time to ${t} for game '${gameId}' -- t never runs backwards, and its ` +
169
+ `current t is ${existing.current_t}. Pass a t >= ${existing.current_t}.`);
170
+ }
171
+ getDatabase().prepare(`UPDATE timeline_clock SET current_t = ? WHERE game_id = ?`).run(t, gameId);
172
+ return { gameId, t, axis };
173
+ }