ad-presentation (on main) registers WPC1 for a player's presentation claim, and co-performance registered WPC1 again for its commitment, so the registry's library test (wire_identifiers_are_in_their_documents) refuses the train: "WPC1 (magic) is listed twice". The commitment takes WPK1, unused anywhere: the extension's magic table and §7.5 layout, wai-rs's constant, the relay's floor reader, the independent verifier, the SPEC §5 paragraph, the changelog entry and two comments. The corpus is regenerated by its generator (only the commitments' bytes and what signs or digests them change) and the registry export with it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
419 lines
19 KiB
JavaScript
419 lines
19 KiB
JavaScript
// Co-performance in the room page (WAI co-performance): what the room's statements say, shown
|
|
// as they arrive, and what this page sends or asks for.
|
|
//
|
|
// - The floor: the turn schedule in effect (WPT1) and its handovers (WPH1), who holds the floor
|
|
// now and the turns ahead, in microseconds or bars at a tempo; the marks the room's clock
|
|
// signs (WPM1) for an object out of turn or late.
|
|
// - What's next: each participant's intents (WPI1) as upcoming lines, and, once the instant has
|
|
// passed, what played: kept, diverged or missed, read from the participant's own captions and
|
|
// chats under the room's policy (WPP1); an AI participant's marked as an AI system's.
|
|
// - The audience: the selection window open now (WPW1), and, for a viewer whose grant carries
|
|
// the audience-input power, its options or a prompt box; each window's commitment (WPK1) and
|
|
// selection (WPS1) with its credits.
|
|
// - Fidelity: the clock's last fidelity statement (WPF1), each participant's kept of announced.
|
|
//
|
|
// A lead (a grant with the direct power) asks the room's clock to state a schedule, a handover
|
|
// or a window, and a fidelity statement, through the room service; the holder of a turn may
|
|
// hand it over. A performer's page sends media only while it holds the floor, a guard inside
|
|
// each turn, so that what it plays arrives in its turn; `playOutOfTurn` lifts that for a
|
|
// moment, as a pickup that starts before the bar line does.
|
|
//
|
|
// What a page shows is its own reading. The room's verification (co-performance §9, with
|
|
// wai-live-web's `Room.coVerify`) is what decides.
|
|
|
|
const GUARD_US = 150_000; // a performer's media starts this far into its turn and stops this far before its end
|
|
const STATEMENT_KIND = 0x01; // `meta` (live-events §4.2)
|
|
const TYPE_BIT = { prompt: 1, sketch: 2, vote: 4, movement: 8 };
|
|
|
|
export class Co {
|
|
constructor(o) {
|
|
Object.assign(this, o); // wai, log, $, nameOf, isAi, roomNow, me, grantOf, present, emit, emitDetached, api
|
|
this.policy = JSON.parse(this.wai.coPolicy(o.policyBytes));
|
|
this.windowUs = (this.policy.window_ms || 120) * 1000;
|
|
this.schedules = []; // in the order held
|
|
this.handovers = [];
|
|
this.intents = [];
|
|
this.said = {}; // principal -> [{t, text, digest}]
|
|
this.marks = { out_of_turn: 0, late: 0, records: [] };
|
|
this.windows = new Map(); // digest -> window
|
|
this.inputs = new Map(); // digest -> input
|
|
this.fidelity = null;
|
|
this.sent = {}; // window digest -> inputs this page sent
|
|
this.outOfTurnUntil = 0;
|
|
this.lateNext = 0;
|
|
this.statements = 0;
|
|
this.requests = [];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------- the records
|
|
|
|
// A record the room accepted (`v`, room.offer's view) and its bytes.
|
|
onRecord(v, bytes) {
|
|
if (v.kind_name === "chat" || v.kind_name === "caption") {
|
|
(this.said[v.principal] = this.said[v.principal] || []).push({ t: v.t, text: v.payload && v.payload.text, digest: v.digest });
|
|
return;
|
|
}
|
|
if (v.kind_name !== "meta") return;
|
|
const s = JSON.parse(this.wai.coEventStatement(bytes));
|
|
if (!s || !s.statement) return;
|
|
this.statements++;
|
|
const at = { t: v.t, principal: v.principal, digest: v.digest };
|
|
switch (s.statement) {
|
|
case "schedule": this.schedules.push({ ...s, ...at, order: this.statements }); break;
|
|
case "handover": this.onHandover(s, at); break;
|
|
case "intent": this.intents.push({ ...s, ...at }); break;
|
|
case "mark":
|
|
if (s.out_of_turn) this.marks.out_of_turn++;
|
|
if (s.late) this.marks.late++;
|
|
this.marks.records.push({ ...s, t: v.t });
|
|
break;
|
|
case "window": this.windows.set(v.digest, { ...s, ...at, inputs: [], commit: null, selection: null }); break;
|
|
case "input": {
|
|
this.inputs.set(v.digest, { ...s, ...at });
|
|
const w = this.windows.get(s.window);
|
|
if (w) w.inputs.push(v.digest);
|
|
break;
|
|
}
|
|
case "commit": { const w = this.windows.get(s.window); if (w) w.commit = { ...s, ...at }; break; }
|
|
case "selection": { const w = this.windows.get(s.window); if (w) w.selection = { ...s, ...at }; break; }
|
|
case "fidelity": this.fidelity = { ...s, ...at }; break;
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
onHandover(s, at) {
|
|
const turn = this.turnAt(s.at);
|
|
if (!turn || !turn.holders.includes(s.to)) return; // the room's verifier refuses it; nothing to show
|
|
this.handovers.push({ ...s, ...at, schedule: turn.schedule.digest, segment: turn.segment, turn: turn.index, order: this.statements });
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------ the floor
|
|
|
|
scheduleAt(x) {
|
|
for (let i = this.schedules.length - 1; i >= 0; i--) if (this.schedules[i].from <= x) return this.schedules[i];
|
|
return null;
|
|
}
|
|
|
|
// The turn running at room-clock `x` (§6.2), with its holder at `x` (§6.3).
|
|
turnAt(x) {
|
|
const s = this.scheduleAt(x);
|
|
if (!s) return null;
|
|
for (let i = 0; i < s.segments.length; i++) {
|
|
const g = s.segments[i];
|
|
const L = g.turn_us;
|
|
if (!L) continue;
|
|
if (g.start <= x && x < g.start + g.turns * L) {
|
|
const n = Math.floor((x - g.start) / L);
|
|
const scheduled = g.holders[n % g.holders.length];
|
|
const h = this.handovers.filter((h) => h.schedule === s.digest && h.segment === i && h.turn === n && h.at <= x).sort((a, b) => a.at - b.at || a.order - b.order).pop();
|
|
return { schedule: s, segment: i, index: n, start: g.start + n * L, end: g.start + (n + 1) * L, scheduled, holder: h ? h.to : scheduled, holders: g.holders, role: g.pairs ? (n % 2 ? "response" : "call") : "solo", mode: s.mode, of: g.turns };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The turns ahead of `x`, under the schedule in effect at each.
|
|
turnsAhead(x, n) {
|
|
const out = [];
|
|
let y = x;
|
|
for (let i = 0; i < n; i++) {
|
|
const t = this.turnAt(y);
|
|
if (!t) {
|
|
// The next segment or schedule to start, if any.
|
|
const next = this.nextStart(y);
|
|
if (next == null) break;
|
|
y = next;
|
|
continue;
|
|
}
|
|
out.push(t);
|
|
y = t.end;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
nextStart(y) {
|
|
let best = null;
|
|
for (const s of this.schedules) for (const g of s.segments) if (g.start > y && (best == null || g.start < best)) best = g.start;
|
|
return best;
|
|
}
|
|
|
|
// Whether this page sends its media now: unless a schedule governs it and another holds the
|
|
// floor, or the turn is within its guard of its start or end (§6.4).
|
|
sends(x) {
|
|
if (x < this.outOfTurnUntil) return true;
|
|
const t = this.turnAt(x);
|
|
if (!t || !t.holders.includes(this.me().principal)) return true;
|
|
return t.holder === this.me().principal && x >= t.start + GUARD_US && x < t.end - GUARD_US;
|
|
}
|
|
|
|
// A moment out of turn, on purpose: a pickup before the bar line.
|
|
playOutOfTurn(ms) {
|
|
this.outOfTurnUntil = this.roomNow() + ms * 1000;
|
|
}
|
|
|
|
// The due instant the next audio object states is `ms` earlier than its capture: it arrives
|
|
// that much later than its due instant.
|
|
stampNextLate(ms) {
|
|
this.lateNext = ms * 1000;
|
|
}
|
|
|
|
takeLate() {
|
|
const l = this.lateNext;
|
|
this.lateNext = 0;
|
|
return l;
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------- intents
|
|
|
|
// What played for an intent so far (§5.4, text intents): the author's chat or caption nearest
|
|
// its instant within the policy's window.
|
|
outcome(i, now) {
|
|
if (i.form !== "text") return i.at + this.windowUs < now ? "announced" : "upcoming";
|
|
const near = (this.said[i.principal] || []).filter((x) => Math.abs(x.t - i.at) <= this.windowUs).sort((a, b) => Math.abs(a.t - i.at) - Math.abs(b.t - i.at))[0];
|
|
if (near) return near.text === i.text ? "kept" : "diverged";
|
|
return now > i.at + this.windowUs ? "missed" : "upcoming";
|
|
}
|
|
|
|
// Announce a line `inMs` ahead, and say `say` (the line itself unless given) at its instant,
|
|
// as a caption a machine made when this page is an AI participant.
|
|
async announce({ text, inMs = 1500, spanMs = 1000, say }) {
|
|
const at = this.roomNow() + inMs * 1000;
|
|
const p = this.wai.coEncode(JSON.stringify({ statement: "intent", at, span: spanMs * 1000, form: "text", text }));
|
|
const view = await this.emit(STATEMENT_KIND, p);
|
|
// Said a little before its instant, so that a timer that fires late still says it within
|
|
// the policy's window of it (§5.4).
|
|
const spoken = new Promise((resolve) => {
|
|
setTimeout(async () => {
|
|
try { resolve(await this.say(say == null ? text : say)); } catch (e) { resolve({ error: String(e) }); }
|
|
}, Math.max(0, (at - this.roomNow()) / 1000 - 20));
|
|
});
|
|
this.spoken = spoken;
|
|
return { at, intent: view.digest, spoken };
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------ audience
|
|
|
|
myGrant() {
|
|
return this.grantOf(this.me().principal);
|
|
}
|
|
|
|
audienceLimit() {
|
|
const g = this.myGrant();
|
|
const a = g && g.attrs.find((x) => x[0] === "audience_input");
|
|
return a ? Number(a[1]) : 0;
|
|
}
|
|
|
|
isLead() {
|
|
const g = this.myGrant();
|
|
return !!g && (g.powers & 2) !== 0;
|
|
}
|
|
|
|
openWindow(now) {
|
|
const open = [...this.windows.values()].filter((w) => w.close > now);
|
|
return open[open.length - 1] || null;
|
|
}
|
|
|
|
// An input to window `window` (§7.3): a vote's `option`, or a prompt's `text`.
|
|
async input({ window, type, option, text }) {
|
|
const p = this.wai.coEncode(JSON.stringify({ statement: "input", window, type, option, text }));
|
|
const view = await this.emit(STATEMENT_KIND, p);
|
|
this.sent[window] = (this.sent[window] || 0) + 1;
|
|
this.render();
|
|
return view;
|
|
}
|
|
|
|
// An input signed as this page's next record and sent, but not kept in its chain: for a test
|
|
// that the relay withholds what §7.3 refuses, which nobody then receives.
|
|
async inputDetached(desc) {
|
|
let p;
|
|
try { p = this.wai.coEncode(JSON.stringify({ statement: "input", ...desc })); } catch {
|
|
// A body that does not read: a vote with a byte too many.
|
|
const w = this.wai.coEncode(JSON.stringify({ statement: "input", window: desc.window, type: "vote", option: 0 }));
|
|
p = new Uint8Array(w.length + 1);
|
|
p.set(w);
|
|
}
|
|
return this.emitDetached(STATEMENT_KIND, p);
|
|
}
|
|
|
|
// ------------------------------------------------------------------- asking the room's clock
|
|
|
|
async ask(path, body) {
|
|
const r = await this.api(path, body || {});
|
|
this.requests.push({ path, status: r.status, body: r.body });
|
|
if (r.status !== 200) this.log(`co: ${path} answered ${r.status} ${JSON.stringify(r.body)}`);
|
|
return r;
|
|
}
|
|
|
|
// Two-or-more holders trading turns of `seconds` (mode `mark` or `refuse`), starting soon.
|
|
startTurns({ seconds = 4, turns = 4, mode = "mark", holders, pairs = false }) {
|
|
const hs = holders || this.present().filter((p) => this.isPublisher(p));
|
|
return this.ask("/co/schedule", { mode, beat_us: 0, beats_per_bar: 0, segments: [{ length: Math.round(seconds * 1e6), turns, pairs, holders: hs }] });
|
|
}
|
|
|
|
passFloor() {
|
|
const t = this.turnAt(this.roomNow() + 400_000);
|
|
if (!t) return Promise.resolve({ status: 0, body: { error: "no turn" } });
|
|
const i = t.holders.indexOf(t.holder);
|
|
return this.ask("/co/handover", { to: t.holders[(i + 1) % t.holders.length] });
|
|
}
|
|
|
|
openVote({ question, options = 3, seconds = 8 }) {
|
|
return this.ask("/co/window", { rule: "plurality", options, k: 1, seconds, question });
|
|
}
|
|
|
|
askPrompts({ question, seconds = 8, k = 1, rule = "draw" }) {
|
|
return this.ask("/co/window", { rule, types: ["prompt"], options: 0, k, seconds, question });
|
|
}
|
|
|
|
stateFidelity() {
|
|
return this.ask("/co/fidelity", {});
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------- the view
|
|
|
|
render() {
|
|
const $ = this.$;
|
|
const sec = $("co");
|
|
if (!sec) return;
|
|
sec.hidden = false;
|
|
const now = this.roomNow();
|
|
const me = this.me();
|
|
const name = (p) => (p === me.principal ? "you" : this.nameOf(p)) + (this.isAi(p) ? " (AI system)" : "");
|
|
const s = (us) => `${(us / 1e6).toFixed(1)} s`;
|
|
// The floor.
|
|
const t = this.turnAt(now);
|
|
const nowEl = $("co-now");
|
|
if (nowEl) {
|
|
nowEl.textContent = t
|
|
? `${name(t.holder)} ${t.holder === me.principal ? "hold" : "holds"} the floor · ${s(t.end - now)} left · turn ${t.index + 1} of ${t.of}${t.role === "solo" ? "" : ` (${t.role})`} · out of turn is ${t.mode === "refuse" ? "refused" : "marked"}`
|
|
: this.schedules.length ? "The floor is open: no turn runs now." : "No turn schedule yet: the floor is open.";
|
|
nowEl.dataset.holder = t ? t.holder : "";
|
|
}
|
|
const turnsEl = $("co-turns");
|
|
if (turnsEl) {
|
|
turnsEl.replaceChildren(...this.turnsAhead(now, 6).map((x) => {
|
|
const li = document.createElement("li");
|
|
li.dataset.holder = x.holder;
|
|
li.textContent = `${x.start <= now ? "now" : `in ${s(x.start - now)}`}: ${name(x.holder)}${x.holder !== x.scheduled ? " (handed over)" : ""}${x.role === "solo" ? "" : `, ${x.role}`}`;
|
|
return li;
|
|
}));
|
|
}
|
|
const marksEl = $("co-marks");
|
|
if (marksEl) marksEl.textContent = this.marks.out_of_turn + this.marks.late ? `Marked by the room's clock: ${this.marks.out_of_turn} out of turn, ${this.marks.late} late.` : "";
|
|
// What's next.
|
|
const intentsEl = $("co-intents");
|
|
if (intentsEl) {
|
|
const shown = this.intents.filter((i) => i.at + i.span > now - 20e6).slice(-8);
|
|
intentsEl.replaceChildren(...shown.map((i) => {
|
|
const li = document.createElement("li");
|
|
const o = this.outcome(i, now);
|
|
li.dataset.outcome = o;
|
|
li.dataset.intent = i.digest;
|
|
const what = i.form === "text" ? `“${i.text}”` : i.form === "object" ? `an object ${i.digest.slice(0, 8)}` : i.form;
|
|
li.textContent = o === "upcoming" ? `${name(i.principal)} · next, in ${s(Math.max(0, i.at - now))}: ${what}` : `${name(i.principal)} · ${what} · ${o}`;
|
|
return li;
|
|
}));
|
|
}
|
|
// The audience.
|
|
const w = this.openWindow(now);
|
|
const q = $("co-question");
|
|
if (q) q.textContent = w ? `${w.question || "The audience is asked"} · closes in ${s(w.close - now)}` : "No window is open.";
|
|
const form = $("co-input");
|
|
const limit = this.audienceLimit();
|
|
const present = this.present().includes(me.principal);
|
|
if (form) {
|
|
const take = w && limit > 0 && present;
|
|
form.hidden = !take;
|
|
if (take && form.dataset.window !== w.digest) {
|
|
form.dataset.window = w.digest;
|
|
const opts = $("co-options");
|
|
opts.replaceChildren();
|
|
if (w.rule === "plurality") {
|
|
for (let o = 0; o < w.options; o++) {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.textContent = `Option ${o + 1}`;
|
|
b.dataset.option = String(o);
|
|
b.addEventListener("click", () => this.input({ window: w.digest, type: "vote", option: o }).catch((e) => this.log(`co: ${e}`)));
|
|
opts.appendChild(b);
|
|
}
|
|
}
|
|
$("co-prompt-row").hidden = w.rule === "plurality";
|
|
}
|
|
const left = w ? limit - (this.sent[w.digest] || 0) : 0;
|
|
if ($("co-left")) $("co-left").textContent = take ? `${left} of ${limit} input${limit === 1 ? "" : "s"} left` : "";
|
|
for (const b of form.querySelectorAll("button")) b.disabled = left <= 0;
|
|
}
|
|
const selEl = $("co-selections");
|
|
if (selEl) {
|
|
const done = [...this.windows.values()].filter((x) => x.selection).slice(-4);
|
|
selEl.replaceChildren(...done.map((x) => {
|
|
const li = document.createElement("li");
|
|
li.dataset.window = x.digest;
|
|
const sel = x.selection;
|
|
const picks = sel.picks.map((p, n) => {
|
|
const c = sel.credits[n];
|
|
if (p.option != null) return `option ${p.option + 1}, credited to its ${c.count} voter${c.count === 1 ? "" : "s"} together`;
|
|
const inp = this.inputs.get(p.input);
|
|
return `“${inp && inp.text != null ? inp.text : p.input.slice(0, 8)}”, credited to ${name(c.party)}`;
|
|
});
|
|
li.textContent = `${x.question || "a window"}: ${picks.length ? picks.join("; ") : "nothing selected"} (${x.commit ? x.commit.count : 0} input${x.commit && x.commit.count === 1 ? "" : "s"} committed, ${sel.rule})`;
|
|
return li;
|
|
}));
|
|
}
|
|
// Fidelity.
|
|
const fEl = $("co-fidelity");
|
|
if (fEl && this.fidelity) {
|
|
fEl.replaceChildren(...this.fidelity.entries.map((e) => {
|
|
const li = document.createElement("li");
|
|
li.dataset.principal = e.principal;
|
|
li.textContent = `${name(e.principal)}: kept ${e.kept} of ${e.announced} announced (${e.diverged} diverged, ${e.missed} missed)`;
|
|
return li;
|
|
}));
|
|
}
|
|
const lead = $("co-lead");
|
|
if (lead) lead.hidden = !this.isLead();
|
|
const pass = $("co-pass");
|
|
if (pass) pass.hidden = !(t && (t.holder === me.principal || this.isLead()));
|
|
}
|
|
|
|
snapshot() {
|
|
const now = this.roomNow();
|
|
return {
|
|
now,
|
|
policy: this.policy,
|
|
schedules: this.schedules.map((s) => ({ digest: s.digest, mode: s.mode, from: s.from, segments: s.segments })),
|
|
handovers: this.handovers.map((h) => ({ digest: h.digest, at: h.at, to: h.to })),
|
|
turn: this.turnAt(now),
|
|
marks: { out_of_turn: this.marks.out_of_turn, late: this.marks.late },
|
|
intents: this.intents.map((i) => ({ digest: i.digest, principal: i.principal, at: i.at, text: i.text, outcome: this.outcome(i, now) })),
|
|
windows: [...this.windows.values()].map((w) => ({ digest: w.digest, rule: w.rule, close: w.close, question: w.question, inputs: w.inputs.length, commit: w.commit && { count: w.commit.count, digest: w.commit.digest }, selection: w.selection && { picks: w.selection.picks, credits: w.selection.credits } })),
|
|
fidelity: this.fidelity && { t: this.fidelity.t, through: this.fidelity.through, entries: this.fidelity.entries, digest: this.fidelity.digest },
|
|
audience: { limit: this.audienceLimit(), sent: this.sent },
|
|
lead: this.isLead(),
|
|
requests: this.requests.map((r) => ({ path: r.path, status: r.status })),
|
|
};
|
|
}
|
|
}
|
|
|
|
// The wiring of the section's controls (index.html), once the page has a Co.
|
|
export function wireControls(co, $, fail) {
|
|
const on = (id, f) => { const el = $(id); if (el) el.addEventListener("click", () => f().catch(fail)); };
|
|
const num = (id, d) => Number(($(id) || {}).value || d);
|
|
on("co-start", () => co.startTurns({ seconds: num("co-turn-s", 4), turns: num("co-turns-n", 4), mode: ($("co-mode") || {}).value || "mark" }));
|
|
on("co-pass", () => co.passFloor());
|
|
on("co-vote", () => co.openVote({ question: ($("co-q") || {}).value || "Which way next?", options: num("co-options-n", 3), seconds: num("co-window-s", 8) }));
|
|
on("co-prompts", () => co.askPrompts({ question: ($("co-q") || {}).value || "What should happen next?", seconds: num("co-window-s", 8) }));
|
|
on("co-fid", () => co.stateFidelity());
|
|
const form = $("co-input");
|
|
if (form) form.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const el = $("co-prompt");
|
|
const text = el.value.trim();
|
|
if (!text || !form.dataset.window) return;
|
|
el.value = "";
|
|
co.input({ window: form.dataset.window, type: "prompt", text }).catch(fail);
|
|
});
|
|
}
|
|
|
|
export { GUARD_US, TYPE_BIT };
|