1// Follow mode: a listening party's guests play the host's decisions 2// (jevCore.mjs: watchDecisions on the host, followDecisions on a guest). 3// Host and guest are two evaluations of the same song here, wired through 4// 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'; 17 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']); 36 37// 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} 45 46// 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} 61 62// 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} 71 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}); 269 270// watchDecisions for one given view (the host's), while another 271// 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}