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.
Files changed (135) hide show
  1. package/README.md +76 -10
  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 +439 -7
  8. package/dist/http/server.js +3 -3
  9. package/dist/index.d.ts +36 -2
  10. package/dist/index.js +184 -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 +11 -4
  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 +100 -0
  59. package/dist/timeline/changes.js +161 -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 +171 -0
  67. package/dist/timeline/export.js +329 -0
  68. package/dist/timeline/irreversible.d.ts +85 -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 +64 -0
  83. package/dist/timeline/replay.js +104 -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 +262 -0
  88. package/dist/timeline/t.d.ts +80 -0
  89. package/dist/timeline/t.js +37 -0
  90. package/dist/tools/constraint.d.ts +44 -80
  91. package/dist/tools/constraint.js +115 -124
  92. package/dist/tools/relationship.d.ts +83 -2
  93. package/dist/tools/relationship.js +139 -62
  94. package/dist/tools/resource.d.ts +31 -6
  95. package/dist/tools/resource.js +106 -153
  96. package/dist/types/index.d.ts +19 -1
  97. package/dist/utils/output-schemas.d.ts +593 -2
  98. package/dist/utils/output-schemas.js +3 -0
  99. package/dist/utils/webui.d.ts +32 -0
  100. package/dist/utils/webui.js +54 -1
  101. package/package.json +20 -4
  102. package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
  103. package/dist/__tests__/engineVocabulary.test.js +0 -147
  104. package/dist/db/__tests__/connection.test.d.ts +0 -1
  105. package/dist/db/__tests__/connection.test.js +0 -72
  106. package/dist/db/__tests__/testDb.d.ts +0 -33
  107. package/dist/db/__tests__/testDb.js +0 -41
  108. package/dist/test-setup.d.ts +0 -1
  109. package/dist/test-setup.js +0 -13
  110. package/dist/tools/__tests__/audio.test.d.ts +0 -1
  111. package/dist/tools/__tests__/audio.test.js +0 -59
  112. package/dist/tools/__tests__/conserved.test.d.ts +0 -1
  113. package/dist/tools/__tests__/conserved.test.js +0 -488
  114. package/dist/tools/__tests__/constraint.test.d.ts +0 -1
  115. package/dist/tools/__tests__/constraint.test.js +0 -212
  116. package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
  117. package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
  118. package/dist/tools/__tests__/images.test.d.ts +0 -1
  119. package/dist/tools/__tests__/images.test.js +0 -59
  120. package/dist/tools/__tests__/relationship.test.d.ts +0 -1
  121. package/dist/tools/__tests__/relationship.test.js +0 -132
  122. package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
  123. package/dist/tools/__tests__/resource-constraints.test.js +0 -131
  124. package/dist/tools/__tests__/resource.test.d.ts +0 -1
  125. package/dist/tools/__tests__/resource.test.js +0 -190
  126. package/dist/tools/__tests__/time.test.d.ts +0 -1
  127. package/dist/tools/__tests__/time.test.js +0 -404
  128. package/dist/tools/__tests__/timers.test.d.ts +0 -1
  129. package/dist/tools/__tests__/timers.test.js +0 -426
  130. package/dist/tools/__tests__/world.test.d.ts +0 -1
  131. package/dist/tools/__tests__/world.test.js +0 -70
  132. package/dist/utils/__tests__/json.test.d.ts +0 -1
  133. package/dist/utils/__tests__/json.test.js +0 -55
  134. package/dist/utils/__tests__/validation.test.d.ts +0 -1
  135. package/dist/utils/__tests__/validation.test.js +0 -90
@@ -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
+ }