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,124 @@
|
|
|
1
|
+
import { getDatabase } from "../db/connection.js";
|
|
2
|
+
/**
|
|
3
|
+
* The read side of the resource-constraint registry (design §5.3 / §5.4
|
|
4
|
+
* option (C)): declarative, opt-in, server-enforced invariants on numeric
|
|
5
|
+
* fact keys, keyed on `(entityId, factKey)` rather than on an entity id
|
|
6
|
+
* alone.
|
|
7
|
+
*
|
|
8
|
+
* This lives in src/timeline/ -- not src/tools/constraint.ts, where the code
|
|
9
|
+
* below used to live -- so that the generic write choke point Phase 3
|
|
10
|
+
* introduces next (also under src/timeline/) can consult it WITHOUT
|
|
11
|
+
* importing anything from src/tools/. That import direction is what would
|
|
12
|
+
* otherwise close a cycle: src/tools/constraint.ts already imports
|
|
13
|
+
* src/tools/resource.ts (for getResource()), and the future choke point
|
|
14
|
+
* will need to check what's declared here before it writes a fact -- if the
|
|
15
|
+
* read side still lived in tools/constraint.ts, that edge would run
|
|
16
|
+
* tools/resource.ts -> timeline/<choke point> -> tools/constraint.ts, and
|
|
17
|
+
* tools/constraint.ts already sits downstream of tools/resource.ts. Moving
|
|
18
|
+
* the read side here breaks that cycle before the choke point exists to hit
|
|
19
|
+
* it.
|
|
20
|
+
*
|
|
21
|
+
* Everything that WRITES `resource_constraints` -- insertConstraint() and
|
|
22
|
+
* the declare*() functions, and all the validation that goes with them --
|
|
23
|
+
* stays in src/tools/constraint.ts, which imports the accessors below
|
|
24
|
+
* rather than duplicating the query against resource_constraint_members.
|
|
25
|
+
*/
|
|
26
|
+
/** Absolute tolerance for floating-point sum comparisons on 'conserved'
|
|
27
|
+
* constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
|
|
28
|
+
* so repeated addition/subtraction across many transfers can drift by a
|
|
29
|
+
* few ULPs. This is large enough to absorb that drift over realistic
|
|
30
|
+
* transfer volumes while still catching an actual logic bug (which would
|
|
31
|
+
* typically desync the sum by a whole `amount`, not a fraction of one). */
|
|
32
|
+
export const CONSERVED_SUM_EPSILON = 1e-6;
|
|
33
|
+
export class ConstraintViolationError extends Error {
|
|
34
|
+
constraintKind;
|
|
35
|
+
resourceId;
|
|
36
|
+
contradictedFact;
|
|
37
|
+
constructor(constraintKind, resourceId, message,
|
|
38
|
+
/** Design decision #7 / §5.2c's one hop of causality, attached as typed
|
|
39
|
+
* data and not only baked into `message` -- a reviewer at a fired check
|
|
40
|
+
* has to decide "is the fact wrong or is the claim wrong," and parsing
|
|
41
|
+
* that back out of a sentence is exactly the shape §5.2c exists to
|
|
42
|
+
* prevent. Only ever set when `constraintKind === "irreversible"`;
|
|
43
|
+
* every other family in this union has no contradicted fact to attach,
|
|
44
|
+
* so `undefined` is the correct default rather than a fourth sentinel
|
|
45
|
+
* value. */
|
|
46
|
+
contradictedFact) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.constraintKind = constraintKind;
|
|
49
|
+
this.resourceId = resourceId;
|
|
50
|
+
this.contradictedFact = contradictedFact;
|
|
51
|
+
this.name = "ConstraintViolationError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The resource ids belonging to a constraint, in insertion order. Exported
|
|
55
|
+
* alongside ConstraintRow/rowToConstraint for the same reason. */
|
|
56
|
+
export function memberIdsFor(constraintId) {
|
|
57
|
+
const db = getDatabase();
|
|
58
|
+
const rows = db
|
|
59
|
+
.prepare(`SELECT resource_id FROM resource_constraint_members WHERE constraint_id = ? ORDER BY rowid`)
|
|
60
|
+
.all(constraintId);
|
|
61
|
+
return rows.map((r) => r.resource_id);
|
|
62
|
+
}
|
|
63
|
+
export function rowToConstraint(row) {
|
|
64
|
+
return {
|
|
65
|
+
id: row.id,
|
|
66
|
+
gameId: row.game_id,
|
|
67
|
+
kind: row.kind,
|
|
68
|
+
resourceIds: memberIdsFor(row.id),
|
|
69
|
+
direction: row.direction,
|
|
70
|
+
total: row.total,
|
|
71
|
+
factKey: row.fact_key,
|
|
72
|
+
createdAt: row.created_at,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Shared query underlying every constraint lookup by entity id, so the JOIN
|
|
77
|
+
* against resource_constraint_members is written exactly once. `factKey`
|
|
78
|
+
* omitted means "any key" -- today's getConstraintsForResource() behaviour
|
|
79
|
+
* (src/tools/constraint.ts), preserved for the one caller that still needs
|
|
80
|
+
* it. Every OTHER caller must pass a factKey; see constraintsFor() below.
|
|
81
|
+
*/
|
|
82
|
+
function queryConstraintsForEntity(entityId, factKey) {
|
|
83
|
+
const db = getDatabase();
|
|
84
|
+
const conditions = ["rcm.resource_id = ?"];
|
|
85
|
+
const args = [entityId];
|
|
86
|
+
if (factKey !== undefined) {
|
|
87
|
+
conditions.push("rc.fact_key = ?");
|
|
88
|
+
args.push(factKey);
|
|
89
|
+
}
|
|
90
|
+
const rows = db
|
|
91
|
+
.prepare(`SELECT rc.* FROM resource_constraints rc
|
|
92
|
+
JOIN resource_constraint_members rcm ON rcm.constraint_id = rc.id
|
|
93
|
+
WHERE ${conditions.join(" AND ")}
|
|
94
|
+
ORDER BY rc.created_at`)
|
|
95
|
+
.all(...args);
|
|
96
|
+
return rows.map(rowToConstraint);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Every constraint governing `(entityId, factKey)`, ordered by
|
|
100
|
+
* `created_at`. This is the whole point of Phase 3 step 1: a constraint
|
|
101
|
+
* declared on one fact key of an entity must never be visible when a
|
|
102
|
+
* different fact key of that SAME entity is asked about, even though today
|
|
103
|
+
* every constraint happens to govern the same key ('value').
|
|
104
|
+
*/
|
|
105
|
+
export function constraintsFor(entityId, factKey) {
|
|
106
|
+
return queryConstraintsForEntity(entityId, factKey);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Every constraint governing `entityId`, regardless of fact key. Exists
|
|
110
|
+
* only so getConstraintsForResource() (src/tools/constraint.ts) can keep
|
|
111
|
+
* its pre-Phase-3 "all keys" behaviour without a second copy of the JOIN
|
|
112
|
+
* above -- new callers should prefer constraintsFor(), which cannot
|
|
113
|
+
* accidentally forget to scope by key.
|
|
114
|
+
*/
|
|
115
|
+
export function allConstraintsForEntity(entityId) {
|
|
116
|
+
return queryConstraintsForEntity(entityId);
|
|
117
|
+
}
|
|
118
|
+
/** The 'conserved' constraint governing `(entityId, factKey)`, or `null` if
|
|
119
|
+
* none is declared. At most one can exist for a given key: declareConservedConstraint()
|
|
120
|
+
* (src/tools/constraint.ts) rejects overlapping conserved membership on the
|
|
121
|
+
* same key. */
|
|
122
|
+
export function conservedConstraintFor(entityId, factKey) {
|
|
123
|
+
return constraintsFor(entityId, factKey).find((c) => c.kind === "conserved") ?? null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type T } from "./t.js";
|
|
2
|
+
import type { EntityKind } from "./kinds.js";
|
|
3
|
+
/**
|
|
4
|
+
* The engine's state-to-text projection (design §7/§8, GitHub issue #16):
|
|
5
|
+
* "say what IS true, never what is absent." *"The grain stores are full and
|
|
6
|
+
* the treasury coffers overflow"* -- never *"the grain stores are no longer
|
|
7
|
+
* empty."*
|
|
8
|
+
*
|
|
9
|
+
* **The rule is enforced AT CONSTRUCTION, not by scanning generated text.**
|
|
10
|
+
* `createStateRenderer` validates a caller-injected `RenderVocabulary` once,
|
|
11
|
+
* up front, and every guard below exists to make negation UNCONSTRUCTABLE
|
|
12
|
+
* rather than detected (root CLAUDE.md hard rule 4, design §7's hazard
|
|
13
|
+
* paragraph): a `VocabularyEntry` has exactly two fields, `noun` and
|
|
14
|
+
* `adjectives`, both positive by construction -- there is no free-text
|
|
15
|
+
* sentence field, no template string with holes, and no field a negation
|
|
16
|
+
* could be written into. This module never scans generated text, never
|
|
17
|
+
* matches a word against a list, and never inspects any string this
|
|
18
|
+
* codebase did not itself define as an object key (hard rule 4's "a token
|
|
19
|
+
* WE defined" exception, applied here to the literal `noun`/`adjectives`
|
|
20
|
+
* check in `resolveEntry` below).
|
|
21
|
+
*
|
|
22
|
+
* **Mechanism is core; the vocabulary is injected by each caller.** This
|
|
23
|
+
* module ships NOT ONE vocabulary entry, example or default -- a vocabulary
|
|
24
|
+
* rich enough to render a real world contains client-specific nouns, and
|
|
25
|
+
* either sitting in this file would fail `engineVocabulary.test.ts` on day
|
|
26
|
+
* one, correctly (design §7's closing paragraph, §10). `RenderVocabulary` is
|
|
27
|
+
* a parameter type, never a value exported from here.
|
|
28
|
+
*
|
|
29
|
+
* **Output is generated ONLY from facts that hold at `t`.** The sole source
|
|
30
|
+
* of state is `replay({gameId, t})` (replay.ts) -- there is no diff, no
|
|
31
|
+
* set-complement against another `t`, no "expected keys minus present
|
|
32
|
+
* keys." A fact that does not hold at `t` produces NOTHING: not a phrase
|
|
33
|
+
* about its absence, not an "unknown," not a placeholder. Silence. A fact
|
|
34
|
+
* that DOES hold but has no vocabulary entry is reported as a row in
|
|
35
|
+
* `unnamed` -- a caller learns its vocabulary is too thin from a row, never
|
|
36
|
+
* from invented or negated text (the vocabulary-richness contract, task
|
|
37
|
+
* brief / design §7).
|
|
38
|
+
*
|
|
39
|
+
* **No transition/differential API.** There is no `renderChange(before,
|
|
40
|
+
* after)` and nothing here takes two `t`s -- that is precisely the shape
|
|
41
|
+
* that produces "no longer." State at one `t`, full stop
|
|
42
|
+
* (`render.test.ts`'s "module export surface" test locks the runtime export
|
|
43
|
+
* list to exactly `createStateRenderer` so a second, differential entry
|
|
44
|
+
* point cannot be added silently).
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* A positive concrete noun naming a thing that is present, with optional
|
|
48
|
+
* positive adjectives qualifying it. This is the ENTIRE vocabulary
|
|
49
|
+
* language: there is no sentence field, no connective, no place for a
|
|
50
|
+
* negation to live. `resolveEntry` below refuses any object carrying a key
|
|
51
|
+
* other than these two -- the guard that stops an `avoid`/`unless`/`negate`
|
|
52
|
+
* field from being bolted on later.
|
|
53
|
+
*/
|
|
54
|
+
export interface VocabularyEntry {
|
|
55
|
+
/** A positive concrete noun naming a thing that is present. Required, non-empty after trim. */
|
|
56
|
+
noun: string;
|
|
57
|
+
/** Optional positive adjectives qualifying that noun. Each non-empty after trim. */
|
|
58
|
+
adjectives?: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Keyed by fact `key`, then by fact `value`. Both exact matches -- no
|
|
62
|
+
* pattern matching, no globs, no fallback entry, no wildcard key (hard rule
|
|
63
|
+
* 4: this module never matches meaning, only exact keys it was handed).
|
|
64
|
+
*/
|
|
65
|
+
export type RenderVocabulary = Readonly<Record<string, Readonly<Record<string, VocabularyEntry>>>>;
|
|
66
|
+
/** One fact that held at `t` and was named by the injected vocabulary. */
|
|
67
|
+
export interface RenderedNoun {
|
|
68
|
+
entityId: string;
|
|
69
|
+
entityKind: EntityKind;
|
|
70
|
+
entityName: string | null;
|
|
71
|
+
key: string;
|
|
72
|
+
value: string;
|
|
73
|
+
noun: string;
|
|
74
|
+
adjectives: readonly string[];
|
|
75
|
+
/** The noun and its adjectives joined -- the ONLY text this module composes. */
|
|
76
|
+
phrase: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A fact that held at `t` and had no vocabulary entry. A row, never a
|
|
80
|
+
* verdict (root CLAUDE.md hard rule 2) -- there is deliberately no
|
|
81
|
+
* `isComplete`/coverage/severity field anywhere near this shape. Carries no
|
|
82
|
+
* `noun`/`adjectives`/`phrase`: there is nothing to say about it, and this
|
|
83
|
+
* module never invents something to say.
|
|
84
|
+
*/
|
|
85
|
+
export interface UnnamedFact {
|
|
86
|
+
entityId: string;
|
|
87
|
+
entityKind: EntityKind;
|
|
88
|
+
entityName: string | null;
|
|
89
|
+
key: string;
|
|
90
|
+
value: string;
|
|
91
|
+
}
|
|
92
|
+
/** Everything the projection could say about a game at `t`, and everything it could not. */
|
|
93
|
+
export interface RenderedState {
|
|
94
|
+
gameId: string;
|
|
95
|
+
t: T;
|
|
96
|
+
nouns: RenderedNoun[];
|
|
97
|
+
unnamed: UnnamedFact[];
|
|
98
|
+
}
|
|
99
|
+
export interface StateRenderer {
|
|
100
|
+
render(params: {
|
|
101
|
+
gameId: string;
|
|
102
|
+
t: T;
|
|
103
|
+
}): RenderedState;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Builds a `StateRenderer` over a caller-injected `vocabulary`. Validation
|
|
107
|
+
* happens ONCE here, over the whole vocabulary, never lazily per `render`
|
|
108
|
+
* call -- a vocabulary that would be invalid for entity #4,000 is refused
|
|
109
|
+
* before entity #1 is ever rendered, and every `render()` call after
|
|
110
|
+
* construction reuses the same validated, frozen internal copy rather than
|
|
111
|
+
* re-checking anything.
|
|
112
|
+
*
|
|
113
|
+
* Throws (see `resolveVocabulary`/`resolveEntry` above) on: an entry whose
|
|
114
|
+
* `noun` is missing, not a string, or empty/whitespace after trim; any
|
|
115
|
+
* `adjectives` element that is not a string or is empty/whitespace after
|
|
116
|
+
* trim; an entry object carrying any key other than `noun`/`adjectives`
|
|
117
|
+
* (named in the error); and an empty vocabulary.
|
|
118
|
+
*/
|
|
119
|
+
export declare function createStateRenderer(params: {
|
|
120
|
+
vocabulary: RenderVocabulary;
|
|
121
|
+
}): StateRenderer;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { replay } from "./replay.js";
|
|
2
|
+
/**
|
|
3
|
+
* Exactly the fields `VocabularyEntry` declares. This is a literal check
|
|
4
|
+
* against identifiers THIS MODULE defined in a type THIS MODULE specified
|
|
5
|
+
* -- explicitly permitted by hard rule 4 -- and it is the load-bearing
|
|
6
|
+
* guard named in the task brief: it is what stops a caller (or a future
|
|
7
|
+
* editor of a caller's vocabulary file) from bolting an `avoid:`, an
|
|
8
|
+
* `unless:`, or a `negate:` field onto an entry six months from now. Any
|
|
9
|
+
* key outside this set is refused, by name, at construction.
|
|
10
|
+
*/
|
|
11
|
+
const ALLOWED_ENTRY_FIELDS = new Set(["noun", "adjectives"]);
|
|
12
|
+
function describeValue(value) {
|
|
13
|
+
if (value === null)
|
|
14
|
+
return "null";
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return "undefined";
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return "an array";
|
|
19
|
+
const type = typeof value;
|
|
20
|
+
if (type === "object")
|
|
21
|
+
return "an object";
|
|
22
|
+
return `a ${type} (${JSON.stringify(value)})`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validates and defensively copies one `(key, value) -> entry` mapping.
|
|
26
|
+
* Never returns a reference into the caller's own object graph -- every
|
|
27
|
+
* field is read once and copied into a fresh, frozen object -- so a caller
|
|
28
|
+
* that holds onto its original vocabulary object and mutates it after
|
|
29
|
+
* construction (adding a forbidden field, blanking a noun) can never affect
|
|
30
|
+
* what a renderer built from it produces. `render.test.ts`'s
|
|
31
|
+
* "frozen/defensive-copy behaviour" test proves this by mutating the
|
|
32
|
+
* original object after `createStateRenderer` returns and asserting the
|
|
33
|
+
* renderer's output is unchanged.
|
|
34
|
+
*/
|
|
35
|
+
function resolveEntry(entry, key, value) {
|
|
36
|
+
const path = `["${key}"]["${value}"]`;
|
|
37
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
38
|
+
throw new Error(`render vocabulary: entry at ${path} must be an object with a "noun" field, got ${describeValue(entry)}`);
|
|
39
|
+
}
|
|
40
|
+
const record = entry;
|
|
41
|
+
for (const field of Object.keys(record)) {
|
|
42
|
+
if (!ALLOWED_ENTRY_FIELDS.has(field)) {
|
|
43
|
+
throw new Error(`render vocabulary: entry at ${path} carries an unrecognized field "${field}". A vocabulary entry may ` +
|
|
44
|
+
`only declare "noun" and "adjectives" -- this refusal is what stops a forbidden field (an "avoid", a ` +
|
|
45
|
+
`"negate", a "mustNotSay") from being bolted onto the vocabulary later (root CLAUDE.md hard rules 4 ` +
|
|
46
|
+
`and 5, design §7).`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (typeof record.noun !== "string" || record.noun.trim().length === 0) {
|
|
50
|
+
throw new Error(`render vocabulary: entry at ${path}.noun must be a non-empty string, got ${describeValue(record.noun)}`);
|
|
51
|
+
}
|
|
52
|
+
let adjectives = [];
|
|
53
|
+
if (record.adjectives !== undefined) {
|
|
54
|
+
if (!Array.isArray(record.adjectives)) {
|
|
55
|
+
throw new Error(`render vocabulary: entry at ${path}.adjectives must be an array of strings, got ` +
|
|
56
|
+
`${describeValue(record.adjectives)}`);
|
|
57
|
+
}
|
|
58
|
+
adjectives = record.adjectives.map((adjective, index) => {
|
|
59
|
+
if (typeof adjective !== "string" || adjective.trim().length === 0) {
|
|
60
|
+
throw new Error(`render vocabulary: entry at ${path}.adjectives[${index}] must be a non-empty string, got ` +
|
|
61
|
+
`${describeValue(adjective)}`);
|
|
62
|
+
}
|
|
63
|
+
return adjective;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return Object.freeze({ noun: record.noun, adjectives: Object.freeze(adjectives) });
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Validates the WHOLE vocabulary once, at construction (never lazily per
|
|
70
|
+
* render -- see `createStateRenderer`), and returns a frozen, defensively
|
|
71
|
+
* copied internal form. Every reachable value is copied by primitive field,
|
|
72
|
+
* never by object reference, so nothing in the returned structure can be
|
|
73
|
+
* reached and mutated through the caller's original `vocabulary` argument.
|
|
74
|
+
*/
|
|
75
|
+
function resolveVocabulary(vocabulary) {
|
|
76
|
+
if (typeof vocabulary !== "object" || vocabulary === null || Array.isArray(vocabulary)) {
|
|
77
|
+
throw new Error(`render vocabulary: vocabulary must be an object mapping fact keys to value->entry maps, got ` +
|
|
78
|
+
`${describeValue(vocabulary)}`);
|
|
79
|
+
}
|
|
80
|
+
const resolved = {};
|
|
81
|
+
let entryCount = 0;
|
|
82
|
+
for (const key of Object.keys(vocabulary)) {
|
|
83
|
+
const valuesForKey = vocabulary[key];
|
|
84
|
+
if (typeof valuesForKey !== "object" || valuesForKey === null || Array.isArray(valuesForKey)) {
|
|
85
|
+
throw new Error(`render vocabulary: vocabulary["${key}"] must be an object mapping fact values to vocabulary entries, ` +
|
|
86
|
+
`got ${describeValue(valuesForKey)}`);
|
|
87
|
+
}
|
|
88
|
+
const resolvedValues = {};
|
|
89
|
+
for (const value of Object.keys(valuesForKey)) {
|
|
90
|
+
const entry = valuesForKey[value];
|
|
91
|
+
resolvedValues[value] = resolveEntry(entry, key, value);
|
|
92
|
+
entryCount++;
|
|
93
|
+
}
|
|
94
|
+
resolved[key] = Object.freeze(resolvedValues);
|
|
95
|
+
}
|
|
96
|
+
// A renderer that can name nothing is a configuration error, not a
|
|
97
|
+
// silent no-op -- an empty vocabulary would render every real fact into
|
|
98
|
+
// `unnamed` forever, which is a caller mistake worth refusing loudly
|
|
99
|
+
// rather than a legitimate "narrows to nothing" the way an empty
|
|
100
|
+
// `entityIds` list is elsewhere in this codebase (narration.ts).
|
|
101
|
+
if (entryCount === 0) {
|
|
102
|
+
throw new Error("render vocabulary: an empty vocabulary can name nothing at all -- that is a configuration error, not a " +
|
|
103
|
+
"silent no-op. Supply at least one (key, value) -> { noun, adjectives? } entry.");
|
|
104
|
+
}
|
|
105
|
+
return Object.freeze(resolved);
|
|
106
|
+
}
|
|
107
|
+
/** The noun and its adjectives joined -- the ONLY text this module composes. */
|
|
108
|
+
function composePhrase(entry) {
|
|
109
|
+
return [...entry.adjectives, entry.noun].join(" ");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Renders one game's world at `t`. Reads `replay({gameId, t})` -- the sole
|
|
113
|
+
* source of state -- and nothing else.
|
|
114
|
+
*
|
|
115
|
+
* Ordering is deterministic and total, bottoming out at columns SQLite
|
|
116
|
+
* guarantees unique, copying `narration.ts`/`export.ts`'s discipline:
|
|
117
|
+
* `replay()` already orders entities by `(createdAtT, id)`, and `id` is the
|
|
118
|
+
* entities primary key, so that half is already a total order. Within one
|
|
119
|
+
* entity, interval versioning guarantees at most one fact per `key` is
|
|
120
|
+
* valid at a single `t` (replay.ts's own doc comment), so sorting that
|
|
121
|
+
* entity's fact keys ascending is itself a total order over that entity's
|
|
122
|
+
* facts -- no tie a fact `key` could produce. Two renders of the same world
|
|
123
|
+
* therefore produce identical arrays; `render.test.ts`'s determinism test
|
|
124
|
+
* checks this with `JSON.stringify` equality, the same proof `narration.ts`
|
|
125
|
+
* uses.
|
|
126
|
+
*/
|
|
127
|
+
function renderState(vocabulary, params) {
|
|
128
|
+
const snapshot = replay({ gameId: params.gameId, t: params.t });
|
|
129
|
+
const nouns = [];
|
|
130
|
+
const unnamed = [];
|
|
131
|
+
for (const entity of snapshot.entities) {
|
|
132
|
+
const keys = Object.keys(entity.facts).sort();
|
|
133
|
+
for (const key of keys) {
|
|
134
|
+
const fact = entity.facts[key];
|
|
135
|
+
const entry = vocabulary[key]?.[fact.value];
|
|
136
|
+
if (entry) {
|
|
137
|
+
nouns.push({
|
|
138
|
+
entityId: entity.id,
|
|
139
|
+
entityKind: entity.kind,
|
|
140
|
+
entityName: entity.name,
|
|
141
|
+
key,
|
|
142
|
+
value: fact.value,
|
|
143
|
+
noun: entry.noun,
|
|
144
|
+
adjectives: entry.adjectives,
|
|
145
|
+
phrase: composePhrase(entry),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// A fact with no vocabulary entry is OMITTED as text, but REPORTED
|
|
150
|
+
// as a row -- the vocabulary-richness contract (task brief, design
|
|
151
|
+
// §7): the engine records the gap, never invents a noun, never
|
|
152
|
+
// negates, and passes no judgement on it (root CLAUDE.md hard rule
|
|
153
|
+
// 2 -- no isComplete, no coverage percentage, no severity).
|
|
154
|
+
unnamed.push({
|
|
155
|
+
entityId: entity.id,
|
|
156
|
+
entityKind: entity.kind,
|
|
157
|
+
entityName: entity.name,
|
|
158
|
+
key,
|
|
159
|
+
value: fact.value,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { gameId: snapshot.gameId, t: snapshot.t, nouns, unnamed };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Builds a `StateRenderer` over a caller-injected `vocabulary`. Validation
|
|
168
|
+
* happens ONCE here, over the whole vocabulary, never lazily per `render`
|
|
169
|
+
* call -- a vocabulary that would be invalid for entity #4,000 is refused
|
|
170
|
+
* before entity #1 is ever rendered, and every `render()` call after
|
|
171
|
+
* construction reuses the same validated, frozen internal copy rather than
|
|
172
|
+
* re-checking anything.
|
|
173
|
+
*
|
|
174
|
+
* Throws (see `resolveVocabulary`/`resolveEntry` above) on: an entry whose
|
|
175
|
+
* `noun` is missing, not a string, or empty/whitespace after trim; any
|
|
176
|
+
* `adjectives` element that is not a string or is empty/whitespace after
|
|
177
|
+
* trim; an entry object carrying any key other than `noun`/`adjectives`
|
|
178
|
+
* (named in the error); and an empty vocabulary.
|
|
179
|
+
*/
|
|
180
|
+
export function createStateRenderer(params) {
|
|
181
|
+
const vocabulary = resolveVocabulary(params.vocabulary);
|
|
182
|
+
return {
|
|
183
|
+
render(renderParams) {
|
|
184
|
+
return renderState(vocabulary, renderParams);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type T } from "./t.js";
|
|
2
|
+
import type { EntityKind } from "./kinds.js";
|
|
3
|
+
/**
|
|
4
|
+
* One fact as it read at the replayed `t`. `validFromT` is carried because
|
|
5
|
+
* design §5.2c's one hop of causality needs the moment a fact opened --
|
|
6
|
+
* nothing more than that is in scope here. In particular there is no
|
|
7
|
+
* `validToT`: at the replayed instant the fact is simply open, and whether
|
|
8
|
+
* or when it later closes is not a property of *this* snapshot.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReplayedFact {
|
|
11
|
+
value: string;
|
|
12
|
+
validFromT: T;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* One entity as it read at the replayed `t`, with every fact that was valid
|
|
16
|
+
* then. `facts` is keyed by `key` because at most one fact per key can be
|
|
17
|
+
* valid at a single `t` -- interval versioning (schema.ts) guarantees that
|
|
18
|
+
* for well-formed data, and the query below is written so a duplicate key
|
|
19
|
+
* (malformed data) resolves deterministically rather than by map-insertion
|
|
20
|
+
* order.
|
|
21
|
+
*/
|
|
22
|
+
export interface ReplayedEntity {
|
|
23
|
+
id: string;
|
|
24
|
+
kind: EntityKind;
|
|
25
|
+
name: string | null;
|
|
26
|
+
createdAtT: T;
|
|
27
|
+
facts: Record<string, ReplayedFact>;
|
|
28
|
+
}
|
|
29
|
+
/** A full world snapshot: every entity alive at `t`, with every fact valid at `t`. */
|
|
30
|
+
export interface Snapshot {
|
|
31
|
+
gameId: string;
|
|
32
|
+
t: T;
|
|
33
|
+
entities: ReplayedEntity[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* `replay(t)` -- design §5.1's "whole feature": the state of one game's
|
|
37
|
+
* world at any point in its recorded history, not only at "now".
|
|
38
|
+
*
|
|
39
|
+
* Half-open intervals, everywhere and identically (design §5.1, and the
|
|
40
|
+
* architecture note's flagged likeliest bug):
|
|
41
|
+
*
|
|
42
|
+
* alive at t: created_at_t <= t AND (destroyed_at_t IS NULL OR destroyed_at_t > t)
|
|
43
|
+
* valid at t: valid_from_t <= t AND (valid_to_t IS NULL OR valid_to_t > t)
|
|
44
|
+
*
|
|
45
|
+
* A fact valid [a, b) is present at a and gone at b -- b itself belongs to
|
|
46
|
+
* whatever superseded it, never to both. A zero-width interval (valid_from_t
|
|
47
|
+
* == valid_to_t, which two writes at one declared t produce) is therefore
|
|
48
|
+
* invisible at every t: it opens and closes at the same instant, so the
|
|
49
|
+
* "> t" half of its own close condition is never satisfied at the t it
|
|
50
|
+
* would need to be visible at.
|
|
51
|
+
*
|
|
52
|
+
* Exactly two queries, never one per entity -- this runs over a whole
|
|
53
|
+
* world. The first finds who was alive; the second finds what was true of
|
|
54
|
+
* them, via a JOIN back to `entities` rather than a per-entity-id `IN (...)`
|
|
55
|
+
* list: this snapshot is meant to run over an entire world, and binding one
|
|
56
|
+
* parameter per entity would both hit SQLite's bound-variable limit on a
|
|
57
|
+
* large one and make every distinct entity count its own SQL text, which
|
|
58
|
+
* defeats better-sqlite3's prepared-statement cache. Fixed SQL, two binds
|
|
59
|
+
* of `t`, regardless of how many entities exist.
|
|
60
|
+
*
|
|
61
|
+
* DECISION(#18): replay() applies no visibility filtering, by decision rather than omission.
|
|
62
|
+
*
|
|
63
|
+
* OMNISCIENT, DELIBERATELY (issue #18). This returns every fact valid at
|
|
64
|
+
* `t`, for every entity alive at `t`, with no visibility filtering of any
|
|
65
|
+
* kind. That is a decision, not an omission: per-principal visibility is
|
|
66
|
+
* deferred until a real caller makes the requirement concrete, because the
|
|
67
|
+
* expensive half of such a feature is the principal model, and inventing a
|
|
68
|
+
* principal against an imagined client is precisely what design §13 forbids.
|
|
69
|
+
*
|
|
70
|
+
* Worth stating out loud here rather than leaving to be inferred, because
|
|
71
|
+
* the predecessor's unenforced version of the same decision is legible
|
|
72
|
+
* today for exactly one reason -- `get_secret`'s own description says "DM
|
|
73
|
+
* view - shows all info" -- and a reader who finds an unfiltered query with
|
|
74
|
+
* no such sentence cannot tell a decision from a hole.
|
|
75
|
+
*
|
|
76
|
+
* A later filter is additive and needs nothing reserved for it now: it is
|
|
77
|
+
* one predicate, added here once, because this function is the single place
|
|
78
|
+
* every reader of "what was true at `t`" goes through. Do NOT add a
|
|
79
|
+
* nullable visibility column to `facts` in anticipation -- an unpopulated
|
|
80
|
+
* one reads equally well as "visible to everyone" and "visible to no one",
|
|
81
|
+
* and both readings survive review.
|
|
82
|
+
*/
|
|
83
|
+
export declare function replay(params: {
|
|
84
|
+
gameId: string;
|
|
85
|
+
t: T;
|
|
86
|
+
}): Snapshot;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { getDatabase } from "../db/connection.js";
|
|
2
|
+
import { assertT } from "./t.js";
|
|
3
|
+
/**
|
|
4
|
+
* The "alive at t" half of both queries below, as one shared string.
|
|
5
|
+
*
|
|
6
|
+
* This has to be a single constant, not two hand-matched copies, because
|
|
7
|
+
* the whole point of replay is that "who was alive" and "what was true of
|
|
8
|
+
* them" describe the same instant. Two copies would compile and pass review
|
|
9
|
+
* right up until someone edited one -- silently reintroducing entities whose
|
|
10
|
+
* facts vanished, or facts belonging to nobody the snapshot admits exists.
|
|
11
|
+
* Always used against an `entities` row aliased `e` (see both queries).
|
|
12
|
+
*/
|
|
13
|
+
const ALIVE_AT_T = "e.created_at_t <= ? AND (e.destroyed_at_t IS NULL OR e.destroyed_at_t > ?)";
|
|
14
|
+
/**
|
|
15
|
+
* `replay(t)` -- design §5.1's "whole feature": the state of one game's
|
|
16
|
+
* world at any point in its recorded history, not only at "now".
|
|
17
|
+
*
|
|
18
|
+
* Half-open intervals, everywhere and identically (design §5.1, and the
|
|
19
|
+
* architecture note's flagged likeliest bug):
|
|
20
|
+
*
|
|
21
|
+
* alive at t: created_at_t <= t AND (destroyed_at_t IS NULL OR destroyed_at_t > t)
|
|
22
|
+
* valid at t: valid_from_t <= t AND (valid_to_t IS NULL OR valid_to_t > t)
|
|
23
|
+
*
|
|
24
|
+
* A fact valid [a, b) is present at a and gone at b -- b itself belongs to
|
|
25
|
+
* whatever superseded it, never to both. A zero-width interval (valid_from_t
|
|
26
|
+
* == valid_to_t, which two writes at one declared t produce) is therefore
|
|
27
|
+
* invisible at every t: it opens and closes at the same instant, so the
|
|
28
|
+
* "> t" half of its own close condition is never satisfied at the t it
|
|
29
|
+
* would need to be visible at.
|
|
30
|
+
*
|
|
31
|
+
* Exactly two queries, never one per entity -- this runs over a whole
|
|
32
|
+
* world. The first finds who was alive; the second finds what was true of
|
|
33
|
+
* them, via a JOIN back to `entities` rather than a per-entity-id `IN (...)`
|
|
34
|
+
* list: this snapshot is meant to run over an entire world, and binding one
|
|
35
|
+
* parameter per entity would both hit SQLite's bound-variable limit on a
|
|
36
|
+
* large one and make every distinct entity count its own SQL text, which
|
|
37
|
+
* defeats better-sqlite3's prepared-statement cache. Fixed SQL, two binds
|
|
38
|
+
* of `t`, regardless of how many entities exist.
|
|
39
|
+
*
|
|
40
|
+
* DECISION(#18): replay() applies no visibility filtering, by decision rather than omission.
|
|
41
|
+
*
|
|
42
|
+
* OMNISCIENT, DELIBERATELY (issue #18). This returns every fact valid at
|
|
43
|
+
* `t`, for every entity alive at `t`, with no visibility filtering of any
|
|
44
|
+
* kind. That is a decision, not an omission: per-principal visibility is
|
|
45
|
+
* deferred until a real caller makes the requirement concrete, because the
|
|
46
|
+
* expensive half of such a feature is the principal model, and inventing a
|
|
47
|
+
* principal against an imagined client is precisely what design §13 forbids.
|
|
48
|
+
*
|
|
49
|
+
* Worth stating out loud here rather than leaving to be inferred, because
|
|
50
|
+
* the predecessor's unenforced version of the same decision is legible
|
|
51
|
+
* today for exactly one reason -- `get_secret`'s own description says "DM
|
|
52
|
+
* view - shows all info" -- and a reader who finds an unfiltered query with
|
|
53
|
+
* no such sentence cannot tell a decision from a hole.
|
|
54
|
+
*
|
|
55
|
+
* A later filter is additive and needs nothing reserved for it now: it is
|
|
56
|
+
* one predicate, added here once, because this function is the single place
|
|
57
|
+
* every reader of "what was true at `t`" goes through. Do NOT add a
|
|
58
|
+
* nullable visibility column to `facts` in anticipation -- an unpopulated
|
|
59
|
+
* one reads equally well as "visible to everyone" and "visible to no one",
|
|
60
|
+
* and both readings survive review.
|
|
61
|
+
*/
|
|
62
|
+
export function replay(params) {
|
|
63
|
+
const { gameId, t } = params;
|
|
64
|
+
// A Date, a string, NaN or +-Infinity gets refused here, loudly, before
|
|
65
|
+
// it can silently compare unequal to every row and produce a confidently
|
|
66
|
+
// wrong empty snapshot.
|
|
67
|
+
assertT(t);
|
|
68
|
+
const db = getDatabase();
|
|
69
|
+
// Query 1 of 2: which entities were alive at t, in the caller-diffable
|
|
70
|
+
// order the design calls for (created_at_t, then id -- id as the
|
|
71
|
+
// tiebreaker for entities created at the same t, so the order never
|
|
72
|
+
// depends on SQLite's unspecified tie behavior).
|
|
73
|
+
const entityRows = db
|
|
74
|
+
.prepare(`SELECT e.id, e.kind, e.name, e.created_at_t
|
|
75
|
+
FROM entities e
|
|
76
|
+
WHERE e.game_id = ?
|
|
77
|
+
AND ${ALIVE_AT_T}
|
|
78
|
+
ORDER BY e.created_at_t, e.id`)
|
|
79
|
+
.all(gameId, t, t);
|
|
80
|
+
const entities = entityRows.map((row) => ({
|
|
81
|
+
id: row.id,
|
|
82
|
+
kind: row.kind,
|
|
83
|
+
name: row.name,
|
|
84
|
+
createdAtT: row.created_at_t,
|
|
85
|
+
facts: {},
|
|
86
|
+
}));
|
|
87
|
+
// Nothing alive means nothing that could own a fact. Not load-bearing for
|
|
88
|
+
// correctness the way it was before the JOIN -- query 2 below is valid
|
|
89
|
+
// SQL and would themselves return zero rows -- but it is still a genuine
|
|
90
|
+
// short-circuit: an empty game skips a JOIN entirely rather than running
|
|
91
|
+
// it to find nothing.
|
|
92
|
+
if (entities.length === 0) {
|
|
93
|
+
return { gameId, t, entities };
|
|
94
|
+
}
|
|
95
|
+
const byId = new Map(entities.map((entity) => [entity.id, entity]));
|
|
96
|
+
// Query 2 of 2: every fact valid at t, joined back to `entities` so
|
|
97
|
+
// "valid" is scoped to exactly the entities `ALIVE_AT_T` says were alive
|
|
98
|
+
// -- the same predicate, not a hand-matched copy of it. Ordered by
|
|
99
|
+
// valid_from_t so that if malformed data ever puts two facts for the same
|
|
100
|
+
// key in the visible window at once -- which correct writers never
|
|
101
|
+
// produce -- the one opened later (the one that superseded the other) is
|
|
102
|
+
// the one left standing, rather than whichever row SQLite happened to
|
|
103
|
+
// return first.
|
|
104
|
+
const factRows = db
|
|
105
|
+
.prepare(`SELECT f.entity_id, f.key, f.value, f.valid_from_t
|
|
106
|
+
FROM facts f
|
|
107
|
+
JOIN entities e ON e.id = f.entity_id
|
|
108
|
+
WHERE e.game_id = ?
|
|
109
|
+
AND ${ALIVE_AT_T}
|
|
110
|
+
AND f.valid_from_t <= ?
|
|
111
|
+
AND (f.valid_to_t IS NULL OR f.valid_to_t > ?)
|
|
112
|
+
ORDER BY f.valid_from_t`)
|
|
113
|
+
.all(gameId, t, t, t, t);
|
|
114
|
+
for (const factRow of factRows) {
|
|
115
|
+
const entity = byId.get(factRow.entity_id);
|
|
116
|
+
// Cannot happen: every fact row came from a JOIN against entities e
|
|
117
|
+
// filtered by the same game_id and ALIVE_AT_T as query 1, so its
|
|
118
|
+
// entity_id is always a key already in byId. Guarded anyway rather than
|
|
119
|
+
// asserted, because a snapshot query has no business throwing over its
|
|
120
|
+
// own bookkeeping.
|
|
121
|
+
if (!entity)
|
|
122
|
+
continue;
|
|
123
|
+
entity.facts[factRow.key] = { value: factRow.value, validFromT: factRow.valid_from_t };
|
|
124
|
+
}
|
|
125
|
+
return { gameId, t, entities };
|
|
126
|
+
}
|