babavoss 0.0.1 → 0.12.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/NOTICE +7 -0
- package/bin/voss.ts +48 -0
- package/gui/babavoss-web.js +234 -0
- package/gui/chunk-5hpp1ypv.js +11710 -0
- package/gui/chunk-9rq662fd.js +8627 -0
- package/gui/chunk-k3cm8j6r.js +433 -0
- package/gui/chunk-pk09y98y.js +50 -0
- package/gui/chunk-smz02qa6.js +186 -0
- package/gui/chunk-wwqypxre.js +49 -0
- package/gui/gui.js +2015 -0
- package/gui/react-compiler-runtime.js +39 -0
- package/gui/react-dom-client.js +21 -0
- package/gui/react-dom.js +44 -0
- package/gui/react-jsx-runtime.js +19 -0
- package/gui/react.js +105 -0
- package/gui/theme.css +3053 -0
- package/index.ts +15 -0
- package/package.json +50 -4
- package/src/baba/check.ts +90 -0
- package/src/baba/config.ts +261 -0
- package/src/baba/find.ts +12 -0
- package/src/baba/init.ts +176 -0
- package/src/baba/node.ts +483 -0
- package/src/baba/project.ts +63 -0
- package/src/baba/registry.ts +35 -0
- package/src/baba/worker.ts +55 -0
- package/src/bench/index.ts +6 -0
- package/src/bench/measure.ts +132 -0
- package/src/bench/scenarios.ts +136 -0
- package/src/build/builder.ts +74 -0
- package/src/build/failure.ts +78 -0
- package/src/build/guard.ts +85 -0
- package/src/build/mdx-register.ts +3 -0
- package/src/build/mdx.ts +38 -0
- package/src/build/project.ts +38 -0
- package/src/build/views.ts +146 -0
- package/src/builder/main.ts +29 -0
- package/src/desktop/bob.ts +76 -0
- package/src/desktop/desktop.css +111 -0
- package/src/desktop/icons.ts +50 -0
- package/src/desktop/index.ts +323 -0
- package/src/desktop/routes.ts +98 -0
- package/src/desktop/view.tsx +673 -0
- package/src/door/core.ts +384 -0
- package/src/ecs/baba.ts +431 -0
- package/src/ecs/codec.ts +334 -0
- package/src/ecs/handles.ts +91 -0
- package/src/ecs/replica.ts +150 -0
- package/src/ecs/runtime.ts +603 -0
- package/src/ecs/scheduler.ts +75 -0
- package/src/ecs/snapshot.ts +102 -0
- package/src/ecs/state.ts +759 -0
- package/src/ecs/system.ts +256 -0
- package/src/ecs/table.ts +420 -0
- package/src/ecs/testbed.ts +97 -0
- package/src/exec/host.ts +177 -0
- package/src/exec/main.ts +98 -0
- package/src/exec/watch.ts +7 -0
- package/src/exec/wire.ts +29 -0
- package/src/generated/build.ts +4 -0
- package/src/gui/css.d.ts +1 -0
- package/src/gui/gui.tsx +245 -0
- package/src/gui/index.ts +51 -0
- package/src/gui/inspector.tsx +47 -0
- package/src/gui/levels.tsx +73 -0
- package/src/gui/promptware.tsx +68 -0
- package/src/gui/runner.tsx +118 -0
- package/src/gui/theme.css +498 -0
- package/src/gui/theme.ts +25 -0
- package/src/gui/wizard.tsx +227 -0
- package/src/guide/add-a-desktop.mdx +100 -0
- package/src/guide/compose-an-interface.mdx +84 -0
- package/src/guide/index.ts +13 -0
- package/src/guide/reach-outside.mdx +112 -0
- package/src/guide/spec-a-system.mdx +93 -0
- package/src/guide/systems-together.mdx +72 -0
- package/src/guide/write-a-system.mdx +183 -0
- package/src/guide/write-promptware.mdx +90 -0
- package/src/http/server.ts +310 -0
- package/src/kernel/build.ts +21 -0
- package/src/kernel/builder.ts +105 -0
- package/src/kernel/children.ts +117 -0
- package/src/kernel/context.ts +90 -0
- package/src/kernel/lock.ts +46 -0
- package/src/kernel/names.ts +14 -0
- package/src/kernel/schema.ts +130 -0
- package/src/kernel/where.ts +12 -0
- package/src/kit/index.ts +232 -0
- package/src/maker/system.ts +213 -0
- package/src/mcp/daemon.ts +61 -0
- package/src/mcp/main.ts +208 -0
- package/src/mcp/rpc.ts +64 -0
- package/src/mcp/tools.ts +125 -0
- package/src/prompt/evals.ts +42 -0
- package/src/prompt/index.ts +242 -0
- package/src/prompt/jsx-dev-runtime.ts +1 -0
- package/src/prompt/jsx-runtime.ts +49 -0
- package/src/prompt/mdx.d.ts +1 -0
- package/src/promptware/compile.ts +183 -0
- package/src/promptware/define.ts +16 -0
- package/src/promptware/disk.ts +72 -0
- package/src/promptware/markdown.d.ts +6 -0
- package/src/promptware/sync.ts +437 -0
- package/src/promptware/system.ts +215 -0
- package/src/runtime/bridge.ts +85 -0
- package/src/runtime/connect.ts +54 -0
- package/src/runtime/env.ts +35 -0
- package/src/runtime/harness.ts +80 -0
- package/src/runtime/main.ts +119 -0
- package/src/runtime/worker.ts +33 -0
- package/src/server/edge.ts +332 -0
- package/src/server/main.ts +45 -0
- package/src/server/messages.ts +97 -0
- package/src/server/protocol.ts +37 -0
- package/src/services/args.ts +45 -0
- package/src/services/exec.ts +69 -0
- package/src/services/fs.ts +139 -0
- package/src/services/http.ts +30 -0
- package/src/services/index.ts +113 -0
- package/src/services/secrets.ts +18 -0
- package/src/shell/address.ts +21 -0
- package/src/shell/args.ts +219 -0
- package/src/shell/client.ts +107 -0
- package/src/shell/codes.ts +26 -0
- package/src/shell/positional.ts +20 -0
- package/src/shell/run.ts +470 -0
- package/src/shell/service.ts +167 -0
- package/src/shell/state.ts +204 -0
- package/src/spec/adapters.ts +72 -0
- package/src/spec/diff.ts +26 -0
- package/src/spec/files.ts +17 -0
- package/src/spec/index.ts +155 -0
- package/src/spec/run.ts +97 -0
- package/src/spec/take.ts +54 -0
- package/src/test/index.ts +8 -0
- package/src/test/prove.ts +56 -0
- package/src/test/records.ts +23 -0
- package/src/test/specs.ts +56 -0
- package/src/test/steps.ts +100 -0
- package/src/test/voss-dir.ts +17 -0
- package/src/transport/messages.ts +110 -0
- package/src/transport/transport.ts +62 -0
- package/src/wall/probe.ts +67 -0
- package/src/wall/profile.ts +103 -0
- package/src/wall/spawn.ts +59 -0
- package/src/web/app.tsx +53 -0
- package/src/web/core.tsx +140 -0
- package/src/web/form.ts +155 -0
- package/src/web/hooks.ts +135 -0
- package/src/web/index.tsx +17 -0
- package/src/web/list.ts +19 -0
- package/src/web/maker.tsx +766 -0
- package/src/web/objects.tsx +213 -0
- package/src/web/socket.ts +84 -0
- package/src/web/state.tsx +69 -0
- package/src/web/store.ts +221 -0
- package/src/web/ui.tsx +135 -0
- package/README.md +0 -5
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
// The Baba Maker: the baba as a whole, simulated in the page, for the person
|
|
2
|
+
// making it. Home is the baba with its starting states (the specs). A
|
|
3
|
+
// spec open is its view, filling the page: one of the baba's own apps, the
|
|
4
|
+
// one that fits the spec's systems, or the desktop with every app that
|
|
5
|
+
// fits, drawn over the simulated state, the hooks reading it and the hand
|
|
6
|
+
// acting on it. Along the
|
|
7
|
+
// bottom a status bar says the rest in one line each, and each line opens
|
|
8
|
+
// a popover: the stories and the one under way, the model as objects, the
|
|
9
|
+
// outside as played and quiet, the take, and the machine's debug view.
|
|
10
|
+
// The spec runs on the testbed's virtual clock, advanced from real time by
|
|
11
|
+
// the speed knob; a story is the same program the headless runner executes.
|
|
12
|
+
import { Component, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentType, type ReactNode } from "react";
|
|
13
|
+
import { Replica } from "../ecs/replica.ts";
|
|
14
|
+
import type { Testbed } from "../ecs/testbed.ts";
|
|
15
|
+
import type { Effect, Job, Snapshot } from "../ecs/state.ts";
|
|
16
|
+
import type { Raw } from "../ecs/runtime.ts";
|
|
17
|
+
import type { StateAnswer } from "../ecs/snapshot.ts";
|
|
18
|
+
import { seeded, liveRun, linesOf, asProgram, wordsOf, diffStates, isPlayed, adaptersOf, type Spec, type SpecScenario, type Progress, type RunResult, type TakeStep, type Expectation, type Note, type Change, type ResourceChange } from "../spec/index.ts";
|
|
19
|
+
import { testbed } from "../ecs/testbed.ts";
|
|
20
|
+
import type { Line } from "../test/steps.ts";
|
|
21
|
+
import type { StepRecord, CheckRecord } from "../test/records.ts";
|
|
22
|
+
import maker from "../maker/system.ts";
|
|
23
|
+
import { nameOf, type AppSpec } from "../ecs/baba.ts";
|
|
24
|
+
import { useBabaState } from "./state.tsx";
|
|
25
|
+
import { useManifest } from "./core.tsx";
|
|
26
|
+
import { LiveStoreContext, type LiveStore } from "./hooks.ts";
|
|
27
|
+
import type { Hand } from "./app.tsx";
|
|
28
|
+
import { ObjectCards, ObjectMap, instancesOf, objectsOf, type ObjectType } from "./objects.tsx";
|
|
29
|
+
import { Button } from "./ui.tsx";
|
|
30
|
+
import { LocalDesktop, SpecDesktop, routeOf, type AppProps } from "../desktop/view.tsx";
|
|
31
|
+
import { matchRoute } from "../desktop/routes.ts";
|
|
32
|
+
import type { DesktopSystem } from "../desktop/index.ts";
|
|
33
|
+
|
|
34
|
+
/** A way to look at a spec: one of the baba's apps, or the desktop with every app that fits. */
|
|
35
|
+
export interface AppChoice { key: string; title: string; kind: "app" | "desktop" }
|
|
36
|
+
/** The desktop's key among a spec's choices: no app's key starts so. */
|
|
37
|
+
const DESKTOP = "~desktop";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The apps that fit a spec, the first the default: the desktop, for a spec that holds the desktop system or is the whole
|
|
41
|
+
* baba; then every app the baba declares, with a component, whose systems (`apps: [{ key, systems }]`) are all in the
|
|
42
|
+
* spec. An app that names no system fits every spec.
|
|
43
|
+
*/
|
|
44
|
+
export function appsFor(spec: Spec<any, any>, declared: readonly AppSpec[], components: Record<string, unknown>): AppChoice[] {
|
|
45
|
+
const names = new Set((spec.baba.systems as readonly unknown[]).map(nameOf));
|
|
46
|
+
const fits = declared.filter((sc) => sc.key in components && (sc.systems ?? []).every((n) => names.has(n)));
|
|
47
|
+
const desktop = names.has("desktop") || spec.baba.apps.length > 0;
|
|
48
|
+
return [...(desktop ? [{ key: DESKTOP, title: "Desktop", kind: "desktop" as const }] : []), ...fits.map((sc) => ({ key: sc.key, title: sc.title, kind: "app" as const }))];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- the simulation -------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
type Phase = "idle" | "playing" | "paused";
|
|
54
|
+
/** A story under way: its steps, how far it got, what each step changed, and how it ended. */
|
|
55
|
+
interface Running { scenario: SpecScenario; steps: Line<any>[]; gen: Generator<Progress, RunResult, void>; waitUntil: number | null; kind: "canonical" | "exploratory"; at: number; checks: Map<number, CheckRecord>; errors: Map<number, string>; changes: Map<number, { entities: Change[]; resources: ResourceChange[] }>; result: RunResult | null }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One spec simulated in the page: a testbed of it, a replica the
|
|
59
|
+
* hooks read, a clock that follows real time at `speed` while playing, a
|
|
60
|
+
* story under way when one is, and the take: what the hand did. It is the
|
|
61
|
+
* page's live store for everything drawn over it, so the baba's own desktop
|
|
62
|
+
* fires into it.
|
|
63
|
+
*/
|
|
64
|
+
export class Simulation implements LiveStore {
|
|
65
|
+
spec: Spec<any, any>;
|
|
66
|
+
replica = new Replica();
|
|
67
|
+
probe = { samples: [] };
|
|
68
|
+
ready = true;
|
|
69
|
+
connected = true;
|
|
70
|
+
refused: string | null = null;
|
|
71
|
+
t!: Testbed<any>;
|
|
72
|
+
params: Record<string, unknown>;
|
|
73
|
+
phase: Phase = "idle";
|
|
74
|
+
speed = 1;
|
|
75
|
+
/** Whether nothing but the seed and canonical stories have moved this spec. */
|
|
76
|
+
pristine = true;
|
|
77
|
+
take: TakeStep[] = [];
|
|
78
|
+
expectations: Expectation[] = [];
|
|
79
|
+
notes: Note[] = [];
|
|
80
|
+
/** How many code changes this spec carried across since its seed. */
|
|
81
|
+
carried = 0;
|
|
82
|
+
running: Running | null = null;
|
|
83
|
+
rounds: { round: number; now: number; delta: StateAnswer }[] = [];
|
|
84
|
+
log: string[] = [];
|
|
85
|
+
private listeners = new Set<() => void>();
|
|
86
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
87
|
+
private last = 0;
|
|
88
|
+
private before: Snapshot | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(spec: Spec<any, any>, params?: Record<string, unknown>) {
|
|
91
|
+
this.spec = spec;
|
|
92
|
+
this.params = { ...spec.params, ...(params ?? {}) };
|
|
93
|
+
this.reset();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* New code over this spec: the baba's own reload promise, kept here. The
|
|
98
|
+
* state as it is, fitted to the new spec's model; what the model no longer
|
|
99
|
+
* declares is dropped and logged. A drop means the model changed under
|
|
100
|
+
* this spec, and then it reseeds instead; otherwise it carries on, with
|
|
101
|
+
* its clock, its jobs, its outside's state and its take.
|
|
102
|
+
*/
|
|
103
|
+
adopt(spec: Spec<any, any>): void {
|
|
104
|
+
if (spec === this.spec) return;
|
|
105
|
+
const was = this.spec;
|
|
106
|
+
this.spec = spec;
|
|
107
|
+
this.params = { ...spec.params, ...Object.fromEntries(Object.entries(this.params).filter(([k]) => k in spec.params)) };
|
|
108
|
+
const dropped: string[] = [];
|
|
109
|
+
const log = (line: string) => { if (line.startsWith("the kept state was fitted")) dropped.push(line); this.log = [...this.log.slice(-199), line]; };
|
|
110
|
+
const snapshot = this.t.w.snapshot();
|
|
111
|
+
const now = this.t.now;
|
|
112
|
+
const t = testbed(spec.baba, { now, seed: spec.seed, snapshot, adapters: adaptersOf(spec.adapters), log });
|
|
113
|
+
if (dropped.length > 0 || was.setup !== spec.setup && this.pristine) {
|
|
114
|
+
this.log = [...this.log, dropped.length ? `the model changed under this spec (${dropped.length} dropped): reseeded` : "new code over a spec as seeded: reseeded"];
|
|
115
|
+
this.reset();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// The outside's state carries by effect name: the new code's effects are new objects.
|
|
119
|
+
const effects = spec.baba.model.effects as Effect[];
|
|
120
|
+
for (const [e, st] of this.t.outside) { const n = effects.find((x) => x.name === e.name && x.owner === e.owner); if (n && st.size) (t.outside as Map<Effect, Map<string, unknown>>).set(n, new Map(st)); }
|
|
121
|
+
this.t = t;
|
|
122
|
+
this.running = null;
|
|
123
|
+
this.carried += 1;
|
|
124
|
+
this.replica = new Replica(t.w.snapshot());
|
|
125
|
+
this.log = [...this.log, `new code: the spec carried (${this.carried} so far)`];
|
|
126
|
+
this.notify();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The spec from its seed again, with the knobs as they are. */
|
|
130
|
+
reset(params?: Record<string, unknown>): void {
|
|
131
|
+
this.pause();
|
|
132
|
+
this.running = null;
|
|
133
|
+
if (params) this.params = { ...this.spec.params, ...params };
|
|
134
|
+
const log = (line: string) => { this.log = [...this.log.slice(-199), line]; };
|
|
135
|
+
const { t } = seeded(this.spec, { params: this.params, log });
|
|
136
|
+
this.t = t;
|
|
137
|
+
this.replica = new Replica(t.w.snapshot());
|
|
138
|
+
this.rounds = [];
|
|
139
|
+
this.take = [];
|
|
140
|
+
this.expectations = [];
|
|
141
|
+
this.notes = [];
|
|
142
|
+
this.carried = 0;
|
|
143
|
+
this.recorded = this.t.now;
|
|
144
|
+
this.pristine = true;
|
|
145
|
+
this.phase = "idle";
|
|
146
|
+
this.notify();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The replica brought to the state as it is now: by the delta since its round when the state has one, else whole; nothing when no round passed. */
|
|
150
|
+
private sync(): void {
|
|
151
|
+
const round = this.t.round;
|
|
152
|
+
if (round === this.replica.round) return;
|
|
153
|
+
const delta = this.replica.round > 0 ? this.t.w.delta(this.replica.round) : null;
|
|
154
|
+
const answer: StateAnswer = delta || { ...this.t.w.snapshot(), round };
|
|
155
|
+
this.rounds = [...this.rounds.slice(-99), { round, now: this.t.now, delta: answer }];
|
|
156
|
+
this.replica = delta ? this.replica.apply(delta) : new Replica(answer as Snapshot);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Real time passes: the clock advances by `ms * speed`; a story waiting for a moment goes on when it comes. */
|
|
160
|
+
private step(ms: number): void {
|
|
161
|
+
const to = this.t.now + ms * this.speed;
|
|
162
|
+
const r = this.running;
|
|
163
|
+
if (r && r.waitUntil !== null && r.waitUntil <= to) {
|
|
164
|
+
this.t.advance(r.waitUntil);
|
|
165
|
+
this.t.tick({ now: r.waitUntil });
|
|
166
|
+
r.waitUntil = null;
|
|
167
|
+
this.drive();
|
|
168
|
+
}
|
|
169
|
+
this.t.advance(to);
|
|
170
|
+
this.sync();
|
|
171
|
+
this.notify();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
play(): void {
|
|
175
|
+
if (this.phase === "playing") return;
|
|
176
|
+
this.phase = "playing";
|
|
177
|
+
this.last = performance.now();
|
|
178
|
+
this.timer = setInterval(() => { const now = performance.now(); const ms = now - this.last; this.last = now; this.step(ms); }, 50);
|
|
179
|
+
this.notify();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pause(): void {
|
|
183
|
+
if (this.timer) clearInterval(this.timer);
|
|
184
|
+
this.timer = null;
|
|
185
|
+
if (this.phase === "playing") this.phase = "paused";
|
|
186
|
+
this.notify();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One round now, by hand. */
|
|
190
|
+
tick(): void { this.pass(1); }
|
|
191
|
+
|
|
192
|
+
setSpeed(s: number): void { this.speed = s; this.notify(); }
|
|
193
|
+
|
|
194
|
+
/** The clock as the take last saw it: time that passed since is recorded before the next step, so a replay reaches the same moment. */
|
|
195
|
+
private recorded = 0;
|
|
196
|
+
private markTime(): void {
|
|
197
|
+
if (this.t.now === this.recorded) return;
|
|
198
|
+
this.take.push({ rec: { kind: "tick", label: `to ${(this.t.now / 1000).toFixed(1)} s`, args: { at: this.t.now } } });
|
|
199
|
+
this.recorded = this.t.now;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* An action now, as its system, then a round now, as the running baba gives
|
|
203
|
+
* every action: recorded in the take as the fire and a tick at this very
|
|
204
|
+
* moment, so headless the job starts in the same round. The hand's, or an
|
|
205
|
+
* agent's through an ask.
|
|
206
|
+
*/
|
|
207
|
+
act(name: string, args?: unknown): { value?: unknown; error?: string } {
|
|
208
|
+
this.markTime();
|
|
209
|
+
const rec: StepRecord = { kind: "fire", label: name, args: args ?? {} };
|
|
210
|
+
let value: unknown;
|
|
211
|
+
try { value = rec.result = (this.t.fire as unknown as (n: string, a: unknown) => unknown)(name, args ?? {}); } catch (e) { rec.error = e instanceof Error ? e.message : String(e); }
|
|
212
|
+
this.take.push({ rec });
|
|
213
|
+
if (!rec.error) { this.t.tick({ now: this.t.now }); this.take.push({ rec: { kind: "tick", label: "a round", args: { at: this.t.now } } }); }
|
|
214
|
+
this.touch();
|
|
215
|
+
this.sync();
|
|
216
|
+
this.notify();
|
|
217
|
+
return rec.error ? { error: rec.error } : { value };
|
|
218
|
+
}
|
|
219
|
+
/** Time passes by `ms` on the spec's clock, every round due on the way and one at the end: recorded in the take. */
|
|
220
|
+
pass(ms: number): void {
|
|
221
|
+
this.t.tick({ now: this.t.now + Math.max(1, ms) });
|
|
222
|
+
this.markTime();
|
|
223
|
+
this.touch();
|
|
224
|
+
this.sync();
|
|
225
|
+
this.notify();
|
|
226
|
+
}
|
|
227
|
+
/** What an agent's ask answers from this spec: the steps run in order, what each gave, and what they changed together; or a read. */
|
|
228
|
+
ask(kind: "step" | "read", program: string): unknown {
|
|
229
|
+
const p = JSON.parse(program) as unknown;
|
|
230
|
+
if (kind === "read") {
|
|
231
|
+
const { query, args } = p as { query: string; args?: unknown };
|
|
232
|
+
if (query === "take") return { steps: this.take.map((s) => wordsOf(s.rec)), expectations: this.expectations.map((e) => ({ at: e.at, label: e.label, entity: e.entity, parts: e.parts })), notes: this.notes, pristine: this.pristine, carried: this.carried, now: this.t.now, round: this.t.round, story: this.running ? { name: this.running.scenario.name, kind: this.running.kind, verdict: this.running.result?.verdict ?? null } : null };
|
|
233
|
+
return (this.t.read as unknown as (n: string, a: unknown) => unknown)(query, args ?? {});
|
|
234
|
+
}
|
|
235
|
+
const steps = p as Record<string, unknown>[];
|
|
236
|
+
const before = this.t.w.snapshot();
|
|
237
|
+
const results: unknown[] = [];
|
|
238
|
+
for (const st of steps) {
|
|
239
|
+
if ("fire" in st) { const r = this.act(String(st.fire), st.args); results.push({ fire: st.fire, ...r }); if (r.error) break; }
|
|
240
|
+
else if ("tick" in st) { this.pass(Number(st.tick)); results.push({ tick: Number(st.tick), now: this.t.now }); }
|
|
241
|
+
else if ("read" in st) { try { results.push({ read: st.read, value: (this.t.read as unknown as (n: string, a: unknown) => unknown)(String(st.read), st.args ?? {}) }); } catch (e) { results.push({ read: st.read, error: e instanceof Error ? e.message : String(e) }); } }
|
|
242
|
+
else throw new Error(`a step is {fire, args}, {tick: ms} or {read, args}: ${JSON.stringify(st)}`);
|
|
243
|
+
}
|
|
244
|
+
return { results, changes: diffStates(before, this.t.w.snapshot()), now: this.t.now, round: this.t.round, quiet: [...new Set(this.t.jobs.filter((j) => !isPlayed(this.spec.adapters, j.effect)).map((j) => j.effect.name))].sort() };
|
|
245
|
+
}
|
|
246
|
+
/** The asks this spec answered already, so one page answers each once. */
|
|
247
|
+
readonly answered = new Set<number>();
|
|
248
|
+
|
|
249
|
+
/** The hand: an action now, as its system, recorded in the take. What the desktop and every app fire into. */
|
|
250
|
+
fire(name: string, args?: unknown): Promise<unknown> {
|
|
251
|
+
const r = this.act(name, args);
|
|
252
|
+
return r.error ? Promise.reject(new Error(r.error)) : Promise.resolve(r.value);
|
|
253
|
+
}
|
|
254
|
+
fireIn(name: string, args?: unknown): Promise<{ value: unknown; round: number | undefined }> { return this.fire(name, args).then((value) => ({ value, round: this.t.round })); }
|
|
255
|
+
read(name: string, args?: unknown): Promise<unknown> { try { return Promise.resolve((this.t.read as unknown as (n: string, a: unknown) => unknown)(name, args ?? {})); } catch (e) { return Promise.reject(e); } }
|
|
256
|
+
raw(_op: Raw): Promise<unknown> { return Promise.reject(new Error("the Maker's spec takes actions, not raw writes")); }
|
|
257
|
+
hand: Hand = { fire: (name, args) => { void this.fire(name, args).catch(() => {}); } };
|
|
258
|
+
|
|
259
|
+
/** An expectation: this entity, as it is now, at this point of the take. */
|
|
260
|
+
expect(entity: number, label?: string): void {
|
|
261
|
+
this.markTime();
|
|
262
|
+
const parts = this.replica.of(entity);
|
|
263
|
+
this.expectations = [...this.expectations, { at: this.take.length, label: label ?? `#${entity} is ${Object.keys(parts).join(", ")}`, entity, parts }];
|
|
264
|
+
this.notify();
|
|
265
|
+
}
|
|
266
|
+
forgetExpectation(i: number): void { this.expectations = this.expectations.filter((_, k) => k !== i); this.notify(); }
|
|
267
|
+
|
|
268
|
+
/** A note: the person's words on an entity, at this point of the take, for the agent to read and the file to carry. */
|
|
269
|
+
note(entity: number | null, text: string): void {
|
|
270
|
+
if (!text.trim()) return;
|
|
271
|
+
this.markTime();
|
|
272
|
+
this.notes = [...this.notes, { at: this.take.length, entity, text: text.trim() }];
|
|
273
|
+
this.notify();
|
|
274
|
+
}
|
|
275
|
+
forgetNote(i: number): void { this.notes = this.notes.filter((_, k) => k !== i); this.notify(); }
|
|
276
|
+
|
|
277
|
+
private touch(): void {
|
|
278
|
+
if (!this.pristine) return;
|
|
279
|
+
this.pristine = false;
|
|
280
|
+
if (this.running && this.running.kind === "canonical") { this.running.kind = "exploratory"; this.log = [...this.log, `the hand moved during "${this.running.scenario.name}": the run is exploratory from here`]; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Starts a story on the state as it is: canonical from a pristine seed, exploratory otherwise. */
|
|
284
|
+
start(sc: SpecScenario): void {
|
|
285
|
+
this.running = null;
|
|
286
|
+
const steps = linesOf(sc.script, { ...this.params, ...(sc.params ?? {}) });
|
|
287
|
+
const gen = liveRun(this.spec, sc, this.t, this.params);
|
|
288
|
+
this.running = { scenario: sc, steps, gen, waitUntil: null, kind: this.pristine ? "canonical" : "exploratory", at: -1, checks: new Map(), errors: new Map(), changes: new Map(), result: null };
|
|
289
|
+
this.before = this.t.w.snapshot();
|
|
290
|
+
this.drive();
|
|
291
|
+
if (this.phase !== "playing") this.play();
|
|
292
|
+
this.notify();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Runs the story's generator until it asks for time, or ends; records what each step changed. */
|
|
296
|
+
private drive(): void {
|
|
297
|
+
const r = this.running;
|
|
298
|
+
if (!r) return;
|
|
299
|
+
for (;;) {
|
|
300
|
+
const n = r.gen.next();
|
|
301
|
+
if (n.done) { r.result = n.value; this.settle(r, r.at); this.sync(); this.notify(); return; }
|
|
302
|
+
const p = n.value;
|
|
303
|
+
if (p.index !== r.at) { this.settle(r, r.at); r.at = p.index; }
|
|
304
|
+
if (p.kind === "step") { this.markTime(); this.take.push({ rec: p.rec }); if (p.rec.error) r.errors.set(p.index, p.rec.error); }
|
|
305
|
+
if (p.kind === "check") r.checks.set(p.index, p.rec);
|
|
306
|
+
if (p.kind === "tick") { r.waitUntil = p.to; this.sync(); this.notify(); return; }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** What the step just finished changed, kept against its index. */
|
|
311
|
+
private settle(r: Running, index: number): void {
|
|
312
|
+
const now = this.t.w.snapshot();
|
|
313
|
+
if (index >= 0 && this.before) r.changes.set(index, diffStates(this.before, now));
|
|
314
|
+
this.before = now;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// HookStore
|
|
318
|
+
subscribe = (fn: () => void): (() => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
|
|
319
|
+
private notify(): void { for (const fn of this.listeners) fn(); }
|
|
320
|
+
retain = () => () => {};
|
|
321
|
+
committed = () => {};
|
|
322
|
+
wantAll = () => () => {};
|
|
323
|
+
wantPresence = () => () => {};
|
|
324
|
+
wantValues = () => () => {};
|
|
325
|
+
wantPart = () => () => {};
|
|
326
|
+
wantResource = () => () => {};
|
|
327
|
+
wantSelect = () => () => {};
|
|
328
|
+
|
|
329
|
+
dispose(): void { this.pause(); }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- the page --------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/** Lucide's icons the bars use, inlined: the framework depends on React alone. */
|
|
335
|
+
const ICON: Record<string, ReactNode> = {
|
|
336
|
+
back: <path d="m15 18-6-6 6-6" />,
|
|
337
|
+
play: <polygon points="6 3 20 12 6 21 6 3" />,
|
|
338
|
+
pause: <><rect x="14" y="4" width="4" height="16" rx="1" /><rect x="6" y="4" width="4" height="16" rx="1" /></>,
|
|
339
|
+
clock: <><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>,
|
|
340
|
+
stories: <><path d="M12 7v14" /><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" /></>,
|
|
341
|
+
model: <><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></>,
|
|
342
|
+
outside: <><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" /></>,
|
|
343
|
+
take: <><path d="m3 17 2 2 4-4" /><path d="m3 7 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></>,
|
|
344
|
+
debug: <path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />,
|
|
345
|
+
reset: <><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /></>,
|
|
346
|
+
view: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M10 4v4" /><path d="M2 8h20" /><path d="M6 4v4" /></>,
|
|
347
|
+
spec: <><path d="M7 20h10" /><path d="M10 20c5.5-2.5.8-6.4 3-10" /><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z" /><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z" /></>,
|
|
348
|
+
};
|
|
349
|
+
function Icon({ name }: { name: keyof typeof ICON }) {
|
|
350
|
+
return <svg className="bv-bar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{ICON[name]}</svg>;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface MakerProps {
|
|
354
|
+
/** The specs the view bundles: the baba's starting states. */
|
|
355
|
+
specs: readonly Spec<any, any>[];
|
|
356
|
+
/** The baba's apps, by key, as the interface composes them (the Maker aside): a spec is viewed with the ones that fit it, drawn over the simulation. */
|
|
357
|
+
apps?: Record<string, ComponentType<AppProps>>;
|
|
358
|
+
/** What the baba is called, for the home. */
|
|
359
|
+
title?: string;
|
|
360
|
+
path?: string;
|
|
361
|
+
navigate?: (path: string) => void;
|
|
362
|
+
/** Names the shell's window after what is shown: the Maker, or the spec open. */
|
|
363
|
+
setTitle?: (title: string) => void;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The Maker: the baba with its starting states, and one open on its stage. */
|
|
367
|
+
export function Maker({ specs, apps = {}, title, path = "/", navigate, setTitle }: MakerProps) {
|
|
368
|
+
const open = /^\/spec\/([a-z0-9-]+)/.exec(path)?.[1] ?? null;
|
|
369
|
+
const spec = open ? specs.find((w) => w.name === open) : undefined;
|
|
370
|
+
const go = navigate ?? (() => {});
|
|
371
|
+
useEffect(() => { setTitle?.(spec ? `Maker · ${spec.name}` : "Maker"); }, [setTitle, spec]);
|
|
372
|
+
if (spec) return <Lab key={spec.name} spec={spec} apps={apps} back={() => go("/")} />;
|
|
373
|
+
return <Home specs={specs} title={title} open={(n) => go(`/spec/${n}`)} />;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Home: the baba, and each starting state of it with the standing of its stories. */
|
|
377
|
+
function Home({ specs, title, open }: { specs: readonly Spec<any, any>[]; title?: string; open: (name: string) => void }) {
|
|
378
|
+
const { w, fire } = useBabaState();
|
|
379
|
+
const rows = w.query(maker.specFile).map((r) => ({ entity: r.entity, name: r.specFile.name, updated: r.specFile.updated, code: r.specFile.code, v: w.get(r.entity, maker.specVerdicts), broken: w.get(r.entity, maker.specVerdictsBroken) }));
|
|
380
|
+
const run = w.get(maker.specRun);
|
|
381
|
+
const running = w.running(maker.specTest);
|
|
382
|
+
return (
|
|
383
|
+
<div className="bv-maker">
|
|
384
|
+
<header className="bv-maker-head"><h2>{title ?? "This baba"}</h2><span className="bv-muted">{specs.length} starting state{specs.length === 1 ? "" : "s"}</span><Button variant="quiet" disabled={running} onClick={() => void fire("spec-run" as never, {} as never)}>{running ? "running every story…" : "run every story"}</Button></header>
|
|
385
|
+
{run && run.code !== null && run.code !== 0 && <pre className="bv-maker-output">{run.output}</pre>}
|
|
386
|
+
<ul className="bv-maker-list">
|
|
387
|
+
{specs.map((sp) => {
|
|
388
|
+
const d = rows.find((r) => r.name === sp.name);
|
|
389
|
+
const v = d?.v;
|
|
390
|
+
const stale = !!v && !!d && (v.at < d.updated || v.at < d.code);
|
|
391
|
+
const counts = v ? { passed: v.scenarios.filter((x) => x.verdict === "passed").length, failed: v.scenarios.filter((x) => x.verdict === "failed").length, unknown: v.scenarios.filter((x) => x.verdict === "unknown").length } : null;
|
|
392
|
+
return (
|
|
393
|
+
<li key={sp.name} className="bv-maker-spec">
|
|
394
|
+
<button type="button" className="bv-maker-open" onClick={() => open(sp.name)}><b>{sp.name}</b>{sp.summary && <span className="bv-muted"> {sp.summary}</span>}</button>
|
|
395
|
+
<span className="bv-maker-facts">
|
|
396
|
+
<span className="bv-muted">{sp.baba.systems.length} system{sp.baba.systems.length === 1 ? "" : "s"} · {sp.scenarios.length} stor{sp.scenarios.length === 1 ? "y" : "ies"}</span>
|
|
397
|
+
{counts ? <span className={counts.failed ? "bv-verdict is-failed" : counts.unknown ? "bv-verdict is-sketch" : "bv-verdict is-passed"}>{counts.failed ? `${counts.failed} broken` : counts.unknown ? `${counts.unknown} unknown` : `${counts.passed} hold`}{stale ? " · stale" : ""}</span> : <span className="bv-verdict is-sketch">{d?.broken ? "verdicts unreadable" : "never run"}</span>}
|
|
398
|
+
<Button variant="quiet" disabled={running} onClick={() => void fire("spec-run" as never, { spec: sp.name } as never)}>run</Button>
|
|
399
|
+
</span>
|
|
400
|
+
</li>
|
|
401
|
+
);
|
|
402
|
+
})}
|
|
403
|
+
</ul>
|
|
404
|
+
{specs.length === 0 && <p className="bv-muted">No starting states: a system's spec is <code>systems/NAME/spec.ts</code>, exporting <code>spec(system, {"{"} scenarios: […] {"}"})</code>; the whole baba's is <code>.baba/spec.ts</code>. They are found where they are, with no list to keep.</p>}
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const useSim = (sim: Simulation) => useSyncExternalStore(sim.subscribe, () => sim.replica);
|
|
410
|
+
|
|
411
|
+
type Widget = "spec" | "app" | "clock" | "stories" | "model" | "outside" | "take" | "debug";
|
|
412
|
+
|
|
413
|
+
/** The app a person picked for a spec, kept in this browser: a convenience, so nothing breaks without storage. */
|
|
414
|
+
const pickKey = (spec: string) => `voss.maker.app.${spec}`;
|
|
415
|
+
const pickedApp = (spec: string): string | null => { try { return localStorage.getItem(pickKey(spec)); } catch { return null; } };
|
|
416
|
+
const keepApp = (spec: string, key: string) => { try { localStorage.setItem(pickKey(spec), key); } catch { /* the pick holds for this page */ } };
|
|
417
|
+
|
|
418
|
+
/** One of the baba's apps as a subview: inert AppProps, its path and its memory its own, nothing navigated outside it. */
|
|
419
|
+
function Subview({ View, routes }: { View: ComponentType<AppProps>; routes?: readonly string[] }) {
|
|
420
|
+
const [path, setPath] = useState("/");
|
|
421
|
+
const [memory, setMemory] = useState<Record<string, unknown>>({});
|
|
422
|
+
const remember = (key: string, value: unknown) => setMemory((m) => (value === null ? Object.fromEntries(Object.entries(m).filter(([k]) => k !== key)) : { ...m, [key]: value }));
|
|
423
|
+
return <View session="maker" window={0} path={path} {...routeOf(routes, path)} active memory={memory} navigate={(p) => { if (!routes?.length || matchRoute(routes, p)) setPath(p); }} open={() => {}} setTitle={() => {}} remember={remember} />;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** An app that throws says so in the frame, and the rest of the Maker goes on. */
|
|
427
|
+
class AppBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
|
428
|
+
override state = { error: null as string | null };
|
|
429
|
+
static getDerivedStateFromError(error: unknown) { return { error: error instanceof Error ? error.message : String(error) }; }
|
|
430
|
+
override render() { return this.state.error ? <div className="bv-lab-bare" role="alert"><p className="bv-error">The app failed over this spec: {this.state.error}</p><Button variant="quiet" onClick={() => this.setState({ error: null })}>try again</Button></div> : this.props.children; }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The specs this page keeps, by name, outside the interface's module: a
|
|
435
|
+
* code change swaps the module and this registry hands the new Lab the same
|
|
436
|
+
* spec, which adopts the new spec. A spec lives as long as the page.
|
|
437
|
+
*/
|
|
438
|
+
const registry: Map<string, Simulation> = ((globalThis as { __bvMakerSpecs?: Map<string, Simulation> }).__bvMakerSpecs ??= new Map());
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* A spec open: its view fills the page, one of the baba's apps that fits
|
|
442
|
+
* the spec or the desktop, over the simulation; a status bar along the
|
|
443
|
+
* bottom carries one line per thing to know, each opening a popover with
|
|
444
|
+
* the rest.
|
|
445
|
+
*/
|
|
446
|
+
function Lab({ spec, apps, back }: { spec: Spec<any, any>; apps: Record<string, ComponentType<AppProps>>; back: () => void }) {
|
|
447
|
+
const sim = useMemo(() => { const have = registry.get(spec.name); if (have) { have.adopt(spec); return have; } const made = new Simulation(spec); registry.set(spec.name, made); return made; }, [spec]);
|
|
448
|
+
const replica = useSim(sim);
|
|
449
|
+
const { w: live, fire: liveFire } = useBabaState();
|
|
450
|
+
// An agent's asks of this spec, answered here, each once: the agent acts in and reads what the person sees.
|
|
451
|
+
const asks = live.query(maker.specAsk).filter((r) => r.specAsk.spec === spec.name && r.specAsk.status === "asked");
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
for (const r of asks) {
|
|
454
|
+
if (sim.answered.has(r.entity)) continue;
|
|
455
|
+
sim.answered.add(r.entity);
|
|
456
|
+
let ok = true, answer: unknown;
|
|
457
|
+
try { answer = sim.ask(r.specAsk.kind, r.specAsk.program); } catch (e) { ok = false; answer = e instanceof Error ? e.message : String(e); }
|
|
458
|
+
void (liveFire as unknown as (n: string, a: unknown) => Promise<unknown>)("spec-answer", { entity: r.entity, ok, answer }).catch(() => {});
|
|
459
|
+
}
|
|
460
|
+
}, [asks, sim, liveFire]);
|
|
461
|
+
const verdicts = live.query(maker.specFile).find((r) => r.specFile.name === spec.name);
|
|
462
|
+
const v = verdicts ? live.get(verdicts.entity, maker.specVerdicts) : undefined;
|
|
463
|
+
const [open, setOpen] = useState<Widget | null>(null);
|
|
464
|
+
// The apps that fit this spec, from the apps the baba declares; the person's pick, else the first.
|
|
465
|
+
const manifest = useManifest();
|
|
466
|
+
const declared: readonly AppSpec[] = manifest?.apps ?? spec.baba.apps;
|
|
467
|
+
const choices = useMemo(() => appsFor(spec, declared, apps), [spec, declared, apps]);
|
|
468
|
+
const [picked, setPicked] = useState<string | null>(() => pickedApp(spec.name));
|
|
469
|
+
const choice = choices.find((c) => c.key === picked) ?? choices[0];
|
|
470
|
+
const pick = (key: string) => { setPicked(key); keepApp(spec.name, key); setOpen(null); };
|
|
471
|
+
const fitting = useMemo(() => Object.fromEntries(choices.filter((c) => c.kind === "app").map((c) => [c.key, apps[c.key]!])), [choices, apps]);
|
|
472
|
+
const shell = (spec.baba.systems as readonly unknown[]).find((x) => nameOf(x) === "desktop") as DesktopSystem | undefined;
|
|
473
|
+
const view = !choice ? <div className="bv-lab-bare"><BareState w={replica} /></div>
|
|
474
|
+
: choice.kind === "app" ? <Subview key={choice.key} View={apps[choice.key]!} routes={declared.find((sc) => sc.key === choice.key)?.routes} />
|
|
475
|
+
: shell ? <SpecDesktop system={shell} apps={fitting} title={spec.name} />
|
|
476
|
+
: <LocalDesktop key={choices.length} apps={declared.filter((sc: AppSpec) => sc.key in fitting)} components={fitting} title={spec.name} open={choices.find((c) => c.kind === "app")?.key} />;
|
|
477
|
+
const r = sim.running;
|
|
478
|
+
const knobs = Object.entries(spec.params as Record<string, unknown>);
|
|
479
|
+
const held = v ? v.scenarios.filter((x) => x.verdict === "passed").length : 0;
|
|
480
|
+
const broken = v ? v.scenarios.filter((x) => x.verdict === "failed").length : 0;
|
|
481
|
+
const effects = spec.baba.model.effects as { name: string; owner: string }[];
|
|
482
|
+
const played = effects.filter((e) => isPlayedByName(spec, e)).length;
|
|
483
|
+
// The quiet effects a job asked of: what an adapter would have to play, for whoever asks.
|
|
484
|
+
const quiet = new Set(sim.t.jobs.filter((j) => !isPlayed(spec.adapters, j.effect)).map((j) => j.effect.name)).size;
|
|
485
|
+
const toggle = (k: Widget) => setOpen((o) => (o === k ? null : k));
|
|
486
|
+
const stories = r ? `${r.scenario.name} · ${r.result ? (r.result.verdict === "passed" ? "held" : r.kind === "exploratory" ? "warnings" : "broke") : r.waitUntil !== null ? "waiting" : "running"}` : `${spec.scenarios.length} stor${spec.scenarios.length === 1 ? "y" : "ies"}${v ? ` · ${broken ? `${broken} broken` : `${held} hold`}` : ""}`;
|
|
487
|
+
const storyTone = r ? (r.result ? (r.result.verdict === "passed" ? "is-ok" : r.kind === "exploratory" ? "is-warn" : "is-bad") : "is-here") : broken ? "is-bad" : "";
|
|
488
|
+
return (
|
|
489
|
+
<div className="bv-lab">
|
|
490
|
+
<header className="bv-bar bv-bar-head">
|
|
491
|
+
<button type="button" className="bv-bar-item bv-bar-back" onClick={back} title="every starting state"><Icon name="back" /></button>
|
|
492
|
+
<Item id="spec" open={open} toggle={toggle} tone={sim.pristine ? "" : "is-warn"} label={<><Icon name="spec" /><b>{spec.name}</b><span className="bv-muted">{sim.pristine ? "as seeded" : "played"}{sim.carried ? ` · carried ×${sim.carried}` : ""}</span></>}>
|
|
493
|
+
<p className="bv-bar-summary">{spec.summary || <span className="bv-muted">No summary.</span>}</p>
|
|
494
|
+
<p className="bv-muted">{spec.baba.systems.length} system{spec.baba.systems.length === 1 ? "" : "s"}: {spec.baba.systems.map(nameOf).join(", ")}</p>
|
|
495
|
+
{knobs.length > 0 && <div className="bv-lab-knobs">{knobs.map(([k, dflt]) => <Knob key={k} name={k} value={sim.params[k]} dflt={dflt} set={(val) => sim.reset({ ...sim.params, [k]: val })} />)}</div>}
|
|
496
|
+
<div className="bv-lab-answer"><Button variant="quiet" onClick={() => { sim.reset(); setOpen(null); }}><Icon name="reset" /> reset to the seed</Button></div>
|
|
497
|
+
</Item>
|
|
498
|
+
<Item id="app" open={open} toggle={toggle} label={<><Icon name="app" />{choice?.title ?? <span className="bv-muted">the state bare</span>}</>}>
|
|
499
|
+
{choices.length === 0
|
|
500
|
+
? <p className="bv-muted">No app fits this spec: an app fits when the systems it names in <code>baba({"{"} apps {"}"})</code> are all here.</p>
|
|
501
|
+
: <ul className="bv-lab-views">{choices.map((c) => <li key={c.key}><Button variant="quiet" className={c.key === choice?.key ? "is-open" : ""} onClick={() => pick(c.key)}>{c.title}</Button></li>)}</ul>}
|
|
502
|
+
</Item>
|
|
503
|
+
<Item id="stories" open={open} toggle={toggle} tone={storyTone} label={<><Icon name="stories" />{stories}</>} wide>
|
|
504
|
+
<Stories sim={sim} spec={spec} verdicts={v} />
|
|
505
|
+
</Item>
|
|
506
|
+
<span className="bv-bar-gap" />
|
|
507
|
+
<button type="button" className={`bv-bar-item bv-bar-play${sim.phase === "playing" ? " is-on" : ""}`} onClick={() => (sim.phase === "playing" ? sim.pause() : sim.play())} title={sim.phase === "playing" ? "pause" : "play"}><Icon name={sim.phase === "playing" ? "pause" : "play"} /></button>
|
|
508
|
+
<Item id="clock" open={open} toggle={toggle} align="right" label={<><Icon name="clock" /><span className="bv-bar-time">{(sim.t.now / 1000).toFixed(1)} s</span>{sim.speed !== 1 && <span className="bv-muted">×{sim.speed}</span>}</>}>
|
|
509
|
+
<div className="bv-lab-transport">
|
|
510
|
+
<label className="bv-muted">speed <select value={sim.speed} onChange={(e) => sim.setSpeed(Number(e.target.value))}>{[0.25, 0.5, 1, 2, 4, 8].map((s) => <option key={s} value={s}>{s}×</option>)}</select></label>
|
|
511
|
+
<Button variant="quiet" onClick={() => sim.tick()}>one round</Button>
|
|
512
|
+
<span className="bv-muted">round {sim.t.round}</span>
|
|
513
|
+
</div>
|
|
514
|
+
</Item>
|
|
515
|
+
</header>
|
|
516
|
+
<main className="bv-lab-app">
|
|
517
|
+
<div className={`bv-lab-frame${sim.phase === "playing" ? " is-playing" : ""}`}>
|
|
518
|
+
{/* Every hook inside reads the simulation, and every fire, useBabaState's or useHand's, acts on it. */}
|
|
519
|
+
<LiveStoreContext.Provider value={sim}>
|
|
520
|
+
<AppBoundary key={choice?.key ?? ""}>{view}</AppBoundary>
|
|
521
|
+
</LiveStoreContext.Provider>
|
|
522
|
+
</div>
|
|
523
|
+
</main>
|
|
524
|
+
<footer className="bv-bar">
|
|
525
|
+
<Item id="model" open={open} toggle={toggle} label={<><Icon name="model" />model</>} wide>
|
|
526
|
+
<Model sim={sim} replica={replica} />
|
|
527
|
+
</Item>
|
|
528
|
+
<Item id="outside" open={open} toggle={toggle} label={<><Icon name="outside" />outside<span className="bv-muted">{played} played{quiet ? ` · ${quiet} quiet` : ""}</span></>} wide>
|
|
529
|
+
<Outside sim={sim} />
|
|
530
|
+
</Item>
|
|
531
|
+
<span className="bv-bar-gap" />
|
|
532
|
+
<Item id="take" open={open} toggle={toggle} align="right" label={<><Icon name="take" />take<span className="bv-muted">{sim.take.length}{sim.expectations.length ? ` · ${sim.expectations.length} expected` : ""}{sim.notes.length ? ` · ${sim.notes.length} noted` : ""}</span></>} wide>
|
|
533
|
+
<Take sim={sim} />
|
|
534
|
+
</Item>
|
|
535
|
+
<Item id="debug" open={open} toggle={toggle} align="right" label={<><Icon name="debug" /><span className="bv-muted">{sim.t.jobs.length} job{sim.t.jobs.length === 1 ? "" : "s"} · round {sim.t.round}</span></>} wide>
|
|
536
|
+
<Debug sim={sim} />
|
|
537
|
+
</Item>
|
|
538
|
+
</footer>
|
|
539
|
+
</div>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** One widget of a bar: a one-liner, and the popover it opens beside itself, above a footer's and below a header's. */
|
|
544
|
+
function Item({ id, open, toggle, label, tone = "", align = "left", wide = false, children }: { id: Widget; open: Widget | null; toggle: (k: Widget) => void; label: ReactNode; tone?: string; align?: "left" | "right"; wide?: boolean; children: ReactNode }) {
|
|
545
|
+
const isOpen = open === id;
|
|
546
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
if (!isOpen) return;
|
|
549
|
+
const down = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) toggle(id); };
|
|
550
|
+
const key = (e: KeyboardEvent) => { if (e.key === "Escape") toggle(id); };
|
|
551
|
+
document.addEventListener("mousedown", down);
|
|
552
|
+
document.addEventListener("keydown", key);
|
|
553
|
+
return () => { document.removeEventListener("mousedown", down); document.removeEventListener("keydown", key); };
|
|
554
|
+
}, [isOpen, id, toggle]);
|
|
555
|
+
return (
|
|
556
|
+
<div ref={ref} className={`bv-bar-slot${isOpen ? " is-open" : ""}`}>
|
|
557
|
+
<button type="button" className={`bv-bar-item ${tone}${isOpen ? " is-open" : ""}`} onClick={() => toggle(id)}>{label}</button>
|
|
558
|
+
{isOpen && <div className={`bv-popover is-${align}${wide ? " is-wide" : ""}`} role="dialog">{children}</div>}
|
|
559
|
+
</div>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function Knob({ name, value, dflt, set }: { name: string; value: unknown; dflt: unknown; set: (v: unknown) => void }) {
|
|
564
|
+
const kind = typeof dflt;
|
|
565
|
+
if (kind === "boolean") return <label className="bv-muted"><input type="checkbox" checked={!!value} onChange={(e) => set(e.target.checked)} /> {name}</label>;
|
|
566
|
+
return <label className="bv-muted">{name} <input className="bv-lab-knob" value={String(value ?? "")} onChange={(e) => set(kind === "number" ? Number(e.target.value) : e.target.value)} /></label>;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The stories of the spec, each with its headless standing and a play, and the one under way with its steps. */
|
|
570
|
+
function Stories({ sim, spec, verdicts }: { sim: Simulation; spec: Spec<any, any>; verdicts?: { scenarios: { name: string; verdict: string; failed: string[] }[] } }) {
|
|
571
|
+
const r = sim.running;
|
|
572
|
+
return (
|
|
573
|
+
<div>
|
|
574
|
+
<ul className="bv-lab-scenarios">
|
|
575
|
+
{spec.scenarios.map((sc) => {
|
|
576
|
+
const hv = verdicts?.scenarios.find((x) => x.name === sc.name);
|
|
577
|
+
const here = r?.scenario === sc ? r : null;
|
|
578
|
+
return (
|
|
579
|
+
<li key={sc.name} className={here ? "is-open" : ""}>
|
|
580
|
+
<div className="bv-lab-scenario-row">
|
|
581
|
+
<span className={`bv-verdict is-${hv?.verdict === "passed" ? "passed" : hv?.verdict === "failed" ? "failed" : "sketch"}`} title={hv ? `headless: ${hv.verdict}${hv.failed.length ? `\n${hv.failed.join("\n")}` : ""}` : "never run headless"}>{hv?.verdict === "passed" ? "holds" : hv?.verdict === "failed" ? "broken" : "unknown"}</span>
|
|
582
|
+
<b title={sc.name}>{sc.name}</b>
|
|
583
|
+
<Button variant="quiet" onClick={() => sim.start(sc)}>{here && !here.result ? "restart" : "play"}</Button>
|
|
584
|
+
</div>
|
|
585
|
+
<p className="bv-muted">{sc.intent}</p>
|
|
586
|
+
</li>
|
|
587
|
+
);
|
|
588
|
+
})}
|
|
589
|
+
{spec.scenarios.length === 0 && <li className="bv-muted">No stories yet. Play, then keep the take as one.</li>}
|
|
590
|
+
</ul>
|
|
591
|
+
{r && <Story sim={sim} />}
|
|
592
|
+
</div>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** A step of a story in words, before it runs. */
|
|
597
|
+
function stepWords(step: Line<any>): string {
|
|
598
|
+
switch (step.kind) {
|
|
599
|
+
case "given": return `given ${step.label}`;
|
|
600
|
+
case "fire": return `${step.name} ${Object.keys((step.args as object) ?? {}).length ? JSON.stringify(step.args) : ""}`.trim();
|
|
601
|
+
case "tick": return step.at !== undefined ? `time passes to ${(step.at / 1000).toFixed(1)} s` : `time passes ${step.after ?? 200} ms`;
|
|
602
|
+
case "ticks": return `time passes ${step.count} × ${step.every} ms`;
|
|
603
|
+
case "done": return `${step.effect.name} answers ${JSON.stringify(step.result)}`;
|
|
604
|
+
case "failed": return `${step.effect.name} fails: ${step.error}`;
|
|
605
|
+
case "list": return `${step.effect.name} lists ${JSON.stringify(step.items)}`;
|
|
606
|
+
case "watch": return `${step.effect.name} sends ${JSON.stringify(step.events)}`;
|
|
607
|
+
case "check": return `expect ${step.label}`;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** The story under way: its steps in words, how far it got, what each changed, and each expectation against what the spec did. */
|
|
612
|
+
function Story({ sim }: { sim: Simulation }) {
|
|
613
|
+
const r = sim.running;
|
|
614
|
+
if (!r) return null;
|
|
615
|
+
const warn = r.kind === "exploratory";
|
|
616
|
+
return (
|
|
617
|
+
<div className="bv-lab-story-wrap">
|
|
618
|
+
<p className="bv-lab-story-head"><b>{r.scenario.name}</b> <span className="bv-muted">{r.kind}{r.result ? ` · ${r.result.verdict === "passed" ? "held" : warn ? "did not hold, warnings" : "broke"}` : ""}</span></p>
|
|
619
|
+
<ol className="bv-lab-story">
|
|
620
|
+
{r.steps.map((step, i) => {
|
|
621
|
+
const done = i < r.at || (i === r.at && (r.result !== null || r.waitUntil === null));
|
|
622
|
+
const here = i === r.at && !r.result;
|
|
623
|
+
const check = r.checks.get(i), error = r.errors.get(i), ch = r.changes.get(i);
|
|
624
|
+
const tone = check ? (check.ok ? "is-ok" : warn ? "is-warn" : "is-bad") : error ? (warn ? "is-warn" : "is-bad") : done ? "is-done" : here ? "is-here" : "";
|
|
625
|
+
return (
|
|
626
|
+
<li key={i} className={tone}>
|
|
627
|
+
<span className="bv-film-mark">{check ? (check.ok ? "✓" : warn ? "!" : "✗") : error ? (warn ? "!" : "✗") : done ? "·" : here ? "▸" : " "}</span>
|
|
628
|
+
<span>{stepWords(step)}</span>
|
|
629
|
+
{check && !check.ok && (check.error ? <span className="bv-film-error"> {check.error}</span> : check.want !== undefined ? <span className="bv-muted">: got <code>{JSON.stringify(check.got)}</code>, want <code>{JSON.stringify(check.want)}</code></span> : null)}
|
|
630
|
+
{error && <span className="bv-film-error"> {error}</span>}
|
|
631
|
+
{ch && (ch.entities.length > 0 || ch.resources.length > 0) && <Changes changes={ch} />}
|
|
632
|
+
</li>
|
|
633
|
+
);
|
|
634
|
+
})}
|
|
635
|
+
</ol>
|
|
636
|
+
</div>
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** What a step changed, in object terms. */
|
|
641
|
+
function Changes({ changes }: { changes: { entities: Change[]; resources: ResourceChange[] } }) {
|
|
642
|
+
return (
|
|
643
|
+
<ul className="bv-lab-changes">
|
|
644
|
+
{changes.entities.map((c) => <li key={c.entity}><code>#{c.entity}</code> <span className="bv-muted">{c.kind}</span> {Object.entries(c.parts).map(([n, v]) => <span key={n} className="bv-state-part"><b>{n}</b>{v === null ? <span className="bv-muted"> gone</span> : v === true ? "" : <code>{JSON.stringify(v)}</code>}</span>)}</li>)}
|
|
645
|
+
{changes.resources.map((c) => <li key={c.name}><b>{c.name}</b> <code>{JSON.stringify(c.value)}</code></li>)}
|
|
646
|
+
</ul>
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The state as objects: the OOUX lens over the simulated state, with the hand, and an expectation to keep from any instance. */
|
|
651
|
+
function Model({ sim, replica }: { sim: Simulation; replica: Replica<any> }) {
|
|
652
|
+
const manifest = useManifest();
|
|
653
|
+
// The baba's own objects first; the desktop's, machinery to a maker, last.
|
|
654
|
+
const types = useMemo(() => (manifest ? objectsOf(manifest, replica).filter((t) => sim.spec.baba.systems.some((c: unknown) => nameOf(c) === t.owner)).sort((a, b) => Number(a.owner === "desktop") - Number(b.owner === "desktop")) : []), [manifest, replica, sim.spec]);
|
|
655
|
+
const [pick, setPick] = useState<string | null>(null);
|
|
656
|
+
const type = types.find((t) => t.name === pick) ?? types.find((t) => instancesOf(replica, t).length > 0) ?? types[0];
|
|
657
|
+
if (!manifest) return <p className="bv-muted">…</p>;
|
|
658
|
+
if (types.length === 0) return <p className="bv-muted">No objects yet: a component with a key, or one known by its entity.</p>;
|
|
659
|
+
return (
|
|
660
|
+
<div className="bv-lab-model">
|
|
661
|
+
<ObjectMap types={types} w={replica} picked={type?.name ?? null} onPick={setPick} />
|
|
662
|
+
{type && <ExpectRow replica={replica} type={type} sim={sim} />}
|
|
663
|
+
{type && <ObjectCards w={replica} type={type} types={types} hand={sim.hand} onFollow={(n) => setPick(n)} />}
|
|
664
|
+
</div>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function ExpectRow({ replica, type, sim }: { replica: Replica<any>; type: ObjectType; sim: Simulation }) {
|
|
669
|
+
const rows = instancesOf(replica, type);
|
|
670
|
+
const [on, setOn] = useState<number | null>(null);
|
|
671
|
+
const [text, setText] = useState("");
|
|
672
|
+
if (rows.length === 0) return null;
|
|
673
|
+
return (
|
|
674
|
+
<div className="bv-lab-expect">
|
|
675
|
+
<p className="bv-muted">Keep as expected: {rows.slice(0, 12).map((r) => <Button key={r.entity} variant="quiet" onClick={() => sim.expect(r.entity, `${type.name} #${r.entity} as it is`)}>#{r.entity}</Button>)}</p>
|
|
676
|
+
<p className="bv-muted">Note on: {rows.slice(0, 12).map((r) => <Button key={r.entity} variant="quiet" className={on === r.entity ? "is-open" : ""} onClick={() => setOn(on === r.entity ? null : r.entity)}>#{r.entity}</Button>)}</p>
|
|
677
|
+
<div className="bv-lab-answer"><input value={text} placeholder={on === null ? "a note on the spec, in your words" : `a note on #${on}, in your words`} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { sim.note(on, text); setText(""); } }} /><Button variant="quiet" disabled={!text.trim()} onClick={() => { sim.note(on, text); setText(""); }}>note</Button></div>
|
|
678
|
+
</div>
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const isPlayedByName = (sp: Spec<any, any>, e: { name: string; owner: string }) => (sp.adapters as readonly (readonly [{ name: string; owner: string }, unknown])[]).some(([a]) => a.name === e.name && a.owner === e.owner);
|
|
683
|
+
|
|
684
|
+
/** The outside: the effects the spec plays, each with its adapter and the state its model keeps; then the quiet ones a job asked of; then the rest. */
|
|
685
|
+
function Outside({ sim }: { sim: Simulation }) {
|
|
686
|
+
const adapters = sim.spec.adapters as readonly (readonly [{ name: string; owner: string }, { kind: string }])[];
|
|
687
|
+
const entries = [...sim.t.outside.entries()].filter(([, st]) => st.size > 0);
|
|
688
|
+
const effects = sim.spec.baba.model.effects as { name: string; owner: string; shape: string }[];
|
|
689
|
+
const asked = new Set(sim.t.jobs.map((j) => j.effect.name));
|
|
690
|
+
const played = effects.filter((e) => isPlayedByName(sim.spec, e));
|
|
691
|
+
const quiet = effects.filter((e) => !isPlayedByName(sim.spec, e) && asked.has(e.name));
|
|
692
|
+
const rest = effects.filter((e) => !isPlayedByName(sim.spec, e) && !asked.has(e.name));
|
|
693
|
+
const row = (e: { name: string; owner: string }, note: string) => {
|
|
694
|
+
const st = entries.find(([x]) => x.name === e.name)?.[1];
|
|
695
|
+
return <li key={`${e.owner}/${e.name}`}><b>{e.name}</b> <span className="bv-muted">{e.owner} · {note}</span>{st && <Tree value={Object.fromEntries(st)} />}</li>;
|
|
696
|
+
};
|
|
697
|
+
return (
|
|
698
|
+
<div>
|
|
699
|
+
{played.length > 0 && <><h3>Played</h3><ul className="bv-lab-outside">{played.map((e) => row(e, `${adapters.find(([x]) => x.name === e.name && x.owner === e.owner)![1].kind} adapter`))}</ul></>}
|
|
700
|
+
{quiet.length > 0 && <><h3>Quiet, asked</h3><p className="bv-muted">A job asked of these; no adapter plays them, so they never answer. An adapter would.</p><ul className="bv-lab-outside">{quiet.map((e) => row(e, "quiet"))}</ul></>}
|
|
701
|
+
{rest.length > 0 && <><h3>Quiet</h3><ul className="bv-lab-outside">{rest.map((e) => row(e, "never asked"))}</ul></>}
|
|
702
|
+
{effects.length === 0 && <p className="bv-muted">Nothing reaches outside.</p>}
|
|
703
|
+
</div>
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** A value as a tree, folded where it is deep. */
|
|
708
|
+
function Tree({ value, depth = 0 }: { value: unknown; depth?: number }) {
|
|
709
|
+
if (value === null || typeof value !== "object" || depth > 3) return <code>{JSON.stringify(value)}</code>;
|
|
710
|
+
const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v] as const) : Object.entries(value as Record<string, unknown>);
|
|
711
|
+
return <ul className="bv-lab-tree">{entries.map(([k, v]) => <li key={k}><span className="bv-muted">{k}</span> <Tree value={v} depth={depth + 1} /></li>)}</ul>;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** The take: what the hand did since the seed, the expectations kept, a name and an intent, and the file it becomes. */
|
|
715
|
+
function Take({ sim }: { sim: Simulation }) {
|
|
716
|
+
const { fire } = useBabaState();
|
|
717
|
+
const [name, setName] = useState("");
|
|
718
|
+
const [intent, setIntent] = useState("");
|
|
719
|
+
const [showCode, setShowCode] = useState(false);
|
|
720
|
+
// From the spec's scenarios directory: a system's is .baba/systems/NAME/scenarios, the whole baba's .baba/scenarios.
|
|
721
|
+
const systems = Object.fromEntries(sim.spec.baba.systems.map(nameOf).map((n: string) => [n, sim.spec.name === "baba" ? `../systems/${n}/index.ts` : `../../${n}/index.ts`]));
|
|
722
|
+
const source = useMemo(() => asProgram({ spec: sim.spec.name, name: name || "played", intent, steps: sim.take, expectations: sim.expectations, notes: sim.notes, systems }), [sim.spec.name, name, intent, sim.take.length, sim.expectations.length, sim.notes.length]);
|
|
723
|
+
const rows: ReactNode[] = [];
|
|
724
|
+
const expectRow = (k: number, e: Expectation) => <li key={`e${k}`} className="is-ok"><span className="bv-film-mark">✓</span> expect {e.label} <Button variant="quiet" onClick={() => sim.forgetExpectation(k)}>×</Button></li>;
|
|
725
|
+
const noteRow = (k: number, n: Note) => <li key={`n${k}`} className="is-warn"><span className="bv-film-mark">✎</span> {n.entity !== null ? <code>#{n.entity}</code> : "the spec"}: {n.text} <Button variant="quiet" onClick={() => sim.forgetNote(k)}>×</Button></li>;
|
|
726
|
+
const at = (i: number) => { sim.expectations.forEach((e, k) => { if (e.at === i) rows.push(expectRow(k, e)); }); sim.notes.forEach((n, k) => { if (n.at === i) rows.push(noteRow(k, n)); }); };
|
|
727
|
+
sim.take.forEach((s, i) => {
|
|
728
|
+
at(i);
|
|
729
|
+
rows.push(<li key={i} className={s.rec.error ? "is-bad" : ""}><span className="bv-film-mark">·</span> {wordsOf(s.rec)}</li>);
|
|
730
|
+
});
|
|
731
|
+
at(sim.take.length);
|
|
732
|
+
return (
|
|
733
|
+
<div>
|
|
734
|
+
<p className="bv-muted">{sim.take.length} step{sim.take.length === 1 ? "" : "s"} since the seed, {sim.expectations.length} expectation{sim.expectations.length === 1 ? "" : "s"}, {sim.notes.length} note{sim.notes.length === 1 ? "" : "s"}. Expectations and notes come from the model; the take kept is a story of this spec.</p>
|
|
735
|
+
<ol className="bv-film-checks bv-lab-take">{rows}{rows.length === 0 && <li className="bv-muted">Nothing yet.</li>}</ol>
|
|
736
|
+
<div className="bv-lab-answer"><input value={name} placeholder="the story's name" onChange={(e) => setName(e.target.value)} /></div>
|
|
737
|
+
<div className="bv-lab-answer"><input value={intent} placeholder="what should hold, as a person would say it" onChange={(e) => setIntent(e.target.value)} /></div>
|
|
738
|
+
<div className="bv-lab-answer"><Button variant="quiet" disabled={!name.trim() || (sim.take.length === 0 && sim.notes.length === 0)} onClick={() => void fire("spec-save" as never, { spec: sim.spec.name, file: `${slug(name)}.ts`, source } as never)}>keep as a story</Button><Button variant="quiet" onClick={() => setShowCode((s) => !s)}>{showCode ? "hide the file" : "show the file"}</Button></div>
|
|
739
|
+
{showCode && <pre className="bv-sandbox-code">{source}</pre>}
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Rounds, every job, the log: the machine's view, for whoever needs it. */
|
|
745
|
+
function Debug({ sim }: { sim: Simulation }) {
|
|
746
|
+
const jobs = sim.t.jobs;
|
|
747
|
+
return (
|
|
748
|
+
<div>
|
|
749
|
+
<h3>Jobs</h3>
|
|
750
|
+
<ul className="bv-lab-jobs">{jobs.map((j) => <li key={`${j.effect.name}/${j.entity ?? "·"}/${j.instance}`}><code>{j.effect.name}</code>{j.entity !== null && <span className="bv-muted"> #{j.entity}</span>} <span className="bv-muted">{j.status}{j.phase ? ` · ${j.phase}` : ""}</span></li>)}{jobs.length === 0 && <li className="bv-muted">None.</li>}</ul>
|
|
751
|
+
<h3>Rounds</h3>
|
|
752
|
+
<ul className="bv-lab-changes">{[...sim.rounds].reverse().slice(0, 30).map((r) => { const d = r.delta as { entities?: Record<string, unknown>; resources?: Record<string, unknown> }; const n = Object.keys(d.entities ?? {}).length + Object.keys(d.resources ?? {}).length; return <li key={r.round}><b>round {r.round}</b> <span className="bv-muted">{(r.now / 1000).toFixed(1)} s · {n ? `${n} moved` : "quiet"}</span></li>; })}{sim.rounds.length === 0 && <li className="bv-muted">No round yet.</li>}</ul>
|
|
753
|
+
<h3>Log</h3>
|
|
754
|
+
<ul className="bv-lab-log">{sim.log.map((l, i) => <li key={i}>{l}</li>)}{sim.log.length === 0 && <li className="bv-muted">Quiet.</li>}</ul>
|
|
755
|
+
</div>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function BareState({ w }: { w: Replica<any> }) {
|
|
760
|
+
const all = w.all();
|
|
761
|
+
if (all.length === 0) return <p className="bv-muted">Nothing in the state.</p>;
|
|
762
|
+
return <div className="bv-state-list">{all.map((e) => <div key={e} className="bv-state-entity"><code>#{e}</code>{Object.entries(w.of(e)).map(([n, v]) => <span key={n} className="bv-state-part"><b>{n}</b>{v === true ? "" : <code>{JSON.stringify(v)}</code>}</span>)}</div>)}</div>;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "played";
|
|
766
|
+
export { asProgram };
|