jevstrudel.git / website / src / jev / follow.test.mjs

Follow mode: a listening party's guests play the host's decisions (jevCore.mjs: watchDecisions on the host, followDecisions on a guest). Host and guest are two evaluations of the same song here, wired through JSON as the room would carry them.

5import { describe, expect, it } from 'vitest';
6import {
7  askOpening,
8  choice,
9  createJev,
10  followDecisions,
11  score,
12  setReactions,
13  walk,
14  watchDecisions,
15} from './jevCore.mjs';
16import { boothStore } from './boothStore.mjs';
18const settle = () => new Promise((r) => setTimeout(r, 0));
19const values = (pat, b, e) => pat.queryArc(b, e).map((h) => h.value);
20async function play(pats, from, to) {
21  for (let c = from; c < to; c += 1) {
22    for (const p of pats) p.queryArc(c, c + 1);
23    await settle();
24  }
25}
26const wire = (x) => JSON.parse(JSON.stringify(x));
27
28const sectionQ = choice('Pick the next section', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' });
29const sections = {
30  intro: { at: 0, next: ['verse'] },
31  verse: { at: 2, next: ['chorus', 'verse'] },
32  chorus: { at: 4, next: ['outro', 'verse'] },
33  outro: { at: 6, next: [] },
34};
35const energyQ = score('How intense?', ['low', 'mid', 'high']);

One evaluation of the song: its section and energy answers, and its view.

38function song(decide) {
39  boothStore.beginEval();
40  const dj = createJev(decide)({ state: { title: 'T' }, questions: { section: sectionQ, energy: energyQ } });
41  const answers = dj.every(2, { fallback: { energy: 1 } });
42  walk(answers.section, { sections, max: 8 });
43  return { ...answers, view: boothStore.starting()[0] };
44}

A host whose Jev picks verse, verse, chorus, outro, with rising energy.

47function host() {
48  const plan = ['verse', 'verse', 'chorus', 'outro'];
49  const calls = [];
50  const s = song(async (state, questions) => {
51    calls.push({ state, questions });
52    const segment = state.segment - 1;
53    const pick = plan[segment - 1] ?? 'outro';
54    return {
55      ...(questions.section ? { section: { choice: pick, confidence: 0.9, probabilities: { [pick]: 0.9 } } } : {}),
56      energy: { score: [1, 0.5, 2, 0, 1.5][segment] ?? 1, confidence: 0.8 },
57    };
58  });
59  return { ...s, calls };
60}

A guest: asking Jev is a failure here.

63function guest() {
64  const asked = [];
65  const s = song(async (state) => {
66    asked.push(state);
67    throw new Error('a guest must not ask Jev');
68  });
69  return { ...s, asked };
70}
72describe('follow mode', () => {
73  it('a guest plays the host’s decisions, section and levels alike, and never asks Jev', async () => {
74    const h = host();
75    const sent = [];
76    const watching = watchDecisions((view, segment, decision) => sent.push(wire({ view, segment, decision })));
77    await askOpening(1000);
78    await play([h.section.choice, h.energy.score], 0, 12);
79    watching.stop();
80
81    const g = guest();
82    const receive = followDecisions();
83    for (const m of sent) receive[m.view](m.segment, m.decision);
84    await askOpening(1000);
85    await play([g.section.choice, g.energy.score], 0, 12);
86
87    expect(g.asked).toEqual([]);
88    expect(values(g.section.choice, 0, 12)).toEqual(values(h.section.choice, 0, 12));
89    expect(values(g.energy.score, 0, 12)).toEqual(values(h.energy.score, 0, 12));
90    expect(values(h.section.choice, 0, 12)).toEqual(['intro', 'verse', 'verse', 'chorus', 'outro']);
91    // the song ends where the host's does
92    expect(g.view.last).toBe(h.view.last);
93    expect(g.view.decisions.every((d) => d.status === 'answered' && d.followed)).toBe(true);
94  });
95
96  it('decisions arriving as the host makes them, live, keep the guest in step', async () => {
97    const h = host();
98    const g = guest();
99    const receive = followDecisions(); // the guest's views are the starting ones now
100    watchDecisionsOf(h.view, (view, segment, decision) => receive[view](segment, wire(decision)));
101    await play([h.section.choice, h.energy.score, g.section.choice, g.energy.score], 0, 12);
102    expect(g.asked).toEqual([]);
103    expect(values(g.section.choice, 0, 12)).toEqual(values(h.section.choice, 0, 12));
104    expect(values(g.energy.score, 0, 12)).toEqual(values(h.energy.score, 0, 12));
105  });
106
107  it('a segment whose decision has not arrived when it begins plays its fallbacks, like a late answer', async () => {
108    const h = host();
109    const sent = [];
110    watchDecisionsOf(h.view, (view, segment, decision) => sent.push({ view, segment, decision: wire(decision) }));
111    await play([h.section.choice, h.energy.score], 0, 8);
112
113    const g = guest();
114    const receive = followDecisions();
115    // the opening and segment 1 arrive in time; segment 2 arrives one cycle into it
116    for (const m of sent.filter((m) => m.segment < 2)) receive[m.view](m.segment, m.decision);
117    await play([g.section.choice, g.energy.score], 0, 5);
118    expect(g.view.decisions[2].status).toBe('asking');
119    for (const m of sent.filter((m) => m.segment === 2)) receive[m.view](m.segment, m.decision);
120    await settle();
121    // section 2 began (cycle 4) as its written fallback, the chorus, which
122    // follows the verse as written, and keeps it though the host played a
123    // verse there; its energy is the host's from cycle 5, the next whole one
124    expect(values(h.section.choice, 4, 6)).toEqual(['verse']);
125    expect(g.view.decisions[2]).toMatchObject({ status: 'answered', from: 5 });
126    expect(values(g.section.choice, 4, 6)).toEqual(['chorus', 'chorus']); // cut at cycle 5
127    expect(values(g.energy.score, 4, 6)).toEqual([1, 2]);
128    expect(g.view.decisions[2].problems.join()).toMatch(/after the section began/);
129    // with no whole cycle left, the fallbacks play the section out
130    const late = sent.find((m) => m.segment === 3);
131    await play([g.section.choice, g.energy.score], 5, 8); // into cycle 7, segment 3's last
132    receive[late.view](late.segment, late.decision);
133    expect(g.view.decisions[3]).toMatchObject({ status: 'fallback' });
134    expect(g.asked).toEqual([]);
135  });
136
137  it('a late decision with a whole cycle left plays from the next cycle, keeping the section', async () => {
138    boothStore.beginEval();
139    const hostDj = createJev(async () => ({ energy: { score: 2, confidence: 1 } }))({
140      questions: { energy: energyQ },
141    }).every(4, { fallback: { energy: 0 } });
142    const sent = [];
143    watchDecisions((view, segment, decision) => sent.push({ view, segment, decision: wire(decision) }));
144    await askOpening(1000);
145    await play([hostDj.energy.score], 0, 8);
146
147    boothStore.beginEval();
148    const g = createJev(() => Promise.reject(new Error('no')))({ questions: { energy: energyQ } }).every(4, {
149      fallback: { energy: 0 },
150    });
151    const [receive] = followDecisions();
152    receive(0, sent.find((m) => m.segment === 0).decision);
153    await play([g.energy.score], 0, 5); // cycle 4, section 1's first, plays with nothing received
154    receive(1, sent.find((m) => m.segment === 1).decision);
155    await settle();
156    expect(values(g.energy.score, 4, 8)).toEqual([0, 2]);
157    expect(g.energy.score.queryArc(4, 8).map((h) => h.whole.begin.valueOf())).toEqual([4, 5]);
158  });
159
160  it('checks every value it receives, and plays the fallback for one that is not the song’s', async () => {
161    const g = guest();
162    const [receive] = followDecisions();
163    const logged = [];
164    receive(0, {
165      status: 'answered',
166      values: {
167        section: { type: 'choice', choice: 'intro' },
168        energy: { type: 'score', score: 99, probabilities: { 0: 0.1, evil: 5 } },
169      },
170      last: null,
171    });
172    expect(
173      receive(1, {
174        status: 'answered',
175        values: { section: { type: 'choice', choice: 'toString' }, energy: { type: 'score', score: 'loud' } },
176        problems: [{ not: 'text' }, 'x'.repeat(1000)],
177        last: 'soon',
178      }),
179    ).toBe(true);
180    expect(receive(1, { status: 'answered', values: {}, last: null })).toBe(false); // settled already
181    expect(receive(-1, {})).toBe(false);
182    expect(receive(99, {})).toBe(false);
183    expect(receive(2, 'not an object')).toBe(false);
184    logged.push(...g.view.decisions[1].problems);
185    // a score is kept in range, and only its own levels' probabilities
186    expect(g.view.decisions[0].values.energy).toMatchObject({ score: 2, probabilities: { 0: 0.1 } });
187    // section 1's section is intro's written next, and its energy the fallback
188    expect(values(g.section.choice, 2, 4)).toEqual(['verse']);
189    expect(values(g.energy.score, 2, 4)).toEqual([1]);
190    expect(logged.some((p) => /section: the decision sent is not one of this song's/.test(p))).toBe(true);
191    expect(logged.every((p) => p.length <= 300)).toBe(true);
192    expect(g.view.last).toBe(null);
193    await play([g.section.choice], 0, 4);
194    expect(g.asked).toEqual([]);
195  });
196
197  it("a section the song's form does not allow there, or does not have, plays the planned one", async () => {
198    // a host on another build of the song, or anyone else on the wire: the
199    // guest keeps to its own form, `times` included, and never throws
200    boothStore.beginEval();
201    const dj = createJev(async () => {
202      throw new Error('a guest must not ask Jev');
203    })({ questions: { section: sectionQ } }).every(2);
204    walk(dj.section, {
205      sections: { ...sections, verse: { ...sections.verse, times: 2 } },
206      max: 8,
207    });
208    const [receive] = followDecisions();
209    const sent = (choice, last = null) => ({ status: 'answered', values: { section: { type: 'choice', choice } }, last });
210    receive(0, sent('intro'));
211    receive(1, sent('outro')); // intro → outro: not in the form's next
212    receive(2, sent('verse'));
213    receive(3, sent('verse')); // a third verse: verse plays twice at most
214    receive(4, sent('bridge', 4)); // not a section of this song
215    await play([dj.section.choice], 0, 16);
216    const view = boothStore.starting()[0];
217    expect(values(dj.section.choice, 0, 16)).toEqual(['intro', 'verse', 'verse', 'chorus', 'outro']);
218    expect(view.decisions[1].problems.join()).toMatch(/"outro" may not play here/);
219    expect(view.decisions[2].problems).toEqual([]);
220    expect(view.decisions[3].problems.join()).toMatch(/"verse" may not play here/);
221    expect(view.decisions[4].problems.join()).toMatch(/not one of this song's/);
222    expect(view.last).toBe(4);
223  });
224
225  it('joins mid-song: what is known already is played from the cycle it starts at', async () => {
226    const h = host();
227    const w = watchDecisionsOf(h.view, () => {});
228    await play([h.section.choice, h.energy.score], 0, 7);
229    const { settled } = watchDecisions(() => {});
230    w.stop();
231
232    const g = guest();
233    const receive = followDecisions();
234    for (const m of wire(settled)) receive[m.view](m.segment, m.decision);
235    // the guest's scheduler starts at cycle 6.5, in segment 3
236    await play([g.section.choice, g.energy.score], 6.5, 12);
237    expect(values(g.section.choice, 6, 8)).toEqual(values(h.section.choice, 6, 8));
238    expect(values(g.energy.score, 6, 8)).toEqual(values(h.energy.score, 6, 8));
239    expect(g.asked).toEqual([]);
240  });
241
242  it('askOpening waits for the opening decision to arrive, and does not ask', async () => {
243    const g = guest();
244    const [receive] = followDecisions();
245    let opened = false;
246    const waiting = askOpening(1000).then(() => (opened = true));
247    await settle();
248    expect(opened).toBe(false);
249    receive(0, {
250      status: 'answered',
251      values: { section: { type: 'choice', choice: 'intro' }, energy: { type: 'score', score: 2 } },
252      last: null,
253    });
254    await waiting;
255    expect(values(g.energy.score, 0, 2)).toEqual([2]);
256    expect(g.asked).toEqual([]);
257  });
258
259  it('reactions counted elsewhere reach the host’s Jev', async () => {
260    const h = host();
261    await askOpening(1000);
262    await play([h.section.choice], 0, 2);
263    setReactions(1, { fire: 3, sleep: 1 });
264    await play([h.section.choice], 2, 4);
265    const asked = h.calls.find((c) => c.state.segment === 3);
266    expect(asked.state.audience).toEqual([{ cycles: '3-4', fire: 3, sleep: 1 }]);
267  });
268});

watchDecisions for one given view (the host's), while another evaluation's are the starting ones.

272function watchDecisionsOf(view, fn) {
273  const watcher = (segment, d) => fn(0, segment, { ...wire({ ...d, state: undefined }), last: view.last });
274  view.watchers.add(watcher);
275  return { stop: () => view.watchers.delete(watcher) };
276}