jevstrudel.git / website / src / jev / boothStore.mjs
1// What the page shows of Jev at work: the booth and mixer under the editor,
2// the highlighted choices in the code, and the request log all read this
3// store, and each `jev({...}).every(...)` (jevCore.mjs) writes to it.
4//
5// A song may declare several jev()s (one choosing the section, one the
6// parts, asked after it). The page shows them as one: a cell per section
7// with every answer from every jev on that cadence.
8//
9// Every evaluation builds new views. They are drafts until the evaluation
10// succeeds (afterEval), so code that fails to evaluate leaves the views of
11// the song still playing on screen, and code without jev() clears them.
12let draft = [];
13let current = null;
14// A listener's song plays in a sandbox (sandbox.mjs), whose booth arrives
15// as data: shown instead of the page's own until the page evaluates again.
16let remote = null;
17let snapshot = { booth: null, version: 0 };
18const listeners = new Set();
19
20const STATUS_RANK = { asking: 3, fallback: 2, answered: 1, opening: 0 };
21
22// Several views on one cadence, seen as one.
23export function combine(views) {
24  if (!views?.length) return null;
25  if (views.length === 1) return views[0];
26  const every = views[0].every;
27  const same = views.filter((v) => v.every === every);
28  const lasts = same.map((v) => v.last).filter((l) => l !== null);
29  const length = Math.max(...same.map((v) => v.decisions.length));
30  const decisions = [];
31  for (let n = 0; n < length; n++) {
32    const parts = same.map((v) => v.decisions[n]).filter(Boolean);
33    if (!parts.length) continue;
34    const status = parts.map((d) => d.status).sort((a, b) => STATUS_RANK[b] - STATUS_RANK[a])[0];
35    decisions[n] = {
36      segment: n,
37      cycles: parts[0].cycles,
38      status,
39      values: Object.assign({}, ...parts.map((d) => d.values)),
40      problems: parts.flatMap((d) => d.problems ?? []),
41      reason: parts.find((d) => d.reason)?.reason,
42      // the song ended before this segment: nothing was needed, nothing failed
43      ended: parts.some((d) => d.ended),
44      ms: parts.some((d) => d.ms !== undefined) ? Math.max(...parts.map((d) => d.ms ?? 0)) : undefined,
45      states: parts.map((d) => d.state).filter(Boolean),
46    };
47  }
48  return {
49    model: views[0].model,
50    about: views[0].about,
51    every,
52    segments: Math.min(...same.map((v) => v.segments)),
53    questions: same.flatMap((v) => v.questions),
54    form: same.find((v) => v.form)?.form ?? null,
55    last: lasts.length ? Math.min(...lasts) : null,
56    decisions,
57    reactions: views[0].reactions,
58    replay: views[0].replay ?? null,
59    // every jev hears the listener
60    react: (segment, kind) => same.forEach((v) => v.react(segment, kind)),
61    views: same,
62    // every jev() of the evaluation, in declaration order (a performance
63    // records them all; a song's are all on one cadence)
64    jevs: views,
65  };
66}
67
68function emit() {
69  snapshot = { booth: remote ?? combine(current), version: snapshot.version + 1 };
70  for (const listener of listeners) listener();
71}
72
73export const boothStore = {
74  subscribe(listener) {
75    listeners.add(listener);
76    return () => listeners.delete(listener);
77  },
78  getSnapshot: () => snapshot,
79  // jevCore.mjs, as each jev().every() is declared
80  // Returns the view's index: its jev()'s place in the song, in declaration
81  // order, which is how a recorded performance names it (performance.mjs).
82  adopt(view) {
83    draft.push(view);
84    return draft.length - 1;
85  },
86  changed(view) {
87    if (current?.includes(view)) emit();
88  },
89  // The views of the song about to start: the evaluation's drafts before
90  // it is committed (the scheduler starts inside evaluate), else the
91  // committed ones (play pressed later).
92  starting: () => (draft.length ? draft : (current ?? [])),
93  // useReplContext: around each evaluation.
94  beginEval() {
95    draft = [];
96  },
97  // A block evaluation (`range` set) replaces only its block, so one
98  // without jev() keeps the views that are playing.
99  commitEval({ block = false } = {}) {
100    if (block && !draft.length) return;
101    current = draft.length ? draft : null;
102    draft = [];
103    remote = null;
104    emit();
105  },
106  // sandbox.mjs: the sandbox's booth, already checked (sandboxProtocol.mjs),
107  // or null when the sandbox is gone.
108  showRemote(booth) {
109    remote = booth;
110    if (booth) current = null;
111    emit();
112  },
113};
114
115// The section playing at `cycle`, or null: its segment, decision, how far
116// into it and how many cycles are left (the booth's strip, and the MCP's
117// react in tabTools.mjs).
118export function playing(booth, cycle) {
119  if (!booth || cycle === null || cycle < 0) return null;
120  const segment = Math.floor(cycle / booth.every);
121  if (segment >= booth.segments || (booth.last !== null && segment > booth.last)) return null;
122  const d = booth.decisions[segment];
123  if (!d) return null;
124  return {
125    segment,
126    decision: d,
127    progress: (cycle - segment * booth.every) / booth.every,
128    left: (segment + 1) * booth.every - cycle,
129  };
130}