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