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,264 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ import { ENTITY_KINDS } from "./kinds.js";
3
+ import { installProjectionTriggers, reconcileTimeline } from "./projection.js";
4
+ /**
5
+ * The timeline substrate (design §5.1): `entities`, `facts`, `events`, and
6
+ * the per-game `timeline_clock` that tells a `sequence` axis what its next
7
+ * ordinal is. Strictly additive -- nothing here is read by any existing
8
+ * tool yet, and every statement is idempotent so calling this at every
9
+ * startup (the project's only migration mechanism; see root CLAUDE.md) is
10
+ * safe against both a fresh database and one that predates the timeline.
11
+ *
12
+ * What makes recorded `t` actually immutable is the trigger block at the
13
+ * bottom of this function, not application discipline -- see the comment
14
+ * there.
15
+ */
16
+ export function initializeTimelineSchema() {
17
+ const db = getDatabase();
18
+ // entity_kinds -- the allowed values of entities.kind, enforced by FK
19
+ // rather than a CHECK constraint. SQLite cannot ALTER a CHECK, so a
20
+ // CHECK here would force a full table rebuild the day issue #2 (or
21
+ // anything later) adds a projected kind; a reference table just gets one
22
+ // more row via INSERT OR IGNORE, on every init, forever. This is the
23
+ // same shape as the inherited gotcha it exists to avoid: `stored_audio`
24
+ // and friends carry an unconstrained entity_type that can typo silently
25
+ // and read back never. `kinds.ts` is the single owner of the vocabulary
26
+ // seeded here.
27
+ db.exec(`
28
+ CREATE TABLE IF NOT EXISTS entity_kinds (
29
+ kind TEXT PRIMARY KEY
30
+ )
31
+ `);
32
+ const seedKind = db.prepare("INSERT OR IGNORE INTO entity_kinds (kind) VALUES (?)");
33
+ for (const kind of ENTITY_KINDS) {
34
+ seedKind.run(kind);
35
+ }
36
+ // entities -- one row per timeline-tracked thing. `name` is the name at
37
+ // creation and never changes; the current name (if the projection layer
38
+ // ever changes it) is a `name` fact instead, so nothing here can drift
39
+ // out from under a caller relying on it.
40
+ db.exec(`
41
+ CREATE TABLE IF NOT EXISTS entities (
42
+ id TEXT PRIMARY KEY,
43
+ game_id TEXT NOT NULL,
44
+ kind TEXT NOT NULL REFERENCES entity_kinds(kind),
45
+ name TEXT,
46
+ created_at_t REAL NOT NULL,
47
+ destroyed_at_t REAL,
48
+ CHECK (destroyed_at_t IS NULL OR destroyed_at_t >= created_at_t)
49
+ )
50
+ `);
51
+ // facts -- interval-versioned key/value history for an entity. `value` is
52
+ // NOT NULL on purpose: a NULL column on the live row produces no fact at
53
+ // all (absence is the absence of a fact, never a fact of absence -- hard
54
+ // rule 3). No ON DELETE CASCADE and no FK to `games`: deleting a game
55
+ // deletes its live rows, but the timeline of that game survives.
56
+ // DECISION(#21): a property to be declared irreversible needs its own fact key.
57
+ //
58
+ // `irreversible` is a per-fact flag, not a per-entity or per-value one --
59
+ // so any property a future consumer wants to declare irreversible has to
60
+ // live under its own fact key; you cannot flag half a blob.
61
+ db.exec(`
62
+ CREATE TABLE IF NOT EXISTS facts (
63
+ id TEXT PRIMARY KEY,
64
+ entity_id TEXT NOT NULL REFERENCES entities(id),
65
+ key TEXT NOT NULL,
66
+ value TEXT NOT NULL,
67
+ valid_from_t REAL NOT NULL,
68
+ valid_to_t REAL,
69
+ irreversible INTEGER NOT NULL DEFAULT 0,
70
+ CHECK (valid_to_t IS NULL OR valid_to_t >= valid_from_t)
71
+ )
72
+ `);
73
+ // events -- the append-only log entry itself. `causes` carries one hop of
74
+ // provenance (design §5.2c) as a JSON string; nothing here is inferred
75
+ // from anything anyone wrote (hard rule 4) -- `kind`/`description` are
76
+ // tokens this codebase defines.
77
+ db.exec(`
78
+ CREATE TABLE IF NOT EXISTS events (
79
+ id TEXT PRIMARY KEY,
80
+ game_id TEXT NOT NULL,
81
+ at_t REAL NOT NULL,
82
+ kind TEXT NOT NULL,
83
+ description TEXT,
84
+ causes TEXT
85
+ )
86
+ `);
87
+ // timeline_clock -- one row per game, tracking the declared axis and its
88
+ // current position. A game that never declares an axis still needs one
89
+ // of these once its first entity/fact/event is written, with axis_kind
90
+ // 'sequence' (#6 owns declareTimeAxis/setStoryTime; this table just
91
+ // holds the row).
92
+ db.exec(`
93
+ CREATE TABLE IF NOT EXISTS timeline_clock (
94
+ game_id TEXT PRIMARY KEY,
95
+ current_t REAL NOT NULL,
96
+ axis_kind TEXT NOT NULL CHECK (axis_kind IN ('sequence', 'elapsed', 'counter')),
97
+ axis_unit TEXT NOT NULL,
98
+ declared_at TEXT NOT NULL
99
+ )
100
+ `);
101
+ db.exec(`
102
+ CREATE INDEX IF NOT EXISTS idx_entities_game_kind ON entities(game_id, kind);
103
+ CREATE INDEX IF NOT EXISTS idx_entities_game_created ON entities(game_id, created_at_t);
104
+ CREATE INDEX IF NOT EXISTS idx_facts_entity_key ON facts(entity_id, key);
105
+ CREATE INDEX IF NOT EXISTS idx_facts_entity_key_valid_to ON facts(entity_id, key, valid_to_t);
106
+ CREATE INDEX IF NOT EXISTS idx_facts_valid_from ON facts(valid_from_t);
107
+ CREATE INDEX IF NOT EXISTS idx_events_game_at ON events(game_id, at_t);
108
+ `);
109
+ // Append-only guard triggers.
110
+ //
111
+ // Recorded `t` must be impossible to rewrite, not merely discouraged --
112
+ // that is the whole point of a substrate the checkpoint (#4) can trust.
113
+ // Every column comparison below uses IS NOT rather than <>, because <>
114
+ // with a NULL operand evaluates to NULL (neither true nor false) and the
115
+ // WHEN clause would silently fail to fire on exactly the rows -- open
116
+ // intervals -- where firing matters most.
117
+ //
118
+ // Each table gets exactly one permitted mutation: closing an open
119
+ // interval once (`facts.valid_to_t`, `entities.destroyed_at_t`, both
120
+ // NULL -> value). Every other UPDATE, and every DELETE, aborts.
121
+ //
122
+ // Every trigger below is DROP-then-CREATE, not CREATE TRIGGER IF NOT
123
+ // EXISTS. The column set being fixed is not the risk that decides this --
124
+ // it's that IF NOT EXISTS makes whatever guard shipped in the build that
125
+ // first created a given database permanent for that database. If this
126
+ // logic is ever tightened or a bug in it is fixed, that fix would
127
+ // silently never reach any database that already had the old trigger,
128
+ // which is exactly the "test against an existing database, not just a
129
+ // fresh one" trap this project has already been bitten by once. Dropping
130
+ // and recreating on every init means the guard a database has always
131
+ // matches the guard this build believes it deployed.
132
+ db.exec(`
133
+ DROP TRIGGER IF EXISTS timeline_entities_immutable;
134
+ CREATE TRIGGER timeline_entities_immutable
135
+ BEFORE UPDATE ON entities
136
+ WHEN NEW.id IS NOT OLD.id
137
+ OR NEW.game_id IS NOT OLD.game_id
138
+ OR NEW.kind IS NOT OLD.kind
139
+ OR NEW.name IS NOT OLD.name
140
+ OR NEW.created_at_t IS NOT OLD.created_at_t
141
+ OR (OLD.destroyed_at_t IS NOT NULL AND NEW.destroyed_at_t IS NOT OLD.destroyed_at_t)
142
+ BEGIN
143
+ SELECT RAISE(ABORT, 'timeline: entities are append-only; only destroyed_at_t may be set, once');
144
+ END;
145
+ `);
146
+ // `irreversible` is itself a one-way latch: 0 -> 1 exactly once, never
147
+ // 1 -> 0, never to any other value. The clause below aborts whenever the
148
+ // column changed at all (`NEW.irreversible IS NOT OLD.irreversible`)
149
+ // UNLESS that change is precisely OLD=0, NEW=1 -- so 0 -> 5 still aborts
150
+ // (NEW is not 1), 1 -> 0 still aborts (OLD is not 0), and 1 -> 1 never
151
+ // reaches this branch in the first place because the column didn't
152
+ // change. declareIrreversible() (irreversible.ts) relies on that last
153
+ // case to make re-declaring idempotent with a single UPDATE and no
154
+ // separate "already set" check.
155
+ db.exec(`
156
+ DROP TRIGGER IF EXISTS timeline_facts_immutable;
157
+ CREATE TRIGGER timeline_facts_immutable
158
+ BEFORE UPDATE ON facts
159
+ WHEN NEW.id IS NOT OLD.id
160
+ OR NEW.entity_id IS NOT OLD.entity_id
161
+ OR NEW.key IS NOT OLD.key
162
+ OR NEW.value IS NOT OLD.value
163
+ OR NEW.valid_from_t IS NOT OLD.valid_from_t
164
+ OR (NEW.irreversible IS NOT OLD.irreversible
165
+ AND (OLD.irreversible IS NOT 0 OR NEW.irreversible IS NOT 1))
166
+ OR (OLD.valid_to_t IS NOT NULL AND NEW.valid_to_t IS NOT OLD.valid_to_t)
167
+ BEGIN
168
+ SELECT RAISE(ABORT, 'timeline: facts are append-only; valid_from_t cannot be rewritten, valid_to_t may only be closed once, and irreversible may only move 0 -> 1');
169
+ END;
170
+ `);
171
+ db.exec(`
172
+ DROP TRIGGER IF EXISTS timeline_events_immutable;
173
+ CREATE TRIGGER timeline_events_immutable
174
+ BEFORE UPDATE ON events
175
+ BEGIN
176
+ SELECT RAISE(ABORT, 'timeline: an event never changes once recorded');
177
+ END;
178
+ `);
179
+ db.exec(`
180
+ DROP TRIGGER IF EXISTS timeline_entities_no_delete;
181
+ CREATE TRIGGER timeline_entities_no_delete
182
+ BEFORE DELETE ON entities
183
+ BEGIN
184
+ SELECT RAISE(ABORT, 'timeline: entities are append-only; rows are never deleted');
185
+ END;
186
+ `);
187
+ db.exec(`
188
+ DROP TRIGGER IF EXISTS timeline_facts_no_delete;
189
+ CREATE TRIGGER timeline_facts_no_delete
190
+ BEFORE DELETE ON facts
191
+ BEGIN
192
+ SELECT RAISE(ABORT, 'timeline: facts are append-only; rows are never deleted');
193
+ END;
194
+ `);
195
+ db.exec(`
196
+ DROP TRIGGER IF EXISTS timeline_events_no_delete;
197
+ CREATE TRIGGER timeline_events_no_delete
198
+ BEFORE DELETE ON events
199
+ BEGIN
200
+ SELECT RAISE(ABORT, 'timeline: events are append-only; rows are never deleted');
201
+ END;
202
+ `);
203
+ // Issue #7 / design §5.3: `irreversible` is the temporal member of the
204
+ // constraint family (bounded, monotonic, conserved sets). Rule: once a
205
+ // fact F = (entity_id, key, value) has irreversible = 1 at valid_from_t
206
+ // tF, no INSERT for the same entity_id/key with a DIFFERENT value and
207
+ // valid_from_t >= tF is permitted -- "for all t' > t", stated at the row
208
+ // level as `>=` because valid_from_t IS the instant the new value would
209
+ // take effect. `f.value IS NOT NEW.value` (not `<>`) for the same reason
210
+ // every other comparison in this file uses IS NOT: a NULL operand would
211
+ // otherwise make the WHEN clause silently not fire, and `value` can never
212
+ // legitimately be NULL here regardless (see the `facts` table comment)
213
+ // but this keeps the idiom uniform rather than relying on that.
214
+ //
215
+ // Deliberately an INSERT guard, not an UPDATE guard: facts are append-only
216
+ // (see timeline_facts_immutable above), so every new value for a key
217
+ // arrives as a fresh INSERT, never an UPDATE to an existing row's value.
218
+ //
219
+ // Closing an interval is NOT a contradiction and is NOT blocked here --
220
+ // `UPDATE facts SET valid_to_t = ...` never reaches this trigger at all,
221
+ // it is a different table event. This matters concretely: the projection
222
+ // layer's `_ad` delete trigger (projection.ts) closes every open fact for
223
+ // a destroyed entity via that same UPDATE path, and if closing were
224
+ // treated as a contradiction, any entity carrying an irreversible fact
225
+ // would become impossible to delete -- a policy this engine has no
226
+ // business imposing (see hard rule 2). Reopening the key afterward with a
227
+ // DIFFERENT value is still refused, because the WHEN clause below matches
228
+ // every irreversible row for the key, open or closed, not only the
229
+ // currently-open one.
230
+ //
231
+ // Because SQLite's RAISE(ABORT) backs out every change made by the firing
232
+ // statement -- including everything every trigger it invoked did, not
233
+ // just the INSERT this trigger body itself guards -- a refused reopen
234
+ // attempted from inside the projection layer's own AFTER UPDATE trigger
235
+ // (buildUpdateTrigger in projection.ts, which closes the old fact and
236
+ // then attempts to insert the new one in the same firing statement) rolls
237
+ // the close back too. The fact stays open, the live column stays
238
+ // unwritten, and the game's clock (also advanced earlier in that same
239
+ // trigger body) does not advance either. irreversible.test.ts's
240
+ // "real tool path" tests exercise this rollback directly, through
241
+ // updateResourceValue -> `UPDATE resources SET value = ...`.
242
+ db.exec(`
243
+ DROP TRIGGER IF EXISTS timeline_facts_irreversible;
244
+ CREATE TRIGGER timeline_facts_irreversible
245
+ BEFORE INSERT ON facts
246
+ WHEN EXISTS (
247
+ SELECT 1 FROM facts f
248
+ WHERE f.entity_id = NEW.entity_id
249
+ AND f.key = NEW.key
250
+ AND f.irreversible = 1
251
+ AND f.value IS NOT NEW.value
252
+ AND NEW.valid_from_t >= f.valid_from_t
253
+ )
254
+ BEGIN
255
+ SELECT RAISE(ABORT, 'timeline: an irreversible fact holds for key ''' || NEW.key || ''' on this entity as of its valid_from_t; a contradicting value is refused from that point onward');
256
+ END;
257
+ `);
258
+ // Issue #2: the projection layer. Triggers first, so every write from
259
+ // here on appends by construction; reconciliation second, so it backfills
260
+ // against triggers that are already live rather than a stale set. See
261
+ // projection.ts for what each does and why reconciliation must run last.
262
+ installProjectionTriggers();
263
+ reconcileTimeline();
264
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `t` -- story time, client-defined, chosen by one rule (design §14, hard
3
+ * rule 6):
4
+ *
5
+ * t is the axis that stays invariant when a caller re-segments its own
6
+ * units.
7
+ *
8
+ * A timestamp qualifies; so does a turn counter. An index into re-cuttable
9
+ * units does NOT -- re-cutting the units shifts what the index points at
10
+ * while the story underneath is unchanged. Choosing wrong raises no error:
11
+ * it silently attaches one unit's content to another's.
12
+ *
13
+ * `t` is an opaque, client-declared ordinal. It is never a datetime -- a
14
+ * `Date` carries a specific re-segmentation temptation (timezone, calendar,
15
+ * "which second did this actually happen") that a plain number does not
16
+ * invite. Callers that want elapsed real time declare an `elapsed` axis
17
+ * (see clock.ts, #6) and hand it a float; they do not hand the engine a
18
+ * `Date`.
19
+ */
20
+ export type T = number;
21
+ /**
22
+ * The one comparator every place in the timeline that orders `t` goes
23
+ * through. Numeric ascending -- there is no other ordering `t` could have,
24
+ * since it is always a plain number, but centralizing this means a future
25
+ * axis kind can never introduce a second, inconsistent notion of "later."
26
+ */
27
+ export declare function compareT(a: T, b: T): number;
28
+ /**
29
+ * Throws unless `value` is a finite number. Rejects everything else --
30
+ * `Date`, string (including a numeric string like `"12"`), `NaN`,
31
+ * `Infinity`, `null`, `undefined` -- naming what was actually supplied so
32
+ * the caller doesn't have to guess which of those it sent.
33
+ */
34
+ export declare function assertT(value: unknown): asserts value is T;
35
+ /**
36
+ * The declared shape of a game's `t` -- which axis it moves along, chosen
37
+ * once per game and enforced by clock.ts (issue #6, `declareTimeAxis`).
38
+ * Every variant here is here because it satisfies §14's property (hard rule
39
+ * 6): it stays invariant when a caller re-segments its own units. There is a
40
+ * fourth, more obvious-looking kind that is deliberately absent, and the
41
+ * absence is the point -- see below.
42
+ *
43
+ * - `sequence` -- the engine's own append ordinal, one tick per write. The
44
+ * default for a game that declares nothing. Invariant because nothing
45
+ * re-cuts it: the engine is the only writer of the next value, so there is
46
+ * no "the caller's own units" for it to be an index into in the first
47
+ * place.
48
+ * - `elapsed` -- time since a fixed origin, in a caller-named `unit` (e.g.
49
+ * "seconds", in-game "minutes" -- whatever the caller's own story runs on).
50
+ * Invariant because re-segmenting is exactly the act of drawing new unit
51
+ * boundaries over a timeline that does not itself move; elapsed time from
52
+ * a fixed origin has nothing to re-cut.
53
+ * - `counter` -- a count of things that happened, in a caller-named `unit`
54
+ * (turns, ticks). Not a count of things authored. Invariant for the same
55
+ * reason `sequence` is: re-cutting authored material afterward does not
56
+ * change how many real events occurred before a given point.
57
+ *
58
+ * There is deliberately **no** variant for an index into units the caller
59
+ * may re-cut. Editing re-cuts those: the index shifts while the story
60
+ * underneath does not, and choosing one raises no error -- it silently
61
+ * attaches one unit's content to another's (§14). Because there is no
62
+ * variant for it, a caller who wants to hand the engine that kind of index
63
+ * anyway has to pick `elapsed` or `counter` and lie about the unit to do it
64
+ * -- there is no honest way to spell "index into units I might re-cut" in
65
+ * this type. That awkwardness is deliberate: it is the API making the wrong
66
+ * choice hard to make by accident, not merely documenting that it is wrong.
67
+ * `clock.ts`'s `declareTimeAxis` backs this up at runtime by refusing to let
68
+ * a game's axis change once declared -- swapping axes mid-timeline is the
69
+ * one runtime action that reproduces this exact failure, so that is where
70
+ * it gets refused.
71
+ */
72
+ export type TimeAxis = {
73
+ kind: "sequence";
74
+ } | {
75
+ kind: "elapsed";
76
+ unit: string;
77
+ } | {
78
+ kind: "counter";
79
+ unit: string;
80
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The one comparator every place in the timeline that orders `t` goes
3
+ * through. Numeric ascending -- there is no other ordering `t` could have,
4
+ * since it is always a plain number, but centralizing this means a future
5
+ * axis kind can never introduce a second, inconsistent notion of "later."
6
+ */
7
+ export function compareT(a, b) {
8
+ return a - b;
9
+ }
10
+ /**
11
+ * Throws unless `value` is a finite number. Rejects everything else --
12
+ * `Date`, string (including a numeric string like `"12"`), `NaN`,
13
+ * `Infinity`, `null`, `undefined` -- naming what was actually supplied so
14
+ * the caller doesn't have to guess which of those it sent.
15
+ */
16
+ export function assertT(value) {
17
+ if (typeof value === "number" && Number.isFinite(value)) {
18
+ return;
19
+ }
20
+ throw new Error(`timeline: t must be a finite number, got ${describeRejectedT(value)}`);
21
+ }
22
+ function describeRejectedT(value) {
23
+ if (value === null)
24
+ return "null";
25
+ if (value === undefined)
26
+ return "undefined";
27
+ if (value instanceof Date)
28
+ return `a Date (${value.toISOString()})`;
29
+ if (typeof value === "number") {
30
+ // Only NaN and +-Infinity reach here -- every other number returned
31
+ // above.
32
+ return String(value);
33
+ }
34
+ if (typeof value === "string")
35
+ return `a string (${JSON.stringify(value)})`;
36
+ return `a ${typeof value}`;
37
+ }
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid";
2
2
  import { getDatabase, getDataDir, withTransaction } from "../db/connection.js";
3
3
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, rmSync, } from "fs";
4
4
  import { dirname, join, extname } from "path";
5
+ import { mediaDirPath, mediaFilePath, mediaPathWithin } from "../utils/media-path.js";
5
6
  import { getCharacter } from "./character.js";
6
7
  import { getLocation } from "./world.js";
7
8
  import { getFaction } from "./faction.js";
@@ -128,10 +129,11 @@ export async function storeAudio(params) {
128
129
  else {
129
130
  throw new Error("Either url or filePath must be provided");
130
131
  }
131
- // Build file path
132
+ // Build file path. Every segment below arrives from the caller, so the
133
+ // composition goes through the media-path choke point, which rejects
134
+ // anything that would land outside the audio directory.
132
135
  const ext = getExtension(mimeType);
133
- const relativePath = join(params.gameId, `${params.entityType}s`, params.entityId, `${id}.${ext}`);
134
- const fullPath = join(getAudioDir(), relativePath);
136
+ const { relativePath, fullPath } = mediaFilePath(getAudioDir(), [params.gameId, `${params.entityType}s`, params.entityId], `${id}.${ext}`);
135
137
  // Ensure directory exists and write file
136
138
  ensureDir(dirname(fullPath));
137
139
  writeFileSync(fullPath, audioBuffer);
@@ -192,7 +194,7 @@ export function getAudioFilePath(audioId) {
192
194
  const audio = getAudio(audioId);
193
195
  if (!audio)
194
196
  return null;
195
- const fullPath = join(getAudioDir(), audio.filePath);
197
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
196
198
  if (!existsSync(fullPath))
197
199
  return null;
198
200
  return fullPath;
@@ -201,7 +203,7 @@ export function getAudioData(audioId) {
201
203
  const audio = getAudio(audioId);
202
204
  if (!audio)
203
205
  return null;
204
- const fullPath = join(getAudioDir(), audio.filePath);
206
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
205
207
  if (!existsSync(fullPath))
206
208
  return null;
207
209
  const buffer = readFileSync(fullPath);
@@ -264,7 +266,7 @@ export function getCharacterVoiceReferences(gameId, characterId) {
264
266
  const audioDir = getAudioDir();
265
267
  const filePaths = voiceRefs
266
268
  .map((ref) => {
267
- const fullPath = join(audioDir, ref.filePath);
269
+ const fullPath = mediaPathWithin(audioDir, ref.filePath);
268
270
  return existsSync(fullPath) ? fullPath : null;
269
271
  })
270
272
  .filter((p) => p !== null);
@@ -283,7 +285,7 @@ export function deleteAudio(audioId) {
283
285
  if (!audio)
284
286
  return false;
285
287
  // Delete file
286
- const fullPath = join(getAudioDir(), audio.filePath);
288
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
287
289
  if (existsSync(fullPath)) {
288
290
  unlinkSync(fullPath);
289
291
  }
@@ -339,8 +341,10 @@ export function updateAudioMetadata(audioId, updates) {
339
341
  // Cleanup helper - delete all audio for a game
340
342
  export function deleteGameAudio(gameId) {
341
343
  const db = getDatabase();
342
- // Delete files
343
- const gameDir = join(getAudioDir(), gameId);
344
+ // Delete files. mediaDirPath rejects before the rmSync below, which is
345
+ // recursive and forced: an unchecked `..` here would take the whole data
346
+ // directory with it.
347
+ const gameDir = mediaDirPath(getAudioDir(), [gameId]);
344
348
  if (existsSync(gameDir)) {
345
349
  rmSync(gameDir, { recursive: true, force: true });
346
350
  }
@@ -1,47 +1,12 @@
1
- import type { ConstraintKind, MonotonicDirection, ResourceConstraint } from "../types/index.js";
2
- /**
3
- * Declarative, opt-in, server-enforced invariants on `resources` rows.
4
- *
5
- * A resource with no declared constraint behaves exactly as it always has --
6
- * out-of-bounds writes are silently clamped by clampValue() in resource.ts.
7
- * Declaring a constraint here changes that contract for that one resource
8
- * (or set of resources, for 'conserved') only.
9
- *
10
- * SCOPE NOTE: this layer covers the `resources` table only. Three other
11
- * numeric write paths bypass it entirely and are NOT covered:
12
- * - factions.resources (JSON blob; see modifyFactionResource/setFactionResource
13
- * in src/tools/faction.ts -- no bounds, no history, silently deletes an
14
- * entry when it hits <= 0)
15
- * - characters.attributes (arbitrary numeric JSON, unvalidated)
16
- * - relationships.value (its own separate system)
17
- * A game author who needs a server-enforced invariant on a quantity must
18
- * model that quantity as a `resources` row, not one of the above.
19
- *
20
- * 'conserved' is fully enforced: a resource that is a member of a declared
21
- * 'conserved' constraint can no longer be written directly through
22
- * update_resource_value (see checkResourceConstraints() below) -- it must go
23
- * through transferResourceValue() in resource.ts, which moves value between
24
- * exactly two members of the same set atomically. See the comment on
25
- * transferResourceValue() for why an explicit transfer, rather than a
26
- * balanced multi-resource write, was chosen.
27
- */
28
- /** Absolute tolerance for floating-point sum comparisons on 'conserved'
29
- * constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
30
- * so repeated addition/subtraction across many transfers can drift by a
31
- * few ULPs. This is large enough to absorb that drift over realistic
32
- * transfer volumes while still catching an actual logic bug (which would
33
- * typically desync the sum by a whole `amount`, not a fraction of one). */
34
- export declare const CONSERVED_SUM_EPSILON = 0.000001;
35
- export declare class ConstraintViolationError extends Error {
36
- readonly constraintKind: ConstraintKind;
37
- readonly resourceId: string;
38
- constructor(constraintKind: ConstraintKind, resourceId: string, message: string);
39
- }
1
+ import type { MonotonicDirection, ResourceConstraint } from "../types/index.js";
2
+ import { ConstraintViolationError, CONSERVED_SUM_EPSILON } from "../timeline/registry.js";
3
+ export { ConstraintViolationError, CONSERVED_SUM_EPSILON };
40
4
  /**
41
5
  * Declare a 'bounded' constraint: the resource's value must stay within its
42
6
  * existing minValue/maxValue (set via create_resource/update_resource).
43
7
  * Once declared, out-of-bounds writes through updateResourceValue() are
44
- * REJECTED instead of silently clamped -- see checkResourceConstraints().
8
+ * REJECTED instead of silently clamped -- see assertConstraintsAllow()
9
+ * (src/timeline/constrained.ts).
45
10
  */
46
11
  export declare function declareBoundedConstraint(params: {
47
12
  gameId: string;
@@ -58,6 +23,42 @@ export declare function declareMonotonicConstraint(params: {
58
23
  resourceId: string;
59
24
  direction: MonotonicDirection;
60
25
  }): ResourceConstraint;
26
+ /**
27
+ * Declare a 'resolve_only' constraint (design §5.3, §5.2a; issue #13): every
28
+ * direct write to `factKey` on `resourceId` is refused --
29
+ * assertConstraintsAllow() (src/timeline/constrained.ts) rejects it whether
30
+ * it arrives through writeConstrainedValue OR transferConstrainedValue --
31
+ * unless made while an adjudication window is open
32
+ * (src/timeline/adjudication.ts's `withAdjudicationOpen`). The window is the
33
+ * mechanism this issue builds; the resolver that OPENS it (issue #10) is a
34
+ * separate, not-yet-built caller, and this function does not know or care
35
+ * what that caller will look like.
36
+ *
37
+ * `factKey` is optional and defaults to `'value'`, matching every other
38
+ * declare*Constraint() function's implicit scope -- but, unlike those,
39
+ * 'resolve_only' takes it as a REAL parameter rather than hardcoding it,
40
+ * because it is designed to guard a fact key a caller chooses, not only the
41
+ * one numeric column `resources` happens to expose today (design §5.4
42
+ * option (C)'s eventual generalization; see `factKey`'s doc comment on
43
+ * `ResourceConstraint`, src/types/index.ts).
44
+ *
45
+ * Validation mirrors declareBoundedConstraint()/declareMonotonicConstraint()
46
+ * exactly: the game and resource must exist, and re-declaring the same
47
+ * (resourceId, factKey) pair is rejected rather than silently accepted --
48
+ * scoped by factKey, not just resourceId, because that is the one thing
49
+ * that makes 'resolve_only' different from its two row-based siblings: two
50
+ * DIFFERENT fact keys on the same resource are two independent declarations,
51
+ * never a duplicate of each other.
52
+ *
53
+ * No shape prerequisite the way 'bounded' requires a min/max or 'monotonic'
54
+ * requires a direction -- 'resolve_only' constrains WHO may write, not what
55
+ * shape the value must take, so there is nothing else to validate.
56
+ */
57
+ export declare function declareResolveOnlyConstraint(params: {
58
+ gameId: string;
59
+ resourceId: string;
60
+ factKey?: string;
61
+ }): ResourceConstraint;
61
62
  /**
62
63
  * Register a 'conserved' constraint: a set of resources that must always
63
64
  * sum to a fixed total.
@@ -87,46 +88,9 @@ export declare function declareConservedConstraint(params: {
87
88
  }): ResourceConstraint;
88
89
  /** List constraints for a game, optionally filtered to ones governing a given resource. */
89
90
  export declare function listConstraints(gameId: string, resourceId?: string): ResourceConstraint[];
90
- /** All constraints (of any kind) that govern the given resource. */
91
+ /** All constraints (of any kind, any fact key) that govern the given
92
+ * resource. Delegates to the registry's allConstraintsForEntity() rather
93
+ * than writing its own JOIN -- see src/timeline/registry.ts. */
91
94
  export declare function getConstraintsForResource(resourceId: string): ResourceConstraint[];
92
95
  /** Remove a constraint by id. Returns true if a row was deleted. */
93
96
  export declare function removeConstraint(id: string): boolean;
94
- /**
95
- * Check whether an intended value change for a single resource would
96
- * violate any 'bounded' or 'monotonic' constraint declared on it. Throws
97
- * ConstraintViolationError on violation; returns void otherwise. Shared by
98
- * checkResourceConstraints() (the single-resource write path) and
99
- * transferResourceValue() (resource.ts; the two-resource conserved-transfer
100
- * path) -- both paths must respect 'bounded'/'monotonic' the same way.
101
- * Deliberately does not look at 'conserved' constraints; callers decide
102
- * separately what to do about those (see checkResourceConstraints() below
103
- * and transferResourceValue()).
104
- */
105
- export declare function checkBoundedAndMonotonicConstraints(resourceId: string, previousValue: number, intendedValue: number, bounds: {
106
- minValue: number | null;
107
- maxValue: number | null;
108
- }): void;
109
- /**
110
- * Check whether an intended value change for a single resource would
111
- * violate any constraint declared on it. Throws ConstraintViolationError on
112
- * violation; returns void otherwise. Called from updateResourceValue() in
113
- * resource.ts before the row is written.
114
- *
115
- * 'conserved' is handled specially here: ANY direct single-resource write to
116
- * a conserved member is rejected, unconditionally, regardless of whether the
117
- * particular delta would happen to preserve the total. The server cannot
118
- * know where update_resource_value's counterpart delta should come from --
119
- * writing one member without atomically adjusting another would silently
120
- * break the set's invariant, which is exactly the failure this constraint
121
- * exists to prevent. Use transfer_resource_value (transferResourceValue() in
122
- * resource.ts) instead, which moves value between two members of the same
123
- * set atomically.
124
- */
125
- export declare function checkResourceConstraints(resourceId: string, previousValue: number, intendedValue: number, bounds: {
126
- minValue: number | null;
127
- maxValue: number | null;
128
- }): void;
129
- /** All conserved constraints (of kind 'conserved') governing a resource,
130
- * i.e. zero or one (declareConservedConstraint() rejects overlapping
131
- * conserved membership, so a resource can belong to at most one). */
132
- export declare function getConservedConstraintFor(resourceId: string): ResourceConstraint | null;