@volter/world-core 3.0.166 → 3.0.167
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/src/changeset.d.ts +9 -0
- package/dist/src/changeset.js +5 -2
- package/dist/src/completeness.d.ts +36 -0
- package/dist/src/completeness.js +46 -0
- package/dist/src/derived-core.d.ts +27 -0
- package/dist/src/derived-core.js +31 -2
- package/dist/src/events.d.ts +6 -1
- package/dist/src/events.js +58 -13
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +3 -2
- package/dist/src/openai-wire.js +0 -1
- package/dist/src/pack-fetch.d.ts +1 -3
- package/dist/src/pack-fetch.js +103 -43
- package/dist/src/runtime.d.ts +5 -2
- package/dist/src/runtime.js +3 -1
- package/dist/src/scenario-consume.d.ts +43 -0
- package/dist/src/scenario-consume.js +111 -0
- package/dist/src/scenario-serve.d.ts +16 -0
- package/dist/src/scenario-serve.js +47 -0
- package/dist/src/scenario-timeline.d.ts +131 -0
- package/dist/src/scenario-timeline.js +242 -0
- package/dist/src/scenario.d.ts +15 -93
- package/dist/src/scenario.js +34 -323
- package/dist/src/sockets.d.ts +8 -4
- package/dist/src/sockets.js +1 -1
- package/dist/src/world-clock.d.ts +3 -0
- package/dist/src/world-clock.js +14 -9
- package/package.json +1 -1
- package/src/changeset.ts +13 -3
- package/src/completeness.ts +57 -0
- package/src/derived-core.ts +41 -2
- package/src/events.ts +58 -11
- package/src/index.ts +5 -3
- package/src/openai-wire.ts +0 -1
- package/src/pack-fetch.ts +81 -39
- package/src/runtime.ts +5 -2
- package/src/scenario-consume.ts +123 -0
- package/src/scenario-serve.ts +47 -0
- package/src/scenario-timeline.ts +269 -0
- package/src/scenario.ts +43 -341
- package/src/sockets.ts +5 -5
- package/src/world-clock.ts +16 -9
package/src/scenario.ts
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// A model vendor's turn, matched with the pack's own vocabulary (architecture, "Scenarios"; ADR 0036). The World's
|
|
2
|
+
// scenario places turn decisions and faults on a timeline (scenario-timeline.ts); scenario-serve.ts hands the ones not
|
|
3
|
+
// used up to this engine as ordered rules `{ on, respond | fault }`, and the first whose `on` conditions ALL hold, and
|
|
4
|
+
// whose answer the request admits, decides the turn. No match is the miss: the pack's labelled deterministic stub.
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// the only write surface; `engine.use(...)` exists for in-process tests only (LIFO over the
|
|
10
|
-
// baseline, removable). There are NO runtime write doors — a running world is never mutated.
|
|
11
|
-
//
|
|
12
|
-
// The GRAMMAR (structure, ordering, once/scope/phase, strict validation, extractors,
|
|
13
|
-
// placeholders, miss records) is this module's and identical for every vendor. The
|
|
14
|
-
// VOCABULARY (which `on` keys exist and how each matches; what `respond` may contain; which
|
|
15
|
-
// routes are stateful and therefore refuse success-shaped handlers) is the pack's, declared
|
|
16
|
-
// through a PackScenarioAdapter. Determinism: the engine is a pure state machine — same
|
|
17
|
-
// handler list + same request sequence → same decisions, byte for byte.
|
|
18
|
-
//
|
|
19
|
-
// STRICT EVERYWHERE (the gemini discipline): unknown top-level keys, unknown `on` keys,
|
|
20
|
-
// unknown placeholder names, malformed extractors — all THROW with the valid vocabulary in
|
|
21
|
-
// the message. A typo must fail loudly at load, never silently mis-match at serve.
|
|
6
|
+
// The GRAMMAR (strict validation, ordering, the fault vocabulary) is this module's and the same for every vendor; the
|
|
7
|
+
// VOCABULARY (which `on` keys exist and how each matches, what `respond` may contain, which routes are stateful and so
|
|
8
|
+
// refuse success-shaped answers) is the pack's, declared through a PackScenarioAdapter. A typo fails loudly at load,
|
|
9
|
+
// naming the valid vocabulary, never silently mis-matches at serve.
|
|
22
10
|
|
|
23
11
|
/** A vendor-agnostic bag of facts about one request, produced by the pack's adapter. Powers
|
|
24
12
|
* matching context, miss records (the authoring signal), and the self-teaching stub text. */
|
|
@@ -36,8 +24,8 @@ export type PackScenarioAdapter<Req> = {
|
|
|
36
24
|
/** The legal `on` keys and their per-request semantics. Pure. */
|
|
37
25
|
matchers: Record<string, ScenarioMatcher<Req>>;
|
|
38
26
|
/** Validate `on` CONDITION VALUES at load (key membership is the kernel's; VALUE typing is
|
|
39
|
-
* the pack's —
|
|
40
|
-
*
|
|
27
|
+
* the pack's — a mistyped value silently never matching is the misfire strict loading exists
|
|
28
|
+
* to prevent). Return an error string to refuse. */
|
|
41
29
|
validateOn?: (on: Record<string, unknown>) => string | null;
|
|
42
30
|
/** Validate a handler's `respond` payload at load; return an error string to refuse.
|
|
43
31
|
* This is ALSO where a pack refuses success-shaped handlers on stateful routes
|
|
@@ -46,22 +34,10 @@ export type PackScenarioAdapter<Req> = {
|
|
|
46
34
|
/** Whether the request admits a handler's answer (OpenAI's `tool_choice: 'none'` admits no tool call): a handler whose
|
|
47
35
|
* answer it does not admit is passed over, and the next that matches answers, else the stub. Absent, every answer. */
|
|
48
36
|
admits?: (req: Req, respond: unknown) => boolean;
|
|
49
|
-
/** The request's text corpus for `textPattern` extractors (packs with text requests). */
|
|
50
|
-
text?: (req: Req) => string;
|
|
51
|
-
/** Pack-defined extractor KINDS beyond the builtins (feature, textPattern). A pack kind with
|
|
52
|
-
* a builtin's name OVERRIDES the builtin (e.g. a richer textPattern with flags/group).
|
|
53
|
-
* validate returns an error string to refuse the spec at load; extract runs at serve time
|
|
54
|
-
* and throws ScenarioError when the request cannot supply the value. */
|
|
55
|
-
extractorKinds?: Record<string, {
|
|
56
|
-
validate: (spec: Record<string, unknown>, name: string) => string | null;
|
|
57
|
-
extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number;
|
|
58
|
-
}>;
|
|
59
|
-
/** The per-session scope discriminator; omitted → all requests share one "world" scope. */
|
|
60
|
-
scopeKey?: (req: Req) => string;
|
|
61
|
-
/** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
|
|
62
|
-
* vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
|
|
63
37
|
/** Opt-in scope check for post-admission model generation failures; absent rejects them. */
|
|
64
38
|
validateGenerationFault?: (fault: ScenarioGenerationFault, handler: ScenarioHandler) => string | null;
|
|
39
|
+
/** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
|
|
40
|
+
* vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
|
|
65
41
|
renderFault?: (fault: ScenarioStatusFault, req: Req) => { body: unknown; headers?: Record<string, string> };
|
|
66
42
|
};
|
|
67
43
|
|
|
@@ -88,344 +64,72 @@ export type ScenarioStatusFault = Extract<ScenarioFault, { kind: "status" }>;
|
|
|
88
64
|
export type ScenarioFaultResult = { status: number; headers: Record<string, string>; body: unknown };
|
|
89
65
|
|
|
90
66
|
export type ScenarioHandler = {
|
|
91
|
-
/**
|
|
67
|
+
/** The rule's id: `entry-<n>`, the timeline entry it came from. */
|
|
92
68
|
id?: string;
|
|
93
|
-
/** Conditions — ALL must hold. `{}` matches every request
|
|
69
|
+
/** Conditions — ALL must hold. `{}` matches every request. */
|
|
94
70
|
on: Record<string, unknown>;
|
|
95
|
-
/**
|
|
96
|
-
* Optional only on a handler that carries a `fault` (a `slow` may still carry one; a
|
|
97
|
-
* `status` or `drop` serves no content, so it must not). */
|
|
71
|
+
/** The turn's answer, which the pack realizes into the vendor's envelope; absent on a fault. */
|
|
98
72
|
respond?: unknown;
|
|
99
|
-
/**
|
|
100
|
-
* semantics as any handler — a mid-story outage is `{on, fault, once}` or `{phase}`. */
|
|
73
|
+
/** The fault that answers the turn instead (ScenarioFault). */
|
|
101
74
|
fault?: ScenarioFault;
|
|
102
|
-
/** Fire at most once per scope. */
|
|
103
|
-
once?: boolean;
|
|
104
|
-
/** Reserved: "world" (default) | "session" — with "session", once/phase state is per
|
|
105
|
-
* scopeKey instead of shared. */
|
|
106
|
-
scope?: "world" | "session";
|
|
107
|
-
/** Fires only while the scope's phase equals this. Handlers without `phase` fire in any. */
|
|
108
|
-
phase?: string;
|
|
109
|
-
/** On fire, move the scope's phase — the tiny sequencing primitive that replaces
|
|
110
|
-
* linear scripts and nthCall arithmetic. */
|
|
111
|
-
advancePhase?: string;
|
|
112
75
|
};
|
|
113
76
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
* numeric transforms `{{name|min:N}}` / `{{name|max:N}}`; a whole-string placeholder yields
|
|
117
|
-
* the TYPED value (numbers stay numbers). */
|
|
118
|
-
export type ScenarioExtractorSpec =
|
|
119
|
-
| { kind: "feature"; feature: string }
|
|
120
|
-
| { kind: "textPattern"; pattern: string; as?: "string" | "number" }
|
|
121
|
-
| ({ kind: string } & Record<string, unknown>);
|
|
122
|
-
|
|
123
|
-
export type ScenarioDocument = {
|
|
124
|
-
extractors?: Record<string, ScenarioExtractorSpec>;
|
|
125
|
-
handlers: ScenarioHandler[];
|
|
126
|
-
};
|
|
77
|
+
/** The ordered rules of one turn. */
|
|
78
|
+
export type ScenarioDocument = { handlers: ScenarioHandler[] };
|
|
127
79
|
|
|
128
|
-
export type ScenarioMissRecord = { features: ScenarioFeatures
|
|
80
|
+
export type ScenarioMissRecord = { features: ScenarioFeatures };
|
|
129
81
|
|
|
130
82
|
export type ScenarioDecision =
|
|
131
83
|
| { kind: "handler"; handler: ScenarioHandler; respond: unknown; ruleId: string; fault?: ScenarioFault }
|
|
132
84
|
| { kind: "miss"; miss: ScenarioMissRecord };
|
|
133
85
|
|
|
134
|
-
export type ScenarioStatus = {
|
|
135
|
-
vendor: string;
|
|
136
|
-
handlers: Array<{ id: string; phase?: string; once?: boolean; scope?: string; matches: number; source: "file" | "use" }>;
|
|
137
|
-
misses: number;
|
|
138
|
-
recentMisses: ScenarioMissRecord[];
|
|
139
|
-
};
|
|
140
|
-
|
|
141
86
|
export class ScenarioError extends Error {}
|
|
142
87
|
|
|
143
|
-
|
|
144
|
-
// and scenario files are hand-authored story documents.
|
|
145
|
-
const HANDLER_KEYS = new Set(["id", "on", "respond", "fault", "once", "scope", "phase", "advancePhase", "$comment"]);
|
|
146
|
-
const DOCUMENT_KEYS = new Set(["extractors", "handlers", "$comment"]);
|
|
88
|
+
const HANDLER_KEYS = new Set(["id", "on", "respond", "fault"]);
|
|
147
89
|
|
|
148
|
-
/** Strict-loud parse of a
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ScenarioError(`${adapter.vendor} scenario: the document is an object { extractors?, handlers }`);
|
|
152
|
-
const doc = raw as Record<string, unknown>;
|
|
153
|
-
for (const key of Object.keys(doc)) {
|
|
154
|
-
if (!DOCUMENT_KEYS.has(key)) throw new ScenarioError(`${adapter.vendor} scenario: unknown key "${key}" (valid: ${[...DOCUMENT_KEYS].join(", ")})`);
|
|
155
|
-
}
|
|
156
|
-
const extractors: Record<string, ScenarioExtractorSpec> = {};
|
|
157
|
-
if (doc.extractors !== undefined) {
|
|
158
|
-
if (typeof doc.extractors !== "object" || doc.extractors === null || Array.isArray(doc.extractors)) throw new ScenarioError(`${adapter.vendor} scenario: extractors is an object of named specs`);
|
|
159
|
-
for (const [name, spec] of Object.entries(doc.extractors as Record<string, unknown>)) {
|
|
160
|
-
extractors[name] = parseExtractor(name, spec, adapter);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (!Array.isArray(doc.handlers)) throw new ScenarioError(`${adapter.vendor} scenario: handlers is an array`);
|
|
164
|
-
const handlers = (doc.handlers as unknown[]).map((h, i) => parseHandler(h, i, adapter, extractors));
|
|
165
|
-
return { ...(doc.extractors !== undefined ? { extractors } : {}), handlers };
|
|
90
|
+
/** Strict-loud parse of a turn's rules. Every refusal names the valid vocabulary. */
|
|
91
|
+
export function parseScenarioDocument<Req>(raw: ScenarioDocument, adapter: PackScenarioAdapter<Req>): ScenarioDocument {
|
|
92
|
+
return { handlers: raw.handlers.map((h, i) => parseHandler(h, i, adapter)) };
|
|
166
93
|
}
|
|
167
94
|
|
|
168
|
-
function
|
|
169
|
-
if (typeof raw !== "object" || raw === null) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" is an object`);
|
|
170
|
-
const spec = raw as Record<string, unknown>;
|
|
171
|
-
const packKind = typeof spec.kind === "string" ? adapter.extractorKinds?.[spec.kind] : undefined;
|
|
172
|
-
if (packKind !== undefined) {
|
|
173
|
-
const refusal = packKind.validate(spec, name);
|
|
174
|
-
if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": ${refusal}`);
|
|
175
|
-
return spec as ScenarioExtractorSpec;
|
|
176
|
-
}
|
|
177
|
-
if (spec.kind === "feature") {
|
|
178
|
-
if (typeof spec.feature !== "string" || spec.feature.length === 0) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (feature) needs a feature name`);
|
|
179
|
-
for (const key of Object.keys(spec)) if (key !== "kind" && key !== "feature") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
|
|
180
|
-
return { kind: "feature", feature: spec.feature };
|
|
181
|
-
}
|
|
182
|
-
if (spec.kind === "textPattern") {
|
|
183
|
-
if (adapter.text === undefined) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" uses textPattern but this pack exposes no request text`);
|
|
184
|
-
if (typeof spec.pattern !== "string") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (textPattern) needs a pattern`);
|
|
185
|
-
try { new RegExp(spec.pattern); } catch (e) { throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": invalid pattern: ${e instanceof Error ? e.message : String(e)}`); }
|
|
186
|
-
if (spec.as !== undefined && spec.as !== "string" && spec.as !== "number") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": as is "string" or "number"`);
|
|
187
|
-
for (const key of Object.keys(spec)) if (!["kind", "pattern", "as"].includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
|
|
188
|
-
return { kind: "textPattern", pattern: spec.pattern, ...(spec.as !== undefined ? { as: spec.as as "string" | "number" } : {}) };
|
|
189
|
-
}
|
|
190
|
-
throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": kind is one of ${[...new Set(["feature", "textPattern", ...Object.keys(adapter.extractorKinds ?? {})])].join(", ")}`);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function parseHandler<Req>(raw: unknown, index: number, adapter: PackScenarioAdapter<Req>, extractors: Record<string, ScenarioExtractorSpec>): ScenarioHandler {
|
|
194
|
-
const at = `handler ${index + 1}`;
|
|
195
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ScenarioError(`${adapter.vendor} scenario: ${at} is an object { on, respond, ... }`);
|
|
95
|
+
function parseHandler<Req>(raw: unknown, index: number, adapter: PackScenarioAdapter<Req>): ScenarioHandler {
|
|
196
96
|
const h = raw as Record<string, unknown>;
|
|
97
|
+
const at = typeof h.id === "string" ? h.id : `rule ${index + 1}`;
|
|
197
98
|
for (const key of Object.keys(h)) {
|
|
198
99
|
if (!HANDLER_KEYS.has(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown key "${key}" (valid: ${[...HANDLER_KEYS].join(", ")})`);
|
|
199
100
|
}
|
|
200
101
|
if (typeof h.on !== "object" || h.on === null || Array.isArray(h.on)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`on\` is an object of conditions ({} matches all)`);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const validOn = ["nthCall", ...Object.keys(adapter.matchers)];
|
|
204
|
-
for (const [key, value] of Object.entries(h.on as Record<string, unknown>)) {
|
|
102
|
+
const validOn = Object.keys(adapter.matchers);
|
|
103
|
+
for (const key of Object.keys(h.on as Record<string, unknown>)) {
|
|
205
104
|
if (!validOn.includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown \`on\` key "${key}" (valid here: ${validOn.join(", ")})`);
|
|
206
|
-
if (key === "nthCall" && (!Number.isInteger(value) || (value as number) < 1)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: nthCall is a positive integer`);
|
|
207
105
|
}
|
|
208
106
|
const onRefusal = adapter.validateOn?.(h.on as Record<string, unknown>);
|
|
209
107
|
if (onRefusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${onRefusal}`);
|
|
210
|
-
if (h.fault !== undefined) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (!kinds.includes(f.kind as string)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.kind is one of ${kinds.join(", ")}, got ${JSON.stringify(f.kind)}`);
|
|
215
|
-
const keysFor: Record<string, string[]> = { slow: ["kind", "ms"], status: ["kind", "status", "retryAfterSeconds", "message", "metadata", "errorType"], generation: ["kind", "status", "message", "errorType", "metadata", "partialText", "event"], drop: ["kind", "holdMs"] };
|
|
216
|
-
for (const key of Object.keys(f)) if (!keysFor[f.kind as string]!.includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is not a key of a ${String(f.kind)} fault (valid: ${keysFor[f.kind as string]!.join(", ")})`);
|
|
217
|
-
if (f.kind === "slow" && !(Number.isInteger(f.ms) && (f.ms as number) >= 0 && (f.ms as number) <= 600_000)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.ms is an integer 0..600000 (milliseconds)`);
|
|
218
|
-
if ((f.kind === "status" || f.kind === "generation") && !(Number.isInteger(f.status) && (f.status as number) >= 400 && (f.status as number) <= 599)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.status is an integer 400..599 — a fault is a refusal or a failure, never a success`);
|
|
219
|
-
if (f.kind === "status" && f.retryAfterSeconds !== undefined && !(Number.isInteger(f.retryAfterSeconds) && (f.retryAfterSeconds as number) >= 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.retryAfterSeconds is a non-negative integer`);
|
|
220
|
-
if (f.kind === "status" && f.message !== undefined && typeof f.message !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.message is a string`);
|
|
221
|
-
if ((f.kind === "status" || f.kind === "generation") && f.metadata !== undefined && (typeof f.metadata !== "object" || f.metadata === null || Array.isArray(f.metadata))) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.metadata is an object`);
|
|
222
|
-
if (f.kind === "status" && f.errorType !== undefined && typeof f.errorType !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.errorType is a string`);
|
|
223
|
-
if (f.kind === "generation") {
|
|
224
|
-
if (typeof f.message !== "string" || typeof f.errorType !== "string" || !f.errorType) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation fault requires message and nonempty errorType`);
|
|
225
|
-
for (const key of ["partialText", "event"]) if (f[key] !== undefined && typeof f[key] !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is a string`);
|
|
226
|
-
if (!adapter.validateGenerationFault) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation faults require a compute-only adapter`);
|
|
227
|
-
const refusal = adapter.validateGenerationFault(f as ScenarioGenerationFault, h as unknown as ScenarioHandler);
|
|
228
|
-
if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
|
|
229
|
-
}
|
|
230
|
-
if (f.kind === "drop" && f.holdMs !== undefined && !(Number.isInteger(f.holdMs) && (f.holdMs as number) >= 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.holdMs is a non-negative integer`);
|
|
231
|
-
if (f.kind !== "slow" && "respond" in h) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: a ${String(f.kind)} fault serves no content — drop \`respond\` (only a slow fault may carry one)`);
|
|
232
|
-
}
|
|
233
|
-
if (!("respond" in h) && h.fault === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`respond\` is required (or a \`fault\`)`);
|
|
234
|
-
if (h.id !== undefined && (typeof h.id !== "string" || h.id.length === 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: id is a non-empty string`);
|
|
235
|
-
if (h.once !== undefined && typeof h.once !== "boolean") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: once is boolean`);
|
|
236
|
-
if (h.scope !== undefined && h.scope !== "world" && h.scope !== "session") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope is "world" or "session"`);
|
|
237
|
-
if (h.scope === "session" && adapter.scopeKey === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope "session" but this pack derives no session key`);
|
|
238
|
-
for (const key of ["phase", "advancePhase"] as const) {
|
|
239
|
-
if (h[key] !== undefined && (typeof h[key] !== "string" || (h[key] as string).length === 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${key} is a non-empty string`);
|
|
240
|
-
}
|
|
241
|
-
for (const name of "respond" in h ? placeholderNames(h.respond) : []) {
|
|
242
|
-
if (extractors[name] === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: respond references "{{${name}}}" but no extractor "${name}" is declared`);
|
|
108
|
+
if (h.fault !== undefined && (h.fault as ScenarioFault).kind === "generation") {
|
|
109
|
+
if (!adapter.validateGenerationFault) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation faults require a compute-only adapter`);
|
|
110
|
+
const refusal = adapter.validateGenerationFault(h.fault as ScenarioGenerationFault, h as unknown as ScenarioHandler);
|
|
111
|
+
if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
|
|
243
112
|
}
|
|
244
113
|
const refusal = "respond" in h ? adapter.validateRespond?.(h.respond, h as ScenarioHandler) : null;
|
|
245
114
|
if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
|
|
246
115
|
return h as ScenarioHandler;
|
|
247
116
|
}
|
|
248
117
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
function placeholderNames(value: unknown, out: Set<string> = new Set()): Set<string> {
|
|
252
|
-
if (typeof value === "string") {
|
|
253
|
-
for (const m of value.matchAll(new RegExp(PLACEHOLDER_SRC, "g"))) out.add(m[1]!);
|
|
254
|
-
// A lone unparseable {{...}} is almost certainly a typo'd placeholder — fail loudly.
|
|
255
|
-
const braces = /\{\{[^}]*\}\}/g.exec(value);
|
|
256
|
-
if (braces && !new RegExp(`^${PLACEHOLDER_SRC}$`).test(braces[0])) {
|
|
257
|
-
throw new ScenarioError(`malformed placeholder ${JSON.stringify(braces[0])} (expected {{name}} or {{name|min:N}}/{{name|max:N}})`);
|
|
258
|
-
}
|
|
259
|
-
} else if (Array.isArray(value)) {
|
|
260
|
-
for (const v of value) placeholderNames(v, out);
|
|
261
|
-
} else if (typeof value === "object" && value !== null) {
|
|
262
|
-
for (const v of Object.values(value)) placeholderNames(v, out);
|
|
263
|
-
}
|
|
264
|
-
return out;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function adapterKind<Req>(adapter: PackScenarioAdapter<Req>, kind: string): { validate: (spec: Record<string, unknown>, name: string) => string | null; extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number } | undefined {
|
|
268
|
-
return adapter.extractorKinds?.[kind];
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** `{{name|min:N}}` / `{{name|max:N}}` — numeric clamps on an extracted value. */
|
|
272
|
-
function applyTransform(value: string | number, transform: string | undefined, vendor: string, where: string): string | number {
|
|
273
|
-
if (!transform) return value;
|
|
274
|
-
const [op, rawN] = transform.slice(1).split(":") as [string, string];
|
|
275
|
-
if (typeof value !== "number" && !(typeof value === "string" && isExactIntegerValue(value))) throw new ScenarioError(`${vendor} scenario: ${where} applies |${op}:${rawN} to a non-numeric extractor value ${JSON.stringify(value)}`);
|
|
276
|
-
const n = /^-?\d+$/.test(rawN) ? exactInteger(rawN) : Number(rawN);
|
|
277
|
-
const cmp = compareExactNumbers(value, n);
|
|
278
|
-
return op === "min" ? (cmp <= 0 ? value : n) : (cmp >= 0 ? value : n);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
type ScopeState = { calls: number; phase: string | undefined; onceFired: Set<string> };
|
|
282
|
-
type Registered = { handler: ScenarioHandler; id: string; source: "file" | "use"; matches: number };
|
|
283
|
-
|
|
284
|
-
const MISS_KEEP = 20;
|
|
285
|
-
|
|
286
|
-
/** The engine: pure state machine over registered handlers. One instance per twin server. */
|
|
118
|
+
/** The engine over one turn's rules: pure, the same rules and request giving the same decision. */
|
|
287
119
|
export class ScenarioEngine<Req> {
|
|
288
|
-
private readonly adapter: PackScenarioAdapter<Req
|
|
289
|
-
private readonly extractors: Record<string, ScenarioExtractorSpec>;
|
|
290
|
-
private baseline: Registered[] = [];
|
|
291
|
-
private overrides: Registered[] = [];
|
|
292
|
-
private scopes = new Map<string, ScopeState>();
|
|
293
|
-
private missCount = 0;
|
|
294
|
-
private recentMisses: ScenarioMissRecord[] = [];
|
|
295
|
-
|
|
296
|
-
constructor(adapter: PackScenarioAdapter<Req>, document?: ScenarioDocument) {
|
|
297
|
-
this.adapter = adapter;
|
|
298
|
-
this.extractors = document?.extractors ?? {};
|
|
299
|
-
if (document) {
|
|
300
|
-
this.baseline = document.handlers.map((handler, i) => ({ handler, id: handler.id ?? `handler-${i + 1}`, source: "file", matches: 0 }));
|
|
301
|
-
}
|
|
302
|
-
}
|
|
120
|
+
constructor(private readonly adapter: PackScenarioAdapter<Req>, private readonly document: ScenarioDocument) {}
|
|
303
121
|
|
|
304
|
-
/**
|
|
305
|
-
* door — nothing outside this process can reach it, by design. */
|
|
306
|
-
use(...handlers: ScenarioHandler[]): () => void {
|
|
307
|
-
const parsed = handlers.map((h, i) => parseHandler(h, i, this.adapter, this.extractors));
|
|
308
|
-
const registered: Registered[] = parsed.map((handler, i) => ({ handler, id: handler.id ?? `use-${this.overrides.length + i + 1}`, source: "use", matches: 0 }));
|
|
309
|
-
this.overrides = [...registered, ...this.overrides]; // LIFO: newest first
|
|
310
|
-
return () => { this.overrides = this.overrides.filter((r) => !registered.includes(r)); };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Decide one request. Mutates scope state (calls, once, phase) exactly like serving. */
|
|
122
|
+
/** Decide one request: the first rule that matches and whose answer the request admits, else the miss. */
|
|
314
123
|
next(req: Req): ScenarioDecision {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (this.adapter.scopeKey) this.scope(`session:${this.adapter.scopeKey(req)}`).calls += 1;
|
|
320
|
-
for (const r of [...this.overrides, ...this.baseline]) {
|
|
321
|
-
const scope = this.scope(scopeOf(r.handler));
|
|
322
|
-
if (r.handler.phase !== undefined && scope.phase !== r.handler.phase) continue;
|
|
323
|
-
if (r.handler.once && scope.onceFired.has(r.id)) continue;
|
|
324
|
-
if (!this.matches(r.handler, req, scope)) continue;
|
|
325
|
-
if ("respond" in r.handler && this.adapter.admits && !this.adapter.admits(req, r.handler.respond)) continue;
|
|
326
|
-
if (r.handler.once) scope.onceFired.add(r.id);
|
|
327
|
-
if (r.handler.advancePhase !== undefined) scope.phase = r.handler.advancePhase;
|
|
328
|
-
r.matches += 1;
|
|
329
|
-
// `ruleId` is the row's STABLE id — the authored `id` or the parser's `handler-<n>`
|
|
330
|
-
// default — the same name the status door reports, so a pack stamping the fired rule
|
|
331
|
-
// (deepgram's `scenario_rule`) and the operator reading /twin/scenario see one vocabulary.
|
|
332
|
-
try {
|
|
333
|
-
return { kind: "handler", handler: r.handler, respond: "respond" in r.handler ? this.substitute(r.handler.respond, req) : undefined, ruleId: r.id, ...(r.handler.fault !== undefined ? { fault: r.handler.fault } : {}) };
|
|
334
|
-
} catch (e) {
|
|
335
|
-
// The handler that demanded the value is named: an authoring fault is fixed by finding it.
|
|
336
|
-
if (e instanceof ScenarioError) throw new ScenarioError(`${e.message} [${r.id}${typeof (r.handler as { $comment?: unknown }).$comment === "string" ? `: ${String((r.handler as { $comment?: string }).$comment).slice(0, 80)}` : ""}]`);
|
|
337
|
-
throw e;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
const miss: ScenarioMissRecord = { features: this.adapter.features(req), phase: this.scope("world").phase };
|
|
341
|
-
this.missCount += 1;
|
|
342
|
-
this.recentMisses = [...this.recentMisses.slice(-(MISS_KEEP - 1)), miss];
|
|
343
|
-
return { kind: "miss", miss };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Decide one request AND honor its fault: a `slow` is awaited here and the decision is
|
|
347
|
-
* returned for the pack to serve; a `status` returns the rendered refusal to serve as-is; a
|
|
348
|
-
* `drop` never resolves within its hold. The one call a realizer makes so every vendor's
|
|
349
|
-
* outage reads the same. */
|
|
350
|
-
async serve(req: Req, signal?: AbortSignal): Promise<ScenarioDecision | { kind: "fault"; result: ScenarioFaultResult; ruleId: string }> {
|
|
351
|
-
throwIfScenarioAborted(signal);
|
|
352
|
-
const decision = this.next(req);
|
|
353
|
-
throwIfScenarioAborted(signal);
|
|
354
|
-
if (decision.kind !== "handler" || decision.fault === undefined) return decision;
|
|
355
|
-
const result = await scenarioFaultResult(decision.fault, () => this.adapter.renderFault?.(decision.fault as ScenarioStatusFault, req), signal);
|
|
356
|
-
throwIfScenarioAborted(signal);
|
|
357
|
-
return result === null ? decision : { kind: "fault", result, ruleId: decision.ruleId };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** For GET /twin/scenario — active handlers with match counts, and the recent misses. */
|
|
361
|
-
status(): ScenarioStatus {
|
|
362
|
-
const row = (r: Registered) => ({ id: r.id, ...(r.handler.phase !== undefined ? { phase: r.handler.phase } : {}), ...(r.handler.once !== undefined ? { once: r.handler.once } : {}), ...(r.handler.scope !== undefined ? { scope: r.handler.scope } : {}), matches: r.matches, source: r.source });
|
|
363
|
-
return { vendor: this.adapter.vendor, handlers: [...this.overrides, ...this.baseline].map(row), misses: this.missCount, recentMisses: [...this.recentMisses] };
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
private scope(key: string): ScopeState {
|
|
367
|
-
let s = this.scopes.get(key);
|
|
368
|
-
if (!s) { s = { calls: 0, phase: undefined, onceFired: new Set() }; this.scopes.set(key, s); }
|
|
369
|
-
return s;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
private matches(handler: ScenarioHandler, req: Req, scope: ScopeState): boolean {
|
|
373
|
-
for (const [key, condition] of Object.entries(handler.on)) {
|
|
374
|
-
if (key === "nthCall") { if (scope.calls !== condition) return false; continue; }
|
|
375
|
-
const matcher = this.adapter.matchers[key];
|
|
376
|
-
if (!matcher) return false; // unreachable post-parse; belt over braces
|
|
377
|
-
if (!matcher(req, condition)) return false;
|
|
378
|
-
}
|
|
379
|
-
return true;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
private extractValue(name: string, req: Req, cache: Map<string, string | number>): string | number {
|
|
383
|
-
const cached = cache.get(name);
|
|
384
|
-
if (cached !== undefined) return cached;
|
|
385
|
-
const spec = this.extractors[name]!;
|
|
386
|
-
const packKind = adapterKind(this.adapter, spec.kind);
|
|
387
|
-
let value: string | number;
|
|
388
|
-
if (packKind !== undefined) {
|
|
389
|
-
value = packKind.extract(spec as Record<string, unknown>, req, name);
|
|
390
|
-
} else if (spec.kind === "feature") {
|
|
391
|
-
const v = this.adapter.features(req)[(spec as { feature: string }).feature];
|
|
392
|
-
if (v === undefined) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": the request has no feature "${(spec as { feature: string }).feature}"`);
|
|
393
|
-
value = typeof v === "number" || typeof v === "string" ? v : String(v);
|
|
394
|
-
} else {
|
|
395
|
-
const text = this.adapter.text!(req);
|
|
396
|
-
const tp = spec as { pattern: string; as?: string };
|
|
397
|
-
const m = new RegExp(tp.pattern).exec(text);
|
|
398
|
-
if (!m) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": pattern did not match the request (the handler demanded a value the request never stated)`);
|
|
399
|
-
const captured = m[1] ?? m[0]!;
|
|
400
|
-
if (tp.as === "number") {
|
|
401
|
-
const n = /^-?\d+$/.test(captured) ? exactInteger(captured) : Number(captured);
|
|
402
|
-
if (Number.isNaN(n)) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": captured "${captured}" is not a number`);
|
|
403
|
-
value = n;
|
|
404
|
-
} else value = captured;
|
|
405
|
-
}
|
|
406
|
-
cache.set(name, value);
|
|
407
|
-
return value;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
private substitute(value: unknown, req: Req, cache: Map<string, string | number> = new Map()): unknown {
|
|
411
|
-
if (typeof value === "string") {
|
|
412
|
-
// A WHOLE-string placeholder yields the typed value (numbers stay numbers).
|
|
413
|
-
const whole = new RegExp(`^${PLACEHOLDER_SRC}$`).exec(value);
|
|
414
|
-
if (whole) return applyTransform(this.extractValue(whole[1]!, req, cache), whole[2], this.adapter.vendor, value);
|
|
415
|
-
return value.replace(new RegExp(PLACEHOLDER_SRC, "g"), (all, name: string, transform: string | undefined) =>
|
|
416
|
-
String(applyTransform(this.extractValue(name, req, cache), transform, this.adapter.vendor, all)));
|
|
417
|
-
}
|
|
418
|
-
if (Array.isArray(value)) return value.map((v) => this.substitute(v, req, cache));
|
|
419
|
-
if (typeof value === "object" && value !== null) {
|
|
420
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.substitute(v, req, cache)]));
|
|
124
|
+
for (const [i, handler] of this.document.handlers.entries()) {
|
|
125
|
+
if (!Object.entries(handler.on).every(([key, condition]) => this.adapter.matchers[key]!(req, condition))) continue;
|
|
126
|
+
if ("respond" in handler && this.adapter.admits && !this.adapter.admits(req, handler.respond)) continue;
|
|
127
|
+
return { kind: "handler", handler, respond: handler.respond, ruleId: handler.id ?? `rule-${i + 1}`, ...(handler.fault !== undefined ? { fault: handler.fault } : {}) };
|
|
421
128
|
}
|
|
422
|
-
return
|
|
129
|
+
return { kind: "miss", miss: { features: this.adapter.features(req) } };
|
|
423
130
|
}
|
|
424
131
|
}
|
|
425
132
|
|
|
426
|
-
/** The GET /twin manifest for a STATEFUL twin (no scenario engine): what it stores, how
|
|
427
|
-
* state and identity work, where behavior scripting actually lives. Education ships
|
|
428
|
-
* INSIDE the twin — in-world agents have no repos or skills, only HTTP. */
|
|
429
133
|
/** Honor a fault outside the engine (hand-written serve paths): `slow` waits and returns null
|
|
430
134
|
* (serve normally now); `status` returns what to serve; `drop` holds the request for `holdMs`
|
|
431
135
|
* (default 300 000) and then throws, so the socket is released and nothing is ever answered. */
|
|
@@ -486,8 +190,7 @@ export function statefulTwinManifest(input: { vendor: string; twinOf: string; st
|
|
|
486
190
|
|
|
487
191
|
/** The GET /twin manifest — the discovery door's body. Education ships INSIDE the twin:
|
|
488
192
|
* in-world agents have no repos or skills, only HTTP. */
|
|
489
|
-
export function twinManifest(input: { vendor: string; twinOf: string; stateSentence: string; behaviorSentence: string; exampleHandler: ScenarioHandler | null
|
|
490
|
-
const status = input.engine?.status();
|
|
193
|
+
export function twinManifest(input: { vendor: string; twinOf: string; stateSentence: string; behaviorSentence: string; exampleHandler: ScenarioHandler | null }): Record<string, unknown> {
|
|
491
194
|
return {
|
|
492
195
|
twin: true,
|
|
493
196
|
vendor: input.vendor,
|
|
@@ -495,10 +198,9 @@ export function twinManifest(input: { vendor: string; twinOf: string; stateSente
|
|
|
495
198
|
program: {
|
|
496
199
|
state: input.stateSentence,
|
|
497
200
|
behavior: input.behaviorSentence,
|
|
498
|
-
invariant: "
|
|
499
|
-
...(input.exampleHandler ? { exampleHandler: input.exampleHandler } : {}),
|
|
201
|
+
invariant: "Nothing answers in place of this twin: a World's scenario places turn decisions (point \"turn\") on its timeline, and the twin answers and records them; stored data is seeded through the vendor's own API. Serving is deterministic.",
|
|
202
|
+
...(input.exampleHandler ? { exampleDecision: { at: "0s", decision: { vendor: input.vendor, point: "turn", on: input.exampleHandler.on, outcome: input.exampleHandler.respond } } } : {}),
|
|
500
203
|
},
|
|
501
|
-
doors: { manifest: "GET /twin", scenario: "GET /twin/scenario (read-only:
|
|
502
|
-
...(status ? { activeHandlers: status.handlers.length, misses: status.misses } : {}),
|
|
204
|
+
doors: { manifest: "GET /twin", scenario: "GET /twin/scenario (read-only: what the World's scenario placed on this twin, and what it consumed)" },
|
|
503
205
|
};
|
|
504
206
|
}
|
package/src/sockets.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { runWithStorageTenant, storageTenant } from './request-scope.ts';
|
|
|
9
9
|
// Nothing here runs at module scope beyond the empty registry.
|
|
10
10
|
import { worldNow } from './world-clock.ts';
|
|
11
11
|
import type { EventWrite } from './events.ts';
|
|
12
|
-
import type {
|
|
12
|
+
import type { ScenarioDecision, ScenarioFaultResult } from './scenario.ts';
|
|
13
13
|
import type { WebSocketPeer, WebSocketUpgrade } from './serve-http.ts';
|
|
14
14
|
|
|
15
15
|
/** A socket the vendor serves: its path (and host, when the vendor serves it on a host of its own), as the manifest
|
|
@@ -36,8 +36,8 @@ export type SocketSession = {
|
|
|
36
36
|
decide(asked: unknown): Promise<ScenarioServed | undefined>;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
/** What the World's scenario serves a turn:
|
|
40
|
-
export type ScenarioServed =
|
|
39
|
+
/** What the World's scenario serves a turn (scenario-serve.ts): its decision, the miss, or a fault's answer. */
|
|
40
|
+
export type ScenarioServed = ScenarioDecision | { kind: 'fault'; result: ScenarioFaultResult; ruleId: string };
|
|
41
41
|
|
|
42
42
|
/** A socket's protocol, over the handler contract's context (a read of the World as the request that opened it). */
|
|
43
43
|
export type SocketEngine<C> = {
|
|
@@ -108,7 +108,7 @@ export function socketUpgrade<C>(
|
|
|
108
108
|
context: (request: Request, decl: SocketDecl, params: Record<string, string>) => Promise<C>,
|
|
109
109
|
hostOf: (request: Request) => string,
|
|
110
110
|
pathOf: (request: Request) => string,
|
|
111
|
-
decide: (asked: unknown) => Promise<ScenarioServed | undefined>,
|
|
111
|
+
decide: (asked: unknown, context: () => Promise<C>) => Promise<ScenarioServed | undefined>,
|
|
112
112
|
): WebSocketUpgrade {
|
|
113
113
|
const matchFor = (request: Request): { decl: SocketDecl; params: Record<string, string> } | undefined => {
|
|
114
114
|
const path = pathOf(request).replace(/\/+$/, '') || '/';
|
|
@@ -149,7 +149,7 @@ export function socketUpgrade<C>(
|
|
|
149
149
|
clearTimeout(p.timer); pending.delete(key); p.resolve(value);
|
|
150
150
|
return true;
|
|
151
151
|
},
|
|
152
|
-
decide,
|
|
152
|
+
decide: (asked) => decide(asked, () => o.context() as Promise<C>),
|
|
153
153
|
};
|
|
154
154
|
const tenant = storageTenant(service), run = <T>(fn: () => T): T => runWithStorageTenant(service, tenant, fn);
|
|
155
155
|
const o: Open = { tenant, run, session, engine, context: () => context(request.clone(), decl, params), queue: Promise.resolve(), pending };
|
package/src/world-clock.ts
CHANGED
|
@@ -25,16 +25,23 @@ const machineNow = (): number => {
|
|
|
25
25
|
/** The world's "now": the clock file's time when one is configured (its frozen instant, or a running clock's time
|
|
26
26
|
* now); real wall-clock time otherwise. */
|
|
27
27
|
export function worldNow(): string {
|
|
28
|
+
const clock = heldClock();
|
|
29
|
+
return new Date(clock ? clockForm.clockNowMs(clock, machineNow()) : machineNow()).toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The World's clock as its file holds it, or null where it has none (the machine's time). One read answers both
|
|
33
|
+
* whether the clock file is there and what it holds, kept while the file's own facts stand (world-store.ts readKept):
|
|
34
|
+
* every operation asks the time. A configured-but-corrupt clock is a World defect: parseClock fails loudly, never
|
|
35
|
+
* drifting back to the wall clock (two services disagreeing about now breaks the World). */
|
|
36
|
+
function heldClock(): ReturnType<typeof clockForm.parseClock> | null {
|
|
28
37
|
const file = process.env[WORLD_CLOCK_ENV];
|
|
29
|
-
// One read answers both whether the clock file is there and what it holds.
|
|
30
|
-
// Kept while the clock file's own facts stand (world-store.ts readKept): every operation asks the time.
|
|
31
38
|
const store = getActiveWorldStore();
|
|
32
39
|
const held = file ? (store.readKept ? store.readKept(file) : store.read(file)) : null;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
40
|
+
return held === null ? null : clockForm.parseClock(held);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether the World's time runs on by itself (a running clock, or no clock: the machine's), rather than standing frozen
|
|
44
|
+
* until it is moved. */
|
|
45
|
+
export function worldClockRunning(): boolean {
|
|
46
|
+
return heldClock()?.kind !== 'frozen';
|
|
40
47
|
}
|