run-dmcp 0.9.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 +4 -3
- package/dist/index.d.ts +6 -2
- 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/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 +13 -3
- package/dist/timeline/resolve.js +34 -3
- package/dist/timeline/schema.js +28 -0
- package/dist/timeline/turns.d.ts +60 -0
- package/dist/timeline/turns.js +93 -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
|
+
}
|
|
@@ -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
|
|
@@ -92,8 +92,7 @@ import { type ValueTransition, type SetTransition, type CreatedEntity, type Dest
|
|
|
92
92
|
* mechanic's name, and the change count -- following
|
|
93
93
|
* `applyLiveWrite`'s (constrained.ts) `causes` discipline of never
|
|
94
94
|
* including a `row_id` key, which belongs to the projection triggers'
|
|
95
|
-
* own vocabulary
|
|
96
|
-
* would make `findOpenedByEventId`'s pick non-deterministic).
|
|
95
|
+
* own vocabulary.
|
|
97
96
|
* 7. Build the outcome's constraint AFTER the writes have landed --
|
|
98
97
|
* re-reading `currentStoryTime` inside the same transaction, after
|
|
99
98
|
* every change has been applied, so a `sequence`-axis game (whose `t`
|
|
@@ -125,6 +124,13 @@ export interface Proposal {
|
|
|
125
124
|
mechanic: string;
|
|
126
125
|
parameters?: Record<string, unknown>;
|
|
127
126
|
expects?: readonly Expectation[];
|
|
127
|
+
/** The principal proposing (issue #40): an entity id. When the game has
|
|
128
|
+
* declared any turn order, a proposal whose actor is not the one due at
|
|
129
|
+
* the current t -- including a t no declaration covers yet -- is refused
|
|
130
|
+
* `out-of-turn` before dispatch. Omitted, the
|
|
131
|
+
* proposal is the world's own (time passing, a scheduled consequence) and
|
|
132
|
+
* is not a turn. Recorded in the resolution's causes either way. */
|
|
133
|
+
actor?: string;
|
|
128
134
|
}
|
|
129
135
|
/**
|
|
130
136
|
* What a `Mechanic`'s `adjudicate` receives -- and ALL it receives. There is
|
|
@@ -329,7 +335,11 @@ export interface Mechanic {
|
|
|
329
335
|
* under -- never a judgement about the proposal, the mechanic, or the
|
|
330
336
|
* world (see the module doc comment's "records decisions, does not make
|
|
331
337
|
* them" paragraph). */
|
|
332
|
-
export type ResolveRefusalReason = "unknown-mechanic" | "no-clock"
|
|
338
|
+
export type ResolveRefusalReason = "unknown-mechanic" | "no-clock"
|
|
339
|
+
/** The proposal named an `actor`, the game has declared a turn order, and
|
|
340
|
+
* that actor is not the one due at the current t (issue #40). Refused
|
|
341
|
+
* before dispatch. */
|
|
342
|
+
| "out-of-turn" | "expectation-contradicted"
|
|
333
343
|
/** A leg said `{ ref }` and no earlier `create` leg of the same
|
|
334
344
|
* resolution defined that ref (issue #34). Refused before any write. */
|
|
335
345
|
| "unresolved-ref"
|
package/dist/timeline/resolve.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from "uuid";
|
|
2
2
|
import { getDatabase, withTransaction } from "../db/connection.js";
|
|
3
3
|
import { currentStoryTime } from "./clock.js";
|
|
4
|
+
import { dueAt, hasTurnOrder } from "./turns.js";
|
|
4
5
|
import { narrationConstraintAt, contradictions } from "./narration.js";
|
|
5
6
|
import { withAdjudicationOpen } from "./adjudication.js";
|
|
6
7
|
import { PROJECTED_TABLES, liveColumns } from "./projection.js";
|
|
7
|
-
import { insertConstraintRow } from "./registry.js";
|
|
8
|
+
import { insertConstraintRow, constraintsFor } from "./registry.js";
|
|
8
9
|
import { writeConstrainedValue, transferConstrainedValue, setProjectedValue, createProjectedEntity, destroyProjectedEntity, } from "./constrained.js";
|
|
9
10
|
/**
|
|
10
11
|
* Refused before dispatch, before any write, or (never, by construction --
|
|
@@ -102,6 +103,24 @@ function refsUsedBy(change) {
|
|
|
102
103
|
return [];
|
|
103
104
|
}
|
|
104
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* A write leg that supplies no `bounds`, to a key a `bounded` constraint
|
|
108
|
+
* governs on a resource, is held to that resource's own min/max -- exactly
|
|
109
|
+
* the bounds `update_resource_value` supplies for the same write
|
|
110
|
+
* (tools/resource.ts). Without this, a resolution could write straight past a
|
|
111
|
+
* bound the tool path refuses, because the choke point checks `bounded`
|
|
112
|
+
* against the bounds a writer hands it and this writer handed none. Found by
|
|
113
|
+
* the 2026-09-26 review, through a declared mechanic (#41) with no min/max;
|
|
114
|
+
* a hand-written mechanic had the same hole. Nothing changes for a key with
|
|
115
|
+
* no `bounded` constraint: supplying bounds there would start CLAMPING writes
|
|
116
|
+
* that land outside min/max today.
|
|
117
|
+
*/
|
|
118
|
+
function boundedResourceDefault(entityId, key) {
|
|
119
|
+
if (key !== "value" || !constraintsFor(entityId, key).some((c) => c.kind === "bounded"))
|
|
120
|
+
return undefined;
|
|
121
|
+
const row = getDatabase().prepare(`SELECT min_value, max_value FROM resources WHERE id = ?`).get(entityId);
|
|
122
|
+
return row ? { minValue: row.min_value, maxValue: row.max_value } : undefined;
|
|
123
|
+
}
|
|
105
124
|
/**
|
|
106
125
|
* Step 5's precondition (issue #34): every `{ ref }` names a `create` leg
|
|
107
126
|
* EARLIER in the list, and no two creates share a ref. Checked over the
|
|
@@ -226,15 +245,16 @@ function applyChange(change, gameId, refs) {
|
|
|
226
245
|
return { set: setProjectedValue({ entityId: deref(change.entityId, refs), key: change.key, value: derefValue(change.value, refs) }) };
|
|
227
246
|
}
|
|
228
247
|
if (change.kind === "write") {
|
|
248
|
+
const entityId = deref(change.entityId, refs);
|
|
229
249
|
return {
|
|
230
250
|
transitions: [
|
|
231
251
|
writeConstrainedValue({
|
|
232
|
-
entityId
|
|
252
|
+
entityId,
|
|
233
253
|
key: change.key,
|
|
234
254
|
mode: change.mode,
|
|
235
255
|
value: change.value,
|
|
236
256
|
reason: change.reason,
|
|
237
|
-
bounds: change.bounds,
|
|
257
|
+
bounds: change.bounds ?? boundedResourceDefault(entityId, change.key),
|
|
238
258
|
}),
|
|
239
259
|
],
|
|
240
260
|
};
|
|
@@ -293,6 +313,16 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
293
313
|
`something through the normal tools first, then propose again.`);
|
|
294
314
|
}
|
|
295
315
|
const t = preStory.t;
|
|
316
|
+
// 2b. Turns (issue #40): an actor who is not due is refused before the
|
|
317
|
+
// mechanic ever sees the proposal. No actor -> not a turn -> no check.
|
|
318
|
+
if (proposal.actor !== undefined && hasTurnOrder(gameId)) {
|
|
319
|
+
const due = dueAt({ gameId, t });
|
|
320
|
+
if (!due || due.principal !== proposal.actor) {
|
|
321
|
+
throw new ResolveProtocolError("out-of-turn", `resolve: '${proposal.actor}' proposed at t=${t}, but ` +
|
|
322
|
+
(due ? `'${due.principal}' is due at t=${t}` : `nobody is due at t=${t}`) +
|
|
323
|
+
` under this game's declared turn order. Ask who is due (due_at) or move to the next turn (advance_turn).`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
296
326
|
// 3. ONE query builds both the mechanic's read surface AND the inbound
|
|
297
327
|
// precondition check -- see the module doc comment's unification note on
|
|
298
328
|
// why this is the same structure read in two directions, not two
|
|
@@ -391,6 +421,7 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
391
421
|
resolution_id: resolutionId,
|
|
392
422
|
mechanic: mechanicName,
|
|
393
423
|
change_count: changes.length,
|
|
424
|
+
...(proposal.actor !== undefined ? { actor: proposal.actor } : {}),
|
|
394
425
|
};
|
|
395
426
|
getDatabase()
|
|
396
427
|
.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'resolution.recorded', ?, ?)`)
|
package/dist/timeline/schema.js
CHANGED
|
@@ -282,6 +282,34 @@ export function initializeTimelineSchema() {
|
|
|
282
282
|
BEGIN
|
|
283
283
|
SELECT RAISE(ABORT, 'timeline: an irreversible fact holds for key ''' || NEW.key || ''' on this entity as of its valid_from_t; a contradicting value is refused from that point onward');
|
|
284
284
|
END;
|
|
285
|
+
`);
|
|
286
|
+
// Issue #40: a game's declared turn order -- which entities act in what
|
|
287
|
+
// cycle, from a `from_t` on. Append-only like the timeline it answers for:
|
|
288
|
+
// `dueAt(t)` for a past t must give the same answer forever, so a
|
|
289
|
+
// declaration is never edited or removed, only superseded by a later one
|
|
290
|
+
// from a later `from_t` (turns.ts refuses anything else before insert).
|
|
291
|
+
db.exec(`
|
|
292
|
+
CREATE TABLE IF NOT EXISTS turn_orders (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
game_id TEXT NOT NULL,
|
|
295
|
+
from_t REAL NOT NULL,
|
|
296
|
+
principals TEXT NOT NULL,
|
|
297
|
+
declared_at TEXT NOT NULL,
|
|
298
|
+
UNIQUE (game_id, from_t)
|
|
299
|
+
);
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_turn_orders_game_from ON turn_orders(game_id, from_t);
|
|
301
|
+
DROP TRIGGER IF EXISTS timeline_turn_orders_immutable;
|
|
302
|
+
CREATE TRIGGER timeline_turn_orders_immutable
|
|
303
|
+
BEFORE UPDATE ON turn_orders
|
|
304
|
+
BEGIN
|
|
305
|
+
SELECT RAISE(ABORT, 'timeline: turn orders are append-only; a declaration is superseded, never edited');
|
|
306
|
+
END;
|
|
307
|
+
DROP TRIGGER IF EXISTS timeline_turn_orders_no_delete;
|
|
308
|
+
CREATE TRIGGER timeline_turn_orders_no_delete
|
|
309
|
+
BEFORE DELETE ON turn_orders
|
|
310
|
+
BEGIN
|
|
311
|
+
SELECT RAISE(ABORT, 'timeline: turn orders are append-only; a declaration is superseded, never deleted');
|
|
312
|
+
END;
|
|
285
313
|
`);
|
|
286
314
|
// Issue #2: the projection layer. Triggers first, so every write from
|
|
287
315
|
// here on appends by construction; reconciliation second, so it backfills
|