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.
@@ -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
  /**
@@ -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;
@@ -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 (see that function's comment on why colliding with it
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" | "expectation-contradicted"
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"
@@ -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: deref(change.entityId, refs),
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', ?, ?)`)
@@ -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