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.
- package/README.md +101 -11
- package/dist/bin/run-dmcp.d.ts +2 -0
- package/dist/bin/run-dmcp.js +55 -0
- package/dist/db/connection.d.ts +32 -0
- package/dist/db/connection.js +38 -16
- package/dist/db/schema.d.ts +29 -1
- package/dist/db/schema.js +594 -10
- package/dist/http/server.js +25 -4
- package/dist/index.d.ts +69 -2
- package/dist/index.js +262 -92
- package/dist/mcp-server.d.ts +49 -0
- package/dist/mcp-server.js +127 -0
- package/dist/reader/turnReader.d.ts +185 -0
- package/dist/reader/turnReader.js +288 -0
- package/dist/register/batch.js +5 -79
- package/dist/register/mcp-resources.d.ts +9 -0
- package/dist/register/mcp-resources.js +16 -62
- package/dist/register/render.d.ts +17 -0
- package/dist/register/render.js +50 -0
- package/dist/register/resolve.d.ts +14 -0
- package/dist/register/resolve.js +102 -0
- package/dist/register/resources.js +13 -6
- package/dist/register/timeline.d.ts +2 -0
- package/dist/register/timeline.js +311 -0
- package/dist/rpg/index.d.ts +29 -0
- package/dist/rpg/index.js +55 -0
- package/dist/rpg/register/abilities.d.ts +2 -0
- package/dist/rpg/register/abilities.js +165 -0
- package/dist/rpg/register/batch.d.ts +2 -0
- package/dist/rpg/register/batch.js +92 -0
- package/dist/rpg/register/combat.d.ts +2 -0
- package/dist/rpg/register/combat.js +207 -0
- package/dist/rpg/register/mcp-prompts.d.ts +2 -0
- package/dist/rpg/register/mcp-prompts.js +684 -0
- package/dist/rpg/register/mcp-resources.d.ts +2 -0
- package/dist/rpg/register/mcp-resources.js +61 -0
- package/dist/rpg/register/quests.d.ts +2 -0
- package/dist/rpg/register/quests.js +118 -0
- package/dist/rpg/register/status.d.ts +2 -0
- package/dist/rpg/register/status.js +130 -0
- package/dist/rpg/register/tables.d.ts +2 -0
- package/dist/rpg/register/tables.js +146 -0
- package/dist/rpg/tools/ability.d.ts +48 -0
- package/dist/rpg/tools/ability.js +238 -0
- package/dist/rpg/tools/combat.d.ts +13 -0
- package/dist/rpg/tools/combat.js +195 -0
- package/dist/rpg/tools/dice.d.ts +23 -0
- package/dist/rpg/tools/dice.js +111 -0
- package/dist/rpg/tools/quest.d.ts +34 -0
- package/dist/rpg/tools/quest.js +164 -0
- package/dist/rpg/tools/status.d.ts +36 -0
- package/dist/rpg/tools/status.js +218 -0
- package/dist/rpg/tools/tables.d.ts +33 -0
- package/dist/rpg/tools/tables.js +209 -0
- package/dist/schemas/index.d.ts +12 -12
- package/dist/timeline/adjudication.d.ts +150 -0
- package/dist/timeline/adjudication.js +174 -0
- package/dist/timeline/changes.d.ts +108 -0
- package/dist/timeline/changes.js +169 -0
- package/dist/timeline/checkpoint.d.ts +69 -0
- package/dist/timeline/checkpoint.js +131 -0
- package/dist/timeline/clock.d.ts +89 -0
- package/dist/timeline/clock.js +173 -0
- package/dist/timeline/constrained.d.ts +220 -0
- package/dist/timeline/constrained.js +671 -0
- package/dist/timeline/export.d.ts +181 -0
- package/dist/timeline/export.js +339 -0
- package/dist/timeline/irreversible.d.ts +87 -0
- package/dist/timeline/irreversible.js +108 -0
- package/dist/timeline/kinds.d.ts +14 -0
- package/dist/timeline/kinds.js +22 -0
- package/dist/timeline/narration.d.ts +175 -0
- package/dist/timeline/narration.js +259 -0
- package/dist/timeline/projection.d.ts +97 -0
- package/dist/timeline/projection.js +330 -0
- package/dist/timeline/provenance.d.ts +66 -0
- package/dist/timeline/provenance.js +45 -0
- package/dist/timeline/registry.d.ts +95 -0
- package/dist/timeline/registry.js +124 -0
- package/dist/timeline/render.d.ts +121 -0
- package/dist/timeline/render.js +187 -0
- package/dist/timeline/replay.d.ts +86 -0
- package/dist/timeline/replay.js +126 -0
- package/dist/timeline/resolve.d.ts +262 -0
- package/dist/timeline/resolve.js +226 -0
- package/dist/timeline/schema.d.ts +13 -0
- package/dist/timeline/schema.js +264 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/audio.js +13 -9
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/game.js +33 -1
- package/dist/tools/images.js +17 -10
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +33 -8
- package/dist/tools/resource.js +106 -153
- package/dist/tools/time.js +18 -3
- package/dist/types/index.d.ts +20 -2
- package/dist/utils/media-path.d.ts +52 -0
- package/dist/utils/media-path.js +106 -0
- package/dist/utils/output-schemas.d.ts +594 -3
- package/dist/utils/output-schemas.js +4 -1
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +25 -5
- package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
- package/dist/__tests__/engineVocabulary.test.js +0 -147
- package/dist/db/__tests__/connection.test.d.ts +0 -1
- package/dist/db/__tests__/connection.test.js +0 -72
- package/dist/db/__tests__/testDb.d.ts +0 -33
- package/dist/db/__tests__/testDb.js +0 -41
- package/dist/test-setup.d.ts +0 -1
- package/dist/test-setup.js +0 -13
- package/dist/tools/__tests__/audio.test.d.ts +0 -1
- package/dist/tools/__tests__/audio.test.js +0 -59
- package/dist/tools/__tests__/conserved.test.d.ts +0 -1
- package/dist/tools/__tests__/conserved.test.js +0 -488
- package/dist/tools/__tests__/constraint.test.d.ts +0 -1
- package/dist/tools/__tests__/constraint.test.js +0 -212
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
- package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
- package/dist/tools/__tests__/images.test.d.ts +0 -1
- package/dist/tools/__tests__/images.test.js +0 -59
- package/dist/tools/__tests__/relationship.test.d.ts +0 -1
- package/dist/tools/__tests__/relationship.test.js +0 -132
- package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
- package/dist/tools/__tests__/resource-constraints.test.js +0 -131
- package/dist/tools/__tests__/resource.test.d.ts +0 -1
- package/dist/tools/__tests__/resource.test.js +0 -190
- package/dist/tools/__tests__/time.test.d.ts +0 -1
- package/dist/tools/__tests__/time.test.js +0 -404
- package/dist/tools/__tests__/timers.test.d.ts +0 -1
- package/dist/tools/__tests__/timers.test.js +0 -426
- package/dist/tools/__tests__/world.test.d.ts +0 -1
- package/dist/tools/__tests__/world.test.js +0 -70
- package/dist/utils/__tests__/json.test.d.ts +0 -1
- package/dist/utils/__tests__/json.test.js +0 -55
- package/dist/utils/__tests__/validation.test.d.ts +0 -1
- package/dist/utils/__tests__/validation.test.js +0 -90
|
@@ -0,0 +1,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
|
+
}
|