run-dmcp 0.8.0 → 0.10.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/dist/bin/run-dmcp.js +16 -1
- package/dist/db/schema.js +62 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +12 -1
- package/dist/mcp-server.d.ts +6 -1
- package/dist/mcp-server.js +11 -3
- package/dist/reader/preflight.d.ts +62 -0
- package/dist/reader/preflight.js +40 -0
- package/dist/reader/turnReader.d.ts +169 -13
- package/dist/reader/turnReader.js +232 -78
- package/dist/register/conditions.d.ts +16 -0
- package/dist/register/conditions.js +45 -0
- package/dist/register/reader.d.ts +2 -0
- package/dist/register/reader.js +132 -0
- package/dist/register/render.js +7 -2
- package/dist/register/resolve.js +8 -2
- package/dist/register/timeline.js +29 -2
- package/dist/rpg/server.d.ts +2 -0
- package/dist/schemas/index.d.ts +10 -10
- package/dist/timeline/constrained.d.ts +4 -5
- package/dist/timeline/constrained.js +31 -25
- package/dist/timeline/declared.d.ts +147 -0
- package/dist/timeline/declared.js +388 -0
- package/dist/timeline/registry.d.ts +42 -4
- package/dist/timeline/registry.js +62 -4
- package/dist/timeline/render.d.ts +3 -0
- package/dist/timeline/render.js +1 -1
- package/dist/timeline/replay.d.ts +15 -0
- package/dist/timeline/replay.js +33 -2
- package/dist/timeline/resolve.d.ts +60 -5
- package/dist/timeline/resolve.js +135 -5
- package/dist/timeline/schema.js +28 -0
- package/dist/timeline/turns.d.ts +60 -0
- package/dist/timeline/turns.js +93 -0
- package/dist/tools/constraint.js +10 -20
- package/dist/types/index.d.ts +3 -0
- package/dist/utils/output-schemas.d.ts +6 -6
- package/package.json +1 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { narrationConstraintAt } from "./narration.js";
|
|
2
|
+
import { assertT } from "./t.js";
|
|
3
|
+
const OPS = ["<", "<=", "==", "!=", ">=", ">"];
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Validation -- once, before anything runs
|
|
6
|
+
// ============================================================================
|
|
7
|
+
function fail(message) {
|
|
8
|
+
throw new Error(`declared rules: ${message}`);
|
|
9
|
+
}
|
|
10
|
+
function isParamRef(v) {
|
|
11
|
+
return typeof v === "object" && v !== null && typeof v.param === "string";
|
|
12
|
+
}
|
|
13
|
+
function checkEntity(where, entity) {
|
|
14
|
+
if (typeof entity === "string")
|
|
15
|
+
return;
|
|
16
|
+
if (isParamRef(entity))
|
|
17
|
+
return;
|
|
18
|
+
if (typeof entity === "object" && entity !== null && typeof entity.named === "string")
|
|
19
|
+
return;
|
|
20
|
+
fail(`${where}: entity must be an id, {param}, or {named}, got ${JSON.stringify(entity)}`);
|
|
21
|
+
}
|
|
22
|
+
function checkNumberOperand(where, field, v, optional) {
|
|
23
|
+
if (v === undefined || v === null) {
|
|
24
|
+
if (optional)
|
|
25
|
+
return;
|
|
26
|
+
fail(`${where}: '${field}' is required`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
29
|
+
return;
|
|
30
|
+
if (isParamRef(v))
|
|
31
|
+
return;
|
|
32
|
+
fail(`${where}: '${field}' must be a finite number or {param}, got ${JSON.stringify(v)}`);
|
|
33
|
+
}
|
|
34
|
+
function clausesOf(condition) {
|
|
35
|
+
return condition.all ?? condition.any ?? [];
|
|
36
|
+
}
|
|
37
|
+
export function validateDeclaredRules(rules) {
|
|
38
|
+
if (!rules || !Array.isArray(rules.conditions) || !Array.isArray(rules.mechanics)) {
|
|
39
|
+
fail("expected { conditions: [], mechanics: [] }");
|
|
40
|
+
}
|
|
41
|
+
const byId = new Map();
|
|
42
|
+
for (const condition of rules.conditions) {
|
|
43
|
+
if (typeof condition?.id !== "string" || condition.id.length === 0)
|
|
44
|
+
fail(`a condition's 'id' must be a non-empty string`);
|
|
45
|
+
if (byId.has(condition.id))
|
|
46
|
+
fail(`duplicate condition id '${condition.id}'`);
|
|
47
|
+
byId.set(condition.id, condition);
|
|
48
|
+
if ((condition.all === undefined) === (condition.any === undefined)) {
|
|
49
|
+
fail(`condition '${condition.id}' must declare exactly one of 'all' or 'any'`);
|
|
50
|
+
}
|
|
51
|
+
const clauses = clausesOf(condition);
|
|
52
|
+
if (!Array.isArray(clauses) || clauses.length === 0)
|
|
53
|
+
fail(`condition '${condition.id}' needs at least one clause`);
|
|
54
|
+
for (const clause of clauses) {
|
|
55
|
+
if (typeof clause !== "object" || clause === null)
|
|
56
|
+
fail(`condition '${condition.id}': a clause must be an object, got ${JSON.stringify(clause)}`);
|
|
57
|
+
if ("condition" in clause)
|
|
58
|
+
continue;
|
|
59
|
+
const where = `condition '${condition.id}'`;
|
|
60
|
+
checkEntity(where, clause.entity);
|
|
61
|
+
if (typeof clause.key !== "string" || clause.key.length === 0)
|
|
62
|
+
fail(`${where}: a clause's 'key' must be a non-empty string`);
|
|
63
|
+
if (!OPS.includes(clause.op))
|
|
64
|
+
fail(`${where}: unknown operator '${String(clause.op)}' (one of ${OPS.join(" ")})`);
|
|
65
|
+
const v = clause.value;
|
|
66
|
+
if (!(typeof v === "string" || (typeof v === "number" && Number.isFinite(v)) || isParamRef(v))) {
|
|
67
|
+
fail(`${where}: a clause's 'value' must be a number, a string or {param}, got ${JSON.stringify(v)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// References resolve, and never loop.
|
|
72
|
+
for (const condition of rules.conditions) {
|
|
73
|
+
for (const clause of clausesOf(condition)) {
|
|
74
|
+
if ("condition" in clause && !byId.has(clause.condition)) {
|
|
75
|
+
fail(`condition '${condition.id}' names unknown condition '${clause.condition}'`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const state = new Map();
|
|
80
|
+
const visit = (id, path) => {
|
|
81
|
+
if (state.get(id) === "done")
|
|
82
|
+
return;
|
|
83
|
+
if (state.get(id) === "visiting")
|
|
84
|
+
fail(`conditions form a cycle: ${[...path, id].join(" -> ")}`);
|
|
85
|
+
state.set(id, "visiting");
|
|
86
|
+
const condition = byId.get(id);
|
|
87
|
+
for (const clause of condition ? clausesOf(condition) : []) {
|
|
88
|
+
if ("condition" in clause)
|
|
89
|
+
visit(clause.condition, [...path, id]);
|
|
90
|
+
}
|
|
91
|
+
state.set(id, "done");
|
|
92
|
+
};
|
|
93
|
+
for (const condition of rules.conditions)
|
|
94
|
+
visit(condition.id, []);
|
|
95
|
+
const names = new Set();
|
|
96
|
+
for (const mechanic of rules.mechanics) {
|
|
97
|
+
if (typeof mechanic?.name !== "string" || mechanic.name.length === 0)
|
|
98
|
+
fail(`a mechanic's 'name' must be a non-empty string`);
|
|
99
|
+
if (names.has(mechanic.name))
|
|
100
|
+
fail(`duplicate mechanic name '${mechanic.name}'`);
|
|
101
|
+
names.add(mechanic.name);
|
|
102
|
+
if (mechanic.when !== undefined && !Array.isArray(mechanic.when))
|
|
103
|
+
fail(`mechanic '${mechanic.name}': 'when' must be a list of condition ids`);
|
|
104
|
+
for (const id of mechanic.when ?? []) {
|
|
105
|
+
if (!byId.has(id))
|
|
106
|
+
fail(`mechanic '${mechanic.name}' is gated on unknown condition '${id}'`);
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(mechanic.legs))
|
|
109
|
+
fail(`mechanic '${mechanic.name}' must declare 'legs'`);
|
|
110
|
+
for (const leg of [...mechanic.legs, ...(mechanic.otherwise ?? [])]) {
|
|
111
|
+
const where = `mechanic '${mechanic.name}'`;
|
|
112
|
+
if (leg?.kind !== "adjust" && leg?.kind !== "set")
|
|
113
|
+
fail(`${where}: unknown leg kind '${String(leg?.kind)}'`);
|
|
114
|
+
checkEntity(where, leg.entity);
|
|
115
|
+
if (typeof leg.key !== "string" || leg.key.length === 0)
|
|
116
|
+
fail(`${where}: a leg's 'key' must be a non-empty string`);
|
|
117
|
+
if (leg.kind === "adjust") {
|
|
118
|
+
checkNumberOperand(where, "amount", leg.amount, false);
|
|
119
|
+
if (leg.sign !== undefined && leg.sign !== 1 && leg.sign !== -1)
|
|
120
|
+
fail(`${where}: 'sign' must be 1 or -1`);
|
|
121
|
+
}
|
|
122
|
+
if (leg.kind === "set") {
|
|
123
|
+
const v = leg.value;
|
|
124
|
+
if (!("value" in leg) || !(v === null || typeof v === "string" || (typeof v === "number" && Number.isFinite(v)) || isParamRef(v))) {
|
|
125
|
+
fail(`${where}: a set leg's 'value' must be a number, a string, null or {param}, got ${JSON.stringify(v)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
checkNumberOperand(where, "min", leg.min, true);
|
|
129
|
+
checkNumberOperand(where, "max", leg.max, true);
|
|
130
|
+
if (typeof leg.min === "number" && typeof leg.max === "number" && leg.min > leg.max) {
|
|
131
|
+
fail(`${where}: min ${leg.min} is above max ${leg.max}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function param(params, name, type) {
|
|
137
|
+
const v = params[name];
|
|
138
|
+
const ok = type === "number"
|
|
139
|
+
? typeof v === "number" && Number.isFinite(v)
|
|
140
|
+
: type === "string"
|
|
141
|
+
? typeof v === "string"
|
|
142
|
+
: typeof v === "string" || v === null || (typeof v === "number" && Number.isFinite(v));
|
|
143
|
+
if (!ok)
|
|
144
|
+
throw new Error(`declared rules: parameter '${name}' must be a ${type === "scalar" ? "number, string or null" : type}, got ${JSON.stringify(v)}`);
|
|
145
|
+
return v;
|
|
146
|
+
}
|
|
147
|
+
function numberOf(operand, params) {
|
|
148
|
+
return isParamRef(operand) ? param(params, operand.param, "number") : operand;
|
|
149
|
+
}
|
|
150
|
+
/** Stored fact values are text ("100.0"); one that is a plain decimal
|
|
151
|
+
* number -- optional sign, digits, optional fraction and exponent, nothing
|
|
152
|
+
* else -- is compared as a number. Hex, padding, "Infinity" and words stay
|
|
153
|
+
* strings, compared by exact equality. */
|
|
154
|
+
function isPlainNumber(text) {
|
|
155
|
+
// A character scan, not a pattern: the timeline modules construct no regex
|
|
156
|
+
// (hard rule 4's guard), and this is a question about characters anyway.
|
|
157
|
+
const digit = (c) => c !== undefined && c >= "0" && c <= "9";
|
|
158
|
+
let i = 0;
|
|
159
|
+
if (text[i] === "-")
|
|
160
|
+
i++;
|
|
161
|
+
let digits = 0;
|
|
162
|
+
while (digit(text[i])) {
|
|
163
|
+
i++;
|
|
164
|
+
digits++;
|
|
165
|
+
}
|
|
166
|
+
if (text[i] === ".") {
|
|
167
|
+
i++;
|
|
168
|
+
while (digit(text[i])) {
|
|
169
|
+
i++;
|
|
170
|
+
digits++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (digits === 0)
|
|
174
|
+
return false;
|
|
175
|
+
if (text[i] === "e" || text[i] === "E") {
|
|
176
|
+
i++;
|
|
177
|
+
if (text[i] === "+" || text[i] === "-")
|
|
178
|
+
i++;
|
|
179
|
+
if (!digit(text[i]))
|
|
180
|
+
return false;
|
|
181
|
+
while (digit(text[i]))
|
|
182
|
+
i++;
|
|
183
|
+
}
|
|
184
|
+
return i === text.length;
|
|
185
|
+
}
|
|
186
|
+
function asNumber(v) {
|
|
187
|
+
if (typeof v === "number")
|
|
188
|
+
return Number.isFinite(v) ? v : null;
|
|
189
|
+
if (v === null || !isPlainNumber(v))
|
|
190
|
+
return null;
|
|
191
|
+
const n = Number(v);
|
|
192
|
+
return Number.isFinite(n) ? n : null;
|
|
193
|
+
}
|
|
194
|
+
/** Only facts that HOLD at t: a narration constraint also carries a destroyed
|
|
195
|
+
* entity's irreversible facts, which must neither answer a value nor make a
|
|
196
|
+
* name ambiguous. */
|
|
197
|
+
function indexFacts(facts, t) {
|
|
198
|
+
const values = new Map();
|
|
199
|
+
const idsByName = new Map();
|
|
200
|
+
for (const fact of facts) {
|
|
201
|
+
if (fact.validFromT > t || (fact.validToT !== null && fact.validToT <= t))
|
|
202
|
+
continue;
|
|
203
|
+
values.set(`${fact.entityId}\u0000${fact.key}`, fact.value);
|
|
204
|
+
if (fact.entityName !== null) {
|
|
205
|
+
const ids = idsByName.get(fact.entityName) ?? new Set();
|
|
206
|
+
ids.add(fact.entityId);
|
|
207
|
+
idsByName.set(fact.entityName, ids);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
value: (entityId, key) => values.get(`${entityId}\u0000${key}`) ?? null,
|
|
212
|
+
overlay: (entityId, key, value) => void values.set(`${entityId}\u0000${key}`, value),
|
|
213
|
+
byName: (name) => {
|
|
214
|
+
const ids = idsByName.get(name);
|
|
215
|
+
if (!ids || ids.size === 0)
|
|
216
|
+
return { entityId: null, resolution: "none" };
|
|
217
|
+
if (ids.size > 1)
|
|
218
|
+
return { entityId: null, resolution: "ambiguous" };
|
|
219
|
+
return { entityId: [...ids][0], resolution: "resolved" };
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function resolveEntity(entity, params, facts) {
|
|
224
|
+
if (typeof entity === "string")
|
|
225
|
+
return { entityId: entity, resolution: "resolved" };
|
|
226
|
+
if (isParamRef(entity))
|
|
227
|
+
return { entityId: param(params, entity.param, "string"), resolution: "resolved" };
|
|
228
|
+
return facts.byName(entity.named);
|
|
229
|
+
}
|
|
230
|
+
function compare(observed, op, declared) {
|
|
231
|
+
if (observed === null)
|
|
232
|
+
return false;
|
|
233
|
+
const a = asNumber(observed);
|
|
234
|
+
const b = asNumber(declared);
|
|
235
|
+
if (a !== null && b !== null) {
|
|
236
|
+
switch (op) {
|
|
237
|
+
case "<": return a < b;
|
|
238
|
+
case "<=": return a <= b;
|
|
239
|
+
case "==": return a === b;
|
|
240
|
+
case "!=": return a !== b;
|
|
241
|
+
case ">=": return a >= b;
|
|
242
|
+
case ">": return a > b;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Not both numbers: equality only, exact, no normalisation of any kind.
|
|
246
|
+
if (op === "==")
|
|
247
|
+
return observed === String(declared);
|
|
248
|
+
if (op === "!=")
|
|
249
|
+
return observed !== String(declared);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
/** Evaluates `roots` (default: every condition) and whatever they reference --
|
|
253
|
+
* lazily, so a gate never touches an unrelated condition, whose parameters
|
|
254
|
+
* the proposal has no reason to carry. */
|
|
255
|
+
function evaluateAll(rules, facts, params, roots) {
|
|
256
|
+
const byId = new Map(rules.conditions.map((c) => [c.id, c]));
|
|
257
|
+
const rows = new Map();
|
|
258
|
+
const evaluate = (id) => {
|
|
259
|
+
const done = rows.get(id);
|
|
260
|
+
if (done)
|
|
261
|
+
return done;
|
|
262
|
+
const condition = byId.get(id);
|
|
263
|
+
if (!condition)
|
|
264
|
+
throw new Error(`declared rules: unknown condition '${id}'`); // validated away; guarded, not asserted
|
|
265
|
+
const clauses = clausesOf(condition).map((clause) => {
|
|
266
|
+
if ("condition" in clause)
|
|
267
|
+
return { condition: clause.condition, holds: evaluate(clause.condition).holds };
|
|
268
|
+
// A parameter the proposal did not carry makes a row, not a throw: a
|
|
269
|
+
// condition list shows every condition, and only a gate that FAILS on
|
|
270
|
+
// one refuses (declaredMechanics, below).
|
|
271
|
+
const missing = [clause.entity, clause.value].find((o) => isParamRef(o) && params[o.param] === undefined);
|
|
272
|
+
if (missing) {
|
|
273
|
+
return { key: clause.key, op: clause.op, missingParameter: missing.param, ...(clause.text !== undefined ? { text: clause.text } : {}), holds: false };
|
|
274
|
+
}
|
|
275
|
+
const { entityId, resolution } = resolveEntity(clause.entity, params, facts);
|
|
276
|
+
const declared = isParamRef(clause.value) ? param(params, clause.value.param, "scalar") : clause.value;
|
|
277
|
+
const raw = entityId === null ? null : facts.value(entityId, clause.key);
|
|
278
|
+
const observedNumber = raw === null ? null : asNumber(raw);
|
|
279
|
+
return {
|
|
280
|
+
entityId,
|
|
281
|
+
resolution,
|
|
282
|
+
key: clause.key,
|
|
283
|
+
op: clause.op,
|
|
284
|
+
value: declared,
|
|
285
|
+
observed: raw === null ? null : (observedNumber ?? raw),
|
|
286
|
+
...(clause.text !== undefined ? { text: clause.text } : {}),
|
|
287
|
+
holds: compare(raw, clause.op, declared),
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
const holds = condition.all ? clauses.every((c) => c.holds) : clauses.some((c) => c.holds);
|
|
291
|
+
const row = {
|
|
292
|
+
id,
|
|
293
|
+
...(condition.for !== undefined ? { for: condition.for } : {}),
|
|
294
|
+
...(condition.then !== undefined ? { then: condition.then } : {}),
|
|
295
|
+
holds,
|
|
296
|
+
clauses,
|
|
297
|
+
};
|
|
298
|
+
rows.set(id, row);
|
|
299
|
+
return row;
|
|
300
|
+
};
|
|
301
|
+
for (const id of roots ?? rules.conditions.map((c) => c.id))
|
|
302
|
+
evaluate(id);
|
|
303
|
+
return rows;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Every declared condition at `t`, in declared order, with whether it holds
|
|
307
|
+
* and each clause's observed value -- or only the conditions whose `for`
|
|
308
|
+
* equals the one given, which is a principal's condition list. Read-only.
|
|
309
|
+
*/
|
|
310
|
+
export function evaluateConditions(params) {
|
|
311
|
+
validateDeclaredRules(params.rules);
|
|
312
|
+
assertT(params.t);
|
|
313
|
+
const facts = indexFacts(narrationConstraintAt({ gameId: params.gameId, t: params.t }).mustHonor, params.t);
|
|
314
|
+
const listed = params.rules.conditions.filter((c) => params.for === undefined || c.for === params.for);
|
|
315
|
+
const rows = evaluateAll(params.rules, facts, params.parameters ?? {}, listed.map((c) => c.id));
|
|
316
|
+
return listed.flatMap((c) => rows.get(c.id) ?? []);
|
|
317
|
+
}
|
|
318
|
+
// ============================================================================
|
|
319
|
+
// Mechanics -- ordinary `Mechanic`s built from the declaration
|
|
320
|
+
// ============================================================================
|
|
321
|
+
function legToChange(leg, params, facts) {
|
|
322
|
+
const { entityId } = resolveEntity(leg.entity, params, facts);
|
|
323
|
+
if (entityId === null) {
|
|
324
|
+
throw new Error(`declared rules: leg names entity ${JSON.stringify(leg.entity)}, which does not resolve to exactly one entity`);
|
|
325
|
+
}
|
|
326
|
+
const min = leg.min === undefined || leg.min === null ? null : numberOf(leg.min, params);
|
|
327
|
+
const max = leg.max === undefined || leg.max === null ? null : numberOf(leg.max, params);
|
|
328
|
+
const bounds = min !== null || max !== null ? { bounds: { minValue: min, maxValue: max } } : {};
|
|
329
|
+
const beforeRaw = facts.value(entityId, leg.key);
|
|
330
|
+
const recorded = (change, before, after) => {
|
|
331
|
+
// A later leg of the same mechanic on the same key reads this one's result.
|
|
332
|
+
facts.overlay(entityId, leg.key, after === null ? null : String(after));
|
|
333
|
+
return { change, record: { entityId, key: leg.key, before, after } };
|
|
334
|
+
};
|
|
335
|
+
if (leg.kind === "adjust") {
|
|
336
|
+
const before = beforeRaw === null ? null : asNumber(beforeRaw);
|
|
337
|
+
if (before === null) {
|
|
338
|
+
throw new Error(`declared rules: no numeric fact '${leg.key}' on entity '${entityId}' to adjust`);
|
|
339
|
+
}
|
|
340
|
+
let after = before + (leg.sign ?? 1) * numberOf(leg.amount, params);
|
|
341
|
+
if (min !== null)
|
|
342
|
+
after = Math.max(min, after);
|
|
343
|
+
if (max !== null)
|
|
344
|
+
after = Math.min(max, after);
|
|
345
|
+
return recorded({ kind: "write", entityId, key: leg.key, mode: "set", value: after, ...bounds }, before, after);
|
|
346
|
+
}
|
|
347
|
+
const value = isParamRef(leg.value) ? param(params, leg.value.param, "scalar") : leg.value;
|
|
348
|
+
const before = beforeRaw === null ? null : (asNumber(beforeRaw) ?? beforeRaw);
|
|
349
|
+
if (typeof value === "number") {
|
|
350
|
+
return recorded({ kind: "write", entityId, key: leg.key, mode: "set", value, ...bounds }, before, value);
|
|
351
|
+
}
|
|
352
|
+
return recorded({ kind: "set", entityId, key: leg.key, value }, before, value);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* One `Mechanic` per declared mechanic, for `createResolver` beside any
|
|
356
|
+
* hand-written ones. Each reads only the constraint `resolve()` hands it, like
|
|
357
|
+
* every mechanic, and returns ordinary changes that go through the one choke
|
|
358
|
+
* point. `result` records what it did: which gate conditions held, which leg
|
|
359
|
+
* set applied (`legs`, `otherwise`, or `none`), and each leg's before/after.
|
|
360
|
+
*/
|
|
361
|
+
export function declaredMechanics(rules) {
|
|
362
|
+
validateDeclaredRules(rules);
|
|
363
|
+
return rules.mechanics.map((declared) => ({
|
|
364
|
+
name: declared.name,
|
|
365
|
+
adjudicate(input) {
|
|
366
|
+
const params = input.parameters ?? {};
|
|
367
|
+
const facts = indexFacts(input.constraint.mustHonor, input.t);
|
|
368
|
+
const evaluated = evaluateAll(rules, facts, params, declared.when ?? []);
|
|
369
|
+
const when = (declared.when ?? []).map((id) => ({ id, holds: evaluated.get(id)?.holds === true }));
|
|
370
|
+
const gateHolds = when.every((w) => w.holds);
|
|
371
|
+
if (!gateHolds) {
|
|
372
|
+
// A gate that fails only because the proposal lacked a parameter is a
|
|
373
|
+
// malformed proposal, not a closed gate: refuse it, naming what is missing.
|
|
374
|
+
const missing = [...evaluated.values()].flatMap((row) => row.clauses).find((c) => c.missingParameter !== undefined);
|
|
375
|
+
if (missing) {
|
|
376
|
+
throw new Error(`declared rules: mechanic '${declared.name}' needs parameter '${missing.missingParameter}' to evaluate its gate`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const legs = gateHolds ? declared.legs : (declared.otherwise ?? []);
|
|
380
|
+
const applied = gateHolds ? "legs" : declared.otherwise && declared.otherwise.length > 0 ? "otherwise" : "none";
|
|
381
|
+
const built = legs.map((leg) => legToChange(leg, params, facts));
|
|
382
|
+
return {
|
|
383
|
+
changes: built.map((b) => b.change),
|
|
384
|
+
result: { applied, when, legs: built.map((b) => b.record) },
|
|
385
|
+
};
|
|
386
|
+
},
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
@@ -19,10 +19,23 @@ import type { IrreversibleFact } from "./irreversible.js";
|
|
|
19
19
|
* the read side here breaks that cycle before the choke point exists to hit
|
|
20
20
|
* it.
|
|
21
21
|
*
|
|
22
|
-
* Everything that WRITES `resource_constraints`
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* Everything that WRITES `resource_constraints` used to stay entirely in
|
|
23
|
+
* src/tools/constraint.ts -- insertConstraint() and the declare*()
|
|
24
|
+
* functions, and all the validation that goes with them. Issue #42 moved
|
|
25
|
+
* the one raw INSERT (insertConstraintRow() below) down here, because
|
|
26
|
+
* resolve() (src/timeline/resolve.ts) now also has to write this exact row,
|
|
27
|
+
* from INSIDE its own transaction, when a `create` leg declares
|
|
28
|
+
* `bounded`/`resolve_only`/`monotonic` on the entity it makes. Reaching
|
|
29
|
+
* from src/timeline/ back into src/tools/constraint.ts to get there would
|
|
30
|
+
* close the identical cycle this module's own doc comment (above) describes
|
|
31
|
+
* for the choke point: tools/resource.ts already imports tools/constraint.ts
|
|
32
|
+
* (indirectly, via getResource), and tools/constraint.ts would then import
|
|
33
|
+
* timeline/resolve.ts's writer, closing tools/* -> timeline/* -> tools/*.
|
|
34
|
+
* src/tools/constraint.ts's declare*() functions keep every piece of
|
|
35
|
+
* business validation they always had (game exists, resource exists, no
|
|
36
|
+
* duplicate, minValue/maxValue already set for 'bounded') and call
|
|
37
|
+
* insertConstraintRow() only once every check has passed -- they are not
|
|
38
|
+
* merged away, only pointed at the one place the row is actually written.
|
|
26
39
|
*/
|
|
27
40
|
/** Absolute tolerance for floating-point sum comparisons on 'conserved'
|
|
28
41
|
* constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
|
|
@@ -67,11 +80,36 @@ export interface ConstraintRow {
|
|
|
67
80
|
total: number | null;
|
|
68
81
|
fact_key: string;
|
|
69
82
|
created_at: string;
|
|
83
|
+
min_value: number | null;
|
|
84
|
+
max_value: number | null;
|
|
85
|
+
caused_by_event_id: string | null;
|
|
70
86
|
}
|
|
71
87
|
/** The resource ids belonging to a constraint, in insertion order. Exported
|
|
72
88
|
* alongside ConstraintRow/rowToConstraint for the same reason. */
|
|
73
89
|
export declare function memberIdsFor(constraintId: string): string[];
|
|
74
90
|
export declare function rowToConstraint(row: ConstraintRow): ResourceConstraint;
|
|
91
|
+
/**
|
|
92
|
+
* The one INSERT for `resource_constraints` (+ its members) -- see this
|
|
93
|
+
* module's own doc comment on why it lives here rather than in
|
|
94
|
+
* src/tools/constraint.ts. Every declare*Constraint() function there calls
|
|
95
|
+
* this only after its own business validation passes; resolve()'s create-leg
|
|
96
|
+
* declarations (src/timeline/resolve.ts, issue #42) call it directly, from
|
|
97
|
+
* inside their own transaction, after the leaner pre-transaction check that
|
|
98
|
+
* module runs (a declared key must be a live column -- see
|
|
99
|
+
* assertCreateConstraintKeysValid there). Either way this is the only
|
|
100
|
+
* `INSERT INTO resource_constraints` in the codebase.
|
|
101
|
+
*/
|
|
102
|
+
export declare function insertConstraintRow(params: {
|
|
103
|
+
gameId: string;
|
|
104
|
+
kind: ConstraintKind;
|
|
105
|
+
resourceIds: readonly string[];
|
|
106
|
+
direction?: MonotonicDirection | null;
|
|
107
|
+
total?: number | null;
|
|
108
|
+
factKey?: string;
|
|
109
|
+
minValue?: number | null;
|
|
110
|
+
maxValue?: number | null;
|
|
111
|
+
causedByEventId?: string | null;
|
|
112
|
+
}): ResourceConstraint;
|
|
75
113
|
/**
|
|
76
114
|
* Every constraint governing `(entityId, factKey)`, ordered by
|
|
77
115
|
* `created_at`. This is the whole point of Phase 3 step 1: a constraint
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
1
2
|
import { getDatabase } from "../db/connection.js";
|
|
2
3
|
/**
|
|
3
4
|
* The read side of the resource-constraint registry (design §5.3 / §5.4
|
|
@@ -18,10 +19,23 @@ import { getDatabase } from "../db/connection.js";
|
|
|
18
19
|
* the read side here breaks that cycle before the choke point exists to hit
|
|
19
20
|
* it.
|
|
20
21
|
*
|
|
21
|
-
* Everything that WRITES `resource_constraints`
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Everything that WRITES `resource_constraints` used to stay entirely in
|
|
23
|
+
* src/tools/constraint.ts -- insertConstraint() and the declare*()
|
|
24
|
+
* functions, and all the validation that goes with them. Issue #42 moved
|
|
25
|
+
* the one raw INSERT (insertConstraintRow() below) down here, because
|
|
26
|
+
* resolve() (src/timeline/resolve.ts) now also has to write this exact row,
|
|
27
|
+
* from INSIDE its own transaction, when a `create` leg declares
|
|
28
|
+
* `bounded`/`resolve_only`/`monotonic` on the entity it makes. Reaching
|
|
29
|
+
* from src/timeline/ back into src/tools/constraint.ts to get there would
|
|
30
|
+
* close the identical cycle this module's own doc comment (above) describes
|
|
31
|
+
* for the choke point: tools/resource.ts already imports tools/constraint.ts
|
|
32
|
+
* (indirectly, via getResource), and tools/constraint.ts would then import
|
|
33
|
+
* timeline/resolve.ts's writer, closing tools/* -> timeline/* -> tools/*.
|
|
34
|
+
* src/tools/constraint.ts's declare*() functions keep every piece of
|
|
35
|
+
* business validation they always had (game exists, resource exists, no
|
|
36
|
+
* duplicate, minValue/maxValue already set for 'bounded') and call
|
|
37
|
+
* insertConstraintRow() only once every check has passed -- they are not
|
|
38
|
+
* merged away, only pointed at the one place the row is actually written.
|
|
25
39
|
*/
|
|
26
40
|
/** Absolute tolerance for floating-point sum comparisons on 'conserved'
|
|
27
41
|
* constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
|
|
@@ -70,6 +84,50 @@ export function rowToConstraint(row) {
|
|
|
70
84
|
total: row.total,
|
|
71
85
|
factKey: row.fact_key,
|
|
72
86
|
createdAt: row.created_at,
|
|
87
|
+
minValue: row.min_value,
|
|
88
|
+
maxValue: row.max_value,
|
|
89
|
+
causedByEventId: row.caused_by_event_id,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The one INSERT for `resource_constraints` (+ its members) -- see this
|
|
94
|
+
* module's own doc comment on why it lives here rather than in
|
|
95
|
+
* src/tools/constraint.ts. Every declare*Constraint() function there calls
|
|
96
|
+
* this only after its own business validation passes; resolve()'s create-leg
|
|
97
|
+
* declarations (src/timeline/resolve.ts, issue #42) call it directly, from
|
|
98
|
+
* inside their own transaction, after the leaner pre-transaction check that
|
|
99
|
+
* module runs (a declared key must be a live column -- see
|
|
100
|
+
* assertCreateConstraintKeysValid there). Either way this is the only
|
|
101
|
+
* `INSERT INTO resource_constraints` in the codebase.
|
|
102
|
+
*/
|
|
103
|
+
export function insertConstraintRow(params) {
|
|
104
|
+
const db = getDatabase();
|
|
105
|
+
const id = uuidv4();
|
|
106
|
+
const createdAt = new Date().toISOString();
|
|
107
|
+
const direction = params.direction ?? null;
|
|
108
|
+
const total = params.total ?? null;
|
|
109
|
+
const factKey = params.factKey ?? "value";
|
|
110
|
+
const minValue = params.minValue ?? null;
|
|
111
|
+
const maxValue = params.maxValue ?? null;
|
|
112
|
+
const causedByEventId = params.causedByEventId ?? null;
|
|
113
|
+
db.prepare(`INSERT INTO resource_constraints (id, game_id, kind, direction, total, fact_key, created_at, min_value, max_value, caused_by_event_id)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.gameId, params.kind, direction, total, factKey, createdAt, minValue, maxValue, causedByEventId);
|
|
115
|
+
const memberStmt = db.prepare(`INSERT INTO resource_constraint_members (constraint_id, resource_id) VALUES (?, ?)`);
|
|
116
|
+
for (const resourceId of params.resourceIds) {
|
|
117
|
+
memberStmt.run(id, resourceId);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
gameId: params.gameId,
|
|
122
|
+
kind: params.kind,
|
|
123
|
+
resourceIds: [...params.resourceIds],
|
|
124
|
+
direction,
|
|
125
|
+
total,
|
|
126
|
+
factKey,
|
|
127
|
+
createdAt,
|
|
128
|
+
minValue,
|
|
129
|
+
maxValue,
|
|
130
|
+
causedByEventId,
|
|
73
131
|
};
|
|
74
132
|
}
|
|
75
133
|
/**
|
|
@@ -97,9 +97,12 @@ export interface RenderedState {
|
|
|
97
97
|
unnamed: UnnamedFact[];
|
|
98
98
|
}
|
|
99
99
|
export interface StateRenderer {
|
|
100
|
+
/** `entityIds` scopes the render to a caller's selection (issue #18), with
|
|
101
|
+
* replay()'s semantics: omitted is unscoped, `[]` renders nothing. */
|
|
100
102
|
render(params: {
|
|
101
103
|
gameId: string;
|
|
102
104
|
t: T;
|
|
105
|
+
entityIds?: readonly string[];
|
|
103
106
|
}): RenderedState;
|
|
104
107
|
}
|
|
105
108
|
/**
|
package/dist/timeline/render.js
CHANGED
|
@@ -125,7 +125,7 @@ function composePhrase(entry) {
|
|
|
125
125
|
* uses.
|
|
126
126
|
*/
|
|
127
127
|
function renderState(vocabulary, params) {
|
|
128
|
-
const snapshot = replay({ gameId: params.gameId, t: params.t });
|
|
128
|
+
const snapshot = replay({ gameId: params.gameId, t: params.t, entityIds: params.entityIds });
|
|
129
129
|
const nouns = [];
|
|
130
130
|
const unnamed = [];
|
|
131
131
|
for (const entity of snapshot.entities) {
|
|
@@ -60,6 +60,20 @@ export interface Snapshot {
|
|
|
60
60
|
*
|
|
61
61
|
* DECISION(#18): replay() applies no visibility filtering, by decision rather than omission.
|
|
62
62
|
*
|
|
63
|
+
* DECISION(#18): a per-principal view is the caller's selection; the engine scopes a read to it (entityIds), and holds no principal relation.
|
|
64
|
+
*
|
|
65
|
+
* SCOPED, NOT FILTERED (issue #18, decided 2026-09-26). `entityIds` restricts
|
|
66
|
+
* the snapshot to a selection the CALLER built -- which entities a principal
|
|
67
|
+
* perceives -- and a scoped snapshot is exactly the unscoped one restricted
|
|
68
|
+
* to those entities, never a different answer. The first real caller's
|
|
69
|
+
* measurement settled where the line falls: both of its view builders select
|
|
70
|
+
* positively and subtract nothing, from game-specific predicates (a
|
|
71
|
+
* concealment threshold, containers, shared location) the engine has no
|
|
72
|
+
* business owning. What it could not do was ask for the selection, so it
|
|
73
|
+
* read the omniscient world and picked from it. The principal relation stays
|
|
74
|
+
* in the caller; the paragraphs below about omniscience still describe what
|
|
75
|
+
* an unscoped read returns.
|
|
76
|
+
*
|
|
63
77
|
* OMNISCIENT, DELIBERATELY (issue #18). This returns every fact valid at
|
|
64
78
|
* `t`, for every entity alive at `t`, with no visibility filtering of any
|
|
65
79
|
* kind. That is a decision, not an omission: per-principal visibility is
|
|
@@ -83,4 +97,5 @@ export interface Snapshot {
|
|
|
83
97
|
export declare function replay(params: {
|
|
84
98
|
gameId: string;
|
|
85
99
|
t: T;
|
|
100
|
+
entityIds?: readonly string[];
|
|
86
101
|
}): Snapshot;
|
package/dist/timeline/replay.js
CHANGED
|
@@ -39,6 +39,20 @@ const ALIVE_AT_T = "e.created_at_t <= ? AND (e.destroyed_at_t IS NULL OR e.destr
|
|
|
39
39
|
*
|
|
40
40
|
* DECISION(#18): replay() applies no visibility filtering, by decision rather than omission.
|
|
41
41
|
*
|
|
42
|
+
* DECISION(#18): a per-principal view is the caller's selection; the engine scopes a read to it (entityIds), and holds no principal relation.
|
|
43
|
+
*
|
|
44
|
+
* SCOPED, NOT FILTERED (issue #18, decided 2026-09-26). `entityIds` restricts
|
|
45
|
+
* the snapshot to a selection the CALLER built -- which entities a principal
|
|
46
|
+
* perceives -- and a scoped snapshot is exactly the unscoped one restricted
|
|
47
|
+
* to those entities, never a different answer. The first real caller's
|
|
48
|
+
* measurement settled where the line falls: both of its view builders select
|
|
49
|
+
* positively and subtract nothing, from game-specific predicates (a
|
|
50
|
+
* concealment threshold, containers, shared location) the engine has no
|
|
51
|
+
* business owning. What it could not do was ask for the selection, so it
|
|
52
|
+
* read the omniscient world and picked from it. The principal relation stays
|
|
53
|
+
* in the caller; the paragraphs below about omniscience still describe what
|
|
54
|
+
* an unscoped read returns.
|
|
55
|
+
*
|
|
42
56
|
* OMNISCIENT, DELIBERATELY (issue #18). This returns every fact valid at
|
|
43
57
|
* `t`, for every entity alive at `t`, with no visibility filtering of any
|
|
44
58
|
* kind. That is a decision, not an omission: per-principal visibility is
|
|
@@ -65,6 +79,21 @@ export function replay(params) {
|
|
|
65
79
|
// it can silently compare unequal to every row and produce a confidently
|
|
66
80
|
// wrong empty snapshot.
|
|
67
81
|
assertT(t);
|
|
82
|
+
// Scope (issue #18): the snapshot restricted to a caller's selection. Bound
|
|
83
|
+
// as ONE JSON array through `json_each`, so the SQL text stays fixed and
|
|
84
|
+
// the bind count stays constant whatever the list's length -- the same two
|
|
85
|
+
// properties the paragraph above keeps for the unscoped read. Omitted is
|
|
86
|
+
// unscoped; `[]` narrows to nothing, as narrationConstraintAt's does.
|
|
87
|
+
const entityIds = params.entityIds;
|
|
88
|
+
if (entityIds !== undefined) {
|
|
89
|
+
if (!Array.isArray(entityIds) || entityIds.some((id) => typeof id !== "string")) {
|
|
90
|
+
throw new Error(`replay: entityIds must be a list of entity id strings, got ${JSON.stringify(entityIds)}`);
|
|
91
|
+
}
|
|
92
|
+
if (entityIds.length === 0)
|
|
93
|
+
return { gameId, t, entities: [] };
|
|
94
|
+
}
|
|
95
|
+
const scope = entityIds === undefined ? null : JSON.stringify(entityIds);
|
|
96
|
+
const SCOPED = "(? IS NULL OR e.id IN (SELECT value FROM json_each(?)))";
|
|
68
97
|
const db = getDatabase();
|
|
69
98
|
// Query 1 of 2: which entities were alive at t, in the caller-diffable
|
|
70
99
|
// order the design calls for (created_at_t, then id -- id as the
|
|
@@ -75,8 +104,9 @@ export function replay(params) {
|
|
|
75
104
|
FROM entities e
|
|
76
105
|
WHERE e.game_id = ?
|
|
77
106
|
AND ${ALIVE_AT_T}
|
|
107
|
+
AND ${SCOPED}
|
|
78
108
|
ORDER BY e.created_at_t, e.id`)
|
|
79
|
-
.all(gameId, t, t);
|
|
109
|
+
.all(gameId, t, t, scope, scope);
|
|
80
110
|
const entities = entityRows.map((row) => ({
|
|
81
111
|
id: row.id,
|
|
82
112
|
kind: row.kind,
|
|
@@ -107,10 +137,11 @@ export function replay(params) {
|
|
|
107
137
|
JOIN entities e ON e.id = f.entity_id
|
|
108
138
|
WHERE e.game_id = ?
|
|
109
139
|
AND ${ALIVE_AT_T}
|
|
140
|
+
AND ${SCOPED}
|
|
110
141
|
AND f.valid_from_t <= ?
|
|
111
142
|
AND (f.valid_to_t IS NULL OR f.valid_to_t > ?)
|
|
112
143
|
ORDER BY f.valid_from_t`)
|
|
113
|
-
.all(gameId, t, t, t, t);
|
|
144
|
+
.all(gameId, t, t, scope, scope, t, t);
|
|
114
145
|
for (const factRow of factRows) {
|
|
115
146
|
const entity = byId.get(factRow.entity_id);
|
|
116
147
|
// Cannot happen: every fact row came from a JOIN against entities e
|