@drawcall/feedback 0.0.0-stage → 0.1.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.
Files changed (82) hide show
  1. package/README.md +47 -2
  2. package/dist/api.d.ts +328 -0
  3. package/dist/api.js +202 -0
  4. package/dist/browser/device.d.ts +13 -0
  5. package/dist/browser/device.js +64 -0
  6. package/dist/browser/ending.d.ts +8 -0
  7. package/dist/browser/ending.js +241 -0
  8. package/dist/browser/frame.d.ts +27 -0
  9. package/dist/browser/frame.js +165 -0
  10. package/dist/browser/input.d.ts +45 -0
  11. package/dist/browser/input.js +173 -0
  12. package/dist/browser/intro.d.ts +13 -0
  13. package/dist/browser/intro.js +172 -0
  14. package/dist/browser/mic.d.ts +20 -0
  15. package/dist/browser/mic.js +98 -0
  16. package/dist/browser/pictures/LICENSE +24 -0
  17. package/dist/browser/pictures/controller.webp +0 -0
  18. package/dist/browser/pictures/hand.webp +0 -0
  19. package/dist/browser/run.d.ts +32 -0
  20. package/dist/browser/run.js +78 -0
  21. package/dist/browser/server.d.ts +22 -0
  22. package/dist/browser/server.js +147 -0
  23. package/dist/browser/session.d.ts +31 -0
  24. package/dist/browser/session.js +101 -0
  25. package/dist/browser/sounds/end.m4a +0 -0
  26. package/dist/browser/sounds/failed.m4a +0 -0
  27. package/dist/browser/sounds/hold-controller.m4a +0 -0
  28. package/dist/browser/sounds/hold-hand.m4a +0 -0
  29. package/dist/browser/sounds/nomic.m4a +0 -0
  30. package/dist/browser/sounds/sending.m4a +0 -0
  31. package/dist/browser/sounds/show-controller.m4a +0 -0
  32. package/dist/browser/sounds/show-hand.m4a +0 -0
  33. package/dist/browser/sounds/show.m4a +0 -0
  34. package/dist/browser/sounds/speak-controller.m4a +0 -0
  35. package/dist/browser/sounds/speak-hand.m4a +0 -0
  36. package/dist/browser/sounds/thanks.m4a +0 -0
  37. package/dist/browser/sounds/unheard.m4a +0 -0
  38. package/dist/browser/sounds/walkthrough/a-welcome.m4a +0 -0
  39. package/dist/browser/sounds/walkthrough/b-controller.m4a +0 -0
  40. package/dist/browser/sounds/walkthrough/b-hand.m4a +0 -0
  41. package/dist/browser/sounds/walkthrough/b-key.m4a +0 -0
  42. package/dist/browser/sounds/walkthrough/b-touch.m4a +0 -0
  43. package/dist/browser/sounds/walkthrough/b-type-key.m4a +0 -0
  44. package/dist/browser/sounds/walkthrough/b-type-touch.m4a +0 -0
  45. package/dist/browser/sounds/walkthrough/c-key.m4a +0 -0
  46. package/dist/browser/sounds/walkthrough/c-quest.m4a +0 -0
  47. package/dist/browser/sounds/walkthrough/c-touch.m4a +0 -0
  48. package/dist/browser/sounds/walkthrough/d-key.m4a +0 -0
  49. package/dist/browser/sounds/walkthrough/d-quest.m4a +0 -0
  50. package/dist/browser/sounds/walkthrough/d-touch.m4a +0 -0
  51. package/dist/browser/sounds.d.ts +140 -0
  52. package/dist/browser/sounds.js +118 -0
  53. package/dist/browser/style.d.ts +10 -0
  54. package/dist/browser/style.js +214 -0
  55. package/dist/browser/turn.d.ts +23 -0
  56. package/dist/browser/turn.js +274 -0
  57. package/dist/browser/ui.d.ts +88 -0
  58. package/dist/browser/ui.js +291 -0
  59. package/dist/browser/wait.d.ts +10 -0
  60. package/dist/browser/wait.js +32 -0
  61. package/dist/browser/walkthrough.d.ts +12 -0
  62. package/dist/browser/walkthrough.js +137 -0
  63. package/dist/browser/xr.d.ts +10 -0
  64. package/dist/browser/xr.js +99 -0
  65. package/dist/cli/bin.d.ts +2 -0
  66. package/dist/cli/bin.js +12 -0
  67. package/dist/cli/commands.d.ts +19 -0
  68. package/dist/cli/commands.js +153 -0
  69. package/dist/cli/files.d.ts +21 -0
  70. package/dist/cli/files.js +62 -0
  71. package/dist/cli/index.d.ts +14 -0
  72. package/dist/cli/index.js +94 -0
  73. package/dist/index.d.ts +109 -0
  74. package/dist/index.js +75 -0
  75. package/dist/notes.d.ts +12 -0
  76. package/dist/notes.js +23 -0
  77. package/dist/service.d.ts +53 -0
  78. package/dist/service.js +55 -0
  79. package/dist/tester.d.ts +39 -0
  80. package/dist/tester.js +84 -0
  81. package/package.json +54 -4
  82. package/skills/feedback/SKILL.md +117 -0
@@ -0,0 +1,172 @@
1
+ import { LIMITS } from "../service.js";
2
+ import { phrase, targeted } from "./device.js";
3
+ import { close, openMic } from "./mic.js";
4
+ import { findReviewer } from "./server.js";
5
+ import { Session } from "./session.js";
6
+ import { button, h } from "./ui.js";
7
+ import { wait } from "./wait.js";
8
+ import { walkthrough } from "./walkthrough.js";
9
+ /** What a tester's email is for, wherever they may leave it. A sign-up hears of new versions once they play its link. */
10
+ export const EMAIL_USE = "Leave an email to get your own link for new versions, and for questions about your feedback.";
11
+ /**
12
+ * The full-screen cover on every load in feedback mode. On a device the playtest isn't for, it first says which
13
+ * devices it is for, so the tester can switch or go on anyway. The invite says what the player is asked to do and why,
14
+ * and takes optional details; Start, or entering VR, starts the session, then asks for the microphone and lets sound
15
+ * play. The walkthrough then shows how feedback works on this device. A microphone that fails counts as none, so the
16
+ * walkthrough teaches typing instead. A broken intro must not cost the playtest its feedback, so it is logged, and the
17
+ * session starts anyway.
18
+ */
19
+ export async function intro(ctx, version) {
20
+ const { options, tester, ui, signal } = ctx;
21
+ const cover = ui.cover();
22
+ const failed = (error) => {
23
+ if (signal.aborted)
24
+ throw error;
25
+ console.error("feedback: the intro failed", error);
26
+ return false;
27
+ };
28
+ // The invite has its own button; the walkthrough brings the corner button back.
29
+ ui.corner("hidden");
30
+ try {
31
+ const started = await welcome(ctx, cover).catch(failed);
32
+ const session = new Session(options, tester, version, signal);
33
+ if (started)
34
+ await teach(ctx, cover).catch(failed);
35
+ return session;
36
+ }
37
+ finally {
38
+ cover.dispose();
39
+ ui.corner("shown");
40
+ }
41
+ }
42
+ /** Resolves with true once the tester presses Start, or with false once they enter VR, as Quest Browser can on its own. */
43
+ async function welcome(ctx, cover) {
44
+ const { devices } = ctx.options;
45
+ if (devices?.length && !(await targeted(devices, ctx.device)))
46
+ await notice(ctx, cover, phrase(devices));
47
+ const form = invite(ctx, cover, await known(ctx));
48
+ return wait(ctx.signal, (done, over) => {
49
+ form.addEventListener("submit", () => done(true), { signal: over });
50
+ entered(ctx, () => done(false), over);
51
+ });
52
+ }
53
+ /** The walkthrough for how the tester gives feedback. One who chose typing is never asked for the microphone. */
54
+ async function teach(ctx, cover) {
55
+ const { typing } = ctx.tester;
56
+ const mic = typing ? null : await openMic(ctx.signal);
57
+ if (mic instanceof MediaStream)
58
+ close(mic);
59
+ ctx.ui.corner("raised");
60
+ const means = mic instanceof MediaStream ? "speak" : typing ? "type" : "nomic";
61
+ await walkthrough(ctx, cover, means, (run, over) => entered(ctx, run, over));
62
+ }
63
+ /**
64
+ * The invite, written for someone who only clicked a link: what is asked of them, the game's `task`, the optional
65
+ * details, Start, and what's recorded. A known reviewer is greeted by name and asked for no details; anyone else is
66
+ * asked only when the project takes sign-ups, and leaving an email makes them a reviewer. Returns its form, whose
67
+ * submit starts.
68
+ */
69
+ function invite({ options, tester, device, project }, cover, greeted) {
70
+ const { minutes, task } = options;
71
+ const length = minutes ? `About ${minutes} minute${minutes === 1 ? "" : "s"}` : null;
72
+ // The project's name joins it once the service confirms the key.
73
+ const meta = h("p", { className: "meta", hidden: !length }, length ?? "");
74
+ const name = h("input", { name: "name", autocomplete: "name", maxLength: LIMITS.name, value: tester.name ?? "" });
75
+ const contact = h("input", { name: "email", type: "email", autocomplete: "email", maxLength: LIMITS.contact, value: tester.contact ?? "" });
76
+ const start = h("button", { className: "primary", type: "submit" }, "Allow mic & start");
77
+ // With the microphone already allowed there is nothing to allow or wait for.
78
+ let granted = false;
79
+ void allowed().then(yes => {
80
+ granted = yes;
81
+ if (yes && !start.disabled)
82
+ start.textContent = "Start";
83
+ });
84
+ // Shown once the project says it takes sign-ups.
85
+ const details = greeted ? null : h("fieldset", { hidden: true }, h("legend", {}, h("h2", {}, "Your details (optional)")), h("p", { className: "hint" }, EMAIL_USE), h("label", {}, "Your name", name), h("label", {}, "Your email", contact));
86
+ void project.then(found => {
87
+ if (!found)
88
+ return;
89
+ meta.textContent = length ? `${found.name} · ${length}` : found.name;
90
+ meta.hidden = false;
91
+ if (details)
92
+ details.hidden = !found.signup;
93
+ });
94
+ // VR has only speaking, so Quest's invite doesn't offer typing.
95
+ const type = device === "quest" ? null : h("button", { className: "quiet", type: "submit" }, "Start without mic, type instead");
96
+ const recorded = `Only your voice between ${device === "phone" ? "taps" : "presses"} is recorded, plus a screenshot when asked.`;
97
+ // Playing through a link signs a reviewer up for new-version emails, so they read it before they start. An id this
98
+ // browser holds from a sign-up signs up nobody: only its invitation's link does.
99
+ const emails = greeted && tester.linked ? "By starting, you'll get an email when a new version is ready. Each one has a link to stop them." : null;
100
+ const form = h("form", {}, details, start, type, h("p", { className: "fine" }, recorded), emails ? h("p", { className: "fine" }, emails) : null);
101
+ const lead = "Start for a short spoken guide, then try it out and say what you notice.";
102
+ cover.show([
103
+ h("h1", {}, greeted ? (tester.name ? `Hello, ${tester.name}` : "Hello again") : "You've been invited to give feedback"),
104
+ meta,
105
+ h("p", { className: "lead" }, greeted ? `You've been invited to give feedback. ${lead}` : lead),
106
+ ...(task ? [h("div", { className: "brief" }, h("h2", {}, "Your task"), h("p", {}, task))] : []),
107
+ form,
108
+ ]);
109
+ // Never a native submit, which would reload the page; disabled buttons also end double clicks and Enter.
110
+ form.addEventListener("submit", event => {
111
+ event.preventDefault();
112
+ const typed = event.submitter !== null && event.submitter === type;
113
+ if (type)
114
+ tester.chooseTyping(typed);
115
+ start.disabled = true;
116
+ if (type)
117
+ type.disabled = true;
118
+ if (!granted && !typed)
119
+ start.textContent = "Waiting for microphone…";
120
+ if (details && !details.hidden)
121
+ tester.enter(name.value, contact.value);
122
+ });
123
+ return form;
124
+ }
125
+ /** Whether the tester is a reviewer the service knows, whose stored name then stands for theirs. */
126
+ async function known({ options, tester }) {
127
+ if (!tester.reviewer)
128
+ return false;
129
+ try {
130
+ const reviewer = await findReviewer(options.project, tester.reviewer);
131
+ tester.found(reviewer);
132
+ return reviewer !== null;
133
+ }
134
+ catch (error) {
135
+ // The service checks the id again when the session starts; until then the tester is greeted like anyone.
136
+ console.error("feedback: could not check the reviewer", error);
137
+ return false;
138
+ }
139
+ }
140
+ /**
141
+ * Resolves once the tester chooses to go on, on a device the playtest isn't for. The link, which a reviewer's names
142
+ * them in, is the easiest way to the right device, so it is shown in full and can be copied.
143
+ */
144
+ async function notice({ signal }, cover, devices) {
145
+ const copy = button("secondary", "Copy link");
146
+ copy.addEventListener("click", () => {
147
+ navigator.clipboard.writeText(location.href).then(() => (copy.textContent = "Copied"),
148
+ // Without clipboard access the link stays selectable.
149
+ error => console.error("feedback: could not copy the link", error));
150
+ });
151
+ const go = button("quiet", "Continue on this device");
152
+ cover.show([
153
+ h("h1", {}, `This test is for ${devices}`),
154
+ h("p", { className: "lead" }, `Testing is focused on ${devices} right now, and this looks like a different device. If you can, open this link there instead.`),
155
+ h("div", { className: "url" }, h("code", {}, location.href), copy),
156
+ h("div", { className: "actions" }, go),
157
+ ]);
158
+ await wait(signal, (done, over) => go.addEventListener("click", () => done(undefined), { signal: over }));
159
+ }
160
+ /** Whether the microphone is already allowed. Safari and Firefox may not answer for it, which counts as no. */
161
+ async function allowed() {
162
+ const status = await navigator.permissions.query({ name: "microphone" }).catch(() => null);
163
+ return status?.state === "granted";
164
+ }
165
+ /** Calls `run` when an XR session starts, at once when one already has, as during the microphone prompt. */
166
+ function entered({ options }, run, signal) {
167
+ const xr = options.renderer.xr;
168
+ if (xr.isPresenting)
169
+ return run();
170
+ xr.addEventListener("sessionstart", run);
171
+ signal.addEventListener("abort", () => xr.removeEventListener("sessionstart", run), { once: true });
172
+ }
@@ -0,0 +1,20 @@
1
+ /** Why there is no microphone: the player denied it or has none, or it is broken, which is logged. */
2
+ export type NoMic = "denied" | "broken";
3
+ export interface Recording {
4
+ /** When it started, on `performance.now()`'s clock. */
5
+ since: number;
6
+ /**
7
+ * Resolves once it stopped: by `stop()`, at the time or size cap, so a long take is sent shortened rather than
8
+ * refused, or when the microphone went away. Null when the level never reached speech.
9
+ */
10
+ audio: Promise<Blob | null>;
11
+ stop(): void;
12
+ }
13
+ /**
14
+ * Opens the microphone. Close it right after use: a stream left open makes iOS lower and reroute the game's audio.
15
+ * Rejects at once when `signal` aborts, even during the permission prompt; a stream granted later is closed.
16
+ */
17
+ export declare function openMic(signal: AbortSignal): Promise<MediaStream | NoMic>;
18
+ export declare function close(stream: MediaStream): void;
19
+ /** Records the microphone until stopped; aborting `signal` stops it too, and the microphone closes either way. */
20
+ export declare function record(context: AudioContext, signal: AbortSignal): Promise<Recording | NoMic>;
@@ -0,0 +1,98 @@
1
+ import { LIMITS } from "../service.js";
2
+ import { wait } from "./wait.js";
3
+ /** RMS that counts as speech, and how many 50 ms samples in a row must reach it. Below that, nothing was heard. */
4
+ const SPEECH = 0.02;
5
+ const SPEECH_SAMPLES = 4;
6
+ const FORMATS = ["audio/webm;codecs=opus", "audio/mp4"];
7
+ /** Room left below the audio cap for what the recorder still buffers when it stops; it hands over data every second. */
8
+ const MARGIN = 64 * 1024;
9
+ /**
10
+ * Opens the microphone. Close it right after use: a stream left open makes iOS lower and reroute the game's audio.
11
+ * Rejects at once when `signal` aborts, even during the permission prompt; a stream granted later is closed.
12
+ */
13
+ export async function openMic(signal) {
14
+ // Async, so that an insecure page, which has no `mediaDevices`, rejects like any other failure.
15
+ const asked = (async () => navigator.mediaDevices.getUserMedia({ audio: true }))();
16
+ try {
17
+ return await wait(signal, (done, _, fail) => void asked.then(done, fail));
18
+ }
19
+ catch (error) {
20
+ if (signal.aborted) {
21
+ asked.then(close, () => { });
22
+ throw error;
23
+ }
24
+ if (error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "NotFoundError"))
25
+ return "denied";
26
+ console.error("feedback: the microphone failed", error);
27
+ return "broken";
28
+ }
29
+ }
30
+ export function close(stream) {
31
+ for (const track of stream.getTracks())
32
+ track.stop();
33
+ }
34
+ /** Records the microphone until stopped; aborting `signal` stops it too, and the microphone closes either way. */
35
+ export async function record(context, signal) {
36
+ const stream = await openMic(signal);
37
+ if (typeof stream === "string")
38
+ return stream;
39
+ try {
40
+ return start(context, stream, signal);
41
+ }
42
+ catch (error) {
43
+ // Such as Firefox, whose analyser refuses a microphone at another sample rate than the AudioContext.
44
+ close(stream);
45
+ console.error("feedback: could not record the microphone", error);
46
+ return "broken";
47
+ }
48
+ }
49
+ function start(context, stream, signal) {
50
+ const mimeType = FORMATS.find(format => MediaRecorder.isTypeSupported(format));
51
+ if (!mimeType)
52
+ throw new Error("feedback: this browser can record neither webm/opus nor mp4");
53
+ const recorder = new MediaRecorder(stream, { mimeType, audioBitsPerSecond: 32_000 });
54
+ const stop = () => recorder.state !== "inactive" && recorder.stop();
55
+ const chunks = [];
56
+ let bytes = 0;
57
+ recorder.addEventListener("dataavailable", event => {
58
+ chunks.push(event.data);
59
+ bytes += event.data.size;
60
+ if (bytes >= LIMITS.audio - MARGIN)
61
+ stop();
62
+ });
63
+ const source = context.createMediaStreamSource(stream);
64
+ const analyser = new AnalyserNode(context, { fftSize: 2048 });
65
+ source.connect(analyser);
66
+ const samples = new Float32Array(analyser.fftSize);
67
+ // Without a running AudioContext there is no level, so the service decides whether anything was said.
68
+ let measured = false;
69
+ let heard = false;
70
+ let loud = 0;
71
+ const meter = setInterval(() => {
72
+ if (context.state !== "running")
73
+ return;
74
+ measured = true;
75
+ analyser.getFloatTimeDomainData(samples);
76
+ loud = rms(samples) >= SPEECH ? loud + 1 : 0;
77
+ heard ||= loud >= SPEECH_SAMPLES;
78
+ }, 50);
79
+ const cap = setTimeout(stop, LIMITS.seconds * 1000);
80
+ signal.addEventListener("abort", stop, { once: true });
81
+ const audio = new Promise(resolve => {
82
+ recorder.addEventListener("stop", () => {
83
+ clearTimeout(cap);
84
+ clearInterval(meter);
85
+ source.disconnect();
86
+ close(stream);
87
+ resolve(heard || !measured ? new Blob(chunks, { type: mimeType }) : null);
88
+ }, { once: true });
89
+ });
90
+ recorder.start(1000);
91
+ return { since: performance.now(), audio, stop };
92
+ }
93
+ function rms(samples) {
94
+ let sum = 0;
95
+ for (const sample of samples)
96
+ sum += sample * sample;
97
+ return Math.sqrt(sum / samples.length);
98
+ }
@@ -0,0 +1,24 @@
1
+ controller.webp is rendered from the Meta Quest Touch Plus left controller model in
2
+ @webxr-input-profiles/assets 1.0.20 (https://github.com/immersive-web/webxr-input-profiles), under this licence:
3
+
4
+ MIT License
5
+
6
+ Copyright (c) 2019 Amazon
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is furnished
13
+ to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice (including the next
16
+ paragraph) shall be included in all copies or substantial portions of the
17
+ Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
21
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
22
+ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
24
+ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Binary file
@@ -0,0 +1,32 @@
1
+ import type { PublicProject } from "../api.ts";
2
+ import type { FeedbackOptions } from "../index.ts";
3
+ import type { Tester } from "../tester.ts";
4
+ import { type Device } from "./device.ts";
5
+ import { Frames } from "./frame.ts";
6
+ import { Input } from "./input.ts";
7
+ import { Sounds } from "./sounds.ts";
8
+ import { Ui } from "./ui.ts";
9
+ /** What the intro, the turns and the ending work with. `signal` aborts on dispose. */
10
+ export interface Context {
11
+ options: FeedbackOptions;
12
+ tester: Tester;
13
+ device: Device;
14
+ signal: AbortSignal;
15
+ input: Input;
16
+ ui: Ui;
17
+ sounds: Sounds;
18
+ frames: Frames;
19
+ /** What testers see of the project, or null when the key is invalid or the check failed; both are logged. */
20
+ project: Promise<Pick<PublicProject, "name" | "signup"> | null>;
21
+ }
22
+ /**
23
+ * Feedback mode, loaded only when it is on: the intro, feedback turns until the game calls `finish()`, then the ending.
24
+ * `version`: the build being tested, if known.
25
+ */
26
+ export declare function run({ options, tester, version, finished, signal }: {
27
+ options: FeedbackOptions;
28
+ tester: Tester;
29
+ version: string | null;
30
+ finished: Promise<void>;
31
+ signal: AbortSignal;
32
+ }): Promise<void>;
@@ -0,0 +1,78 @@
1
+ import { detect } from "./device.js";
2
+ import { ending } from "./ending.js";
3
+ import { Frames } from "./frame.js";
4
+ import { Input, learnHotkey } from "./input.js";
5
+ import { intro } from "./intro.js";
6
+ import { checkProject } from "./server.js";
7
+ import { Sounds } from "./sounds.js";
8
+ import { turn } from "./turn.js";
9
+ import { Ui } from "./ui.js";
10
+ import { wait } from "./wait.js";
11
+ /**
12
+ * Feedback mode, loaded only when it is on: the intro, feedback turns until the game calls `finish()`, then the ending.
13
+ * `version`: the build being tested, if known.
14
+ */
15
+ export async function run({ options, tester, version, finished, signal }) {
16
+ if (signal.aborted)
17
+ return;
18
+ const project = checkProject(options.project).catch(error => {
19
+ console.error("feedback: could not check the project key", error);
20
+ return null;
21
+ });
22
+ await learnHotkey();
23
+ const device = detect();
24
+ const input = new Input(signal);
25
+ const sounds = new Sounds(signal);
26
+ const ui = new Ui(device, kind => input.corner(kind), options.theme, signal);
27
+ // A hold fills the corner button, with a rising tone and a pulse at each fifth.
28
+ let fifth = 0;
29
+ input.onHold = (kind, progress) => {
30
+ ui.hold(progress);
31
+ const next = Math.floor(progress * 5);
32
+ if (next > fifth) {
33
+ sounds.hold(progress);
34
+ input.pulse(kind, 0.3, 20);
35
+ }
36
+ fifth = next;
37
+ };
38
+ // The first time each XR input shows up in this browser, outside a turn, a voice line explains its hold.
39
+ const hinted = new Set();
40
+ const frames = new Frames(options.renderer, frame => {
41
+ input.poll(frame);
42
+ const hand = input.hand;
43
+ if (!hand || ui.busy || hinted.has(hand))
44
+ return;
45
+ hinted.add(hand);
46
+ if (tester.firstHint(hand))
47
+ sounds.say(`hold-${hand}`);
48
+ }, signal);
49
+ const ctx = { options, tester, device, signal, input, ui, sounds, frames, project };
50
+ try {
51
+ const session = await intro(ctx, version);
52
+ for (;;) {
53
+ const kind = await wait(signal, (done, over) => {
54
+ input.on(null, done, over);
55
+ void finished.then(() => done(null));
56
+ });
57
+ if (!kind)
58
+ break;
59
+ try {
60
+ await turn(ctx, session, kind);
61
+ }
62
+ catch (error) {
63
+ if (signal.aborted)
64
+ throw error;
65
+ // One broken turn shouldn't end feedback for the rest of the playtest. Send failures end the turn themselves.
66
+ console.error("feedback:", error);
67
+ ui.flash({ big: ["Something went wrong"], small: ["Your feedback may not have been saved."] }, 2500);
68
+ sounds.play("failed");
69
+ }
70
+ }
71
+ session.finish();
72
+ await ending(ctx, session);
73
+ }
74
+ catch (error) {
75
+ if (!signal.aborted)
76
+ throw error;
77
+ }
78
+ }
@@ -0,0 +1,22 @@
1
+ import type { Answer, PublicProject, PublicReviewer, StartSession, Submission, Submitted } from "../api.ts";
2
+ /** A request that can't succeed however often it is sent: the server refused it, or it breaks a limit. */
3
+ export declare class Refused extends Error {
4
+ }
5
+ /** What the tester can do after a send failed: nothing when it was `refused`, else check their connection and `retry`. */
6
+ export declare function advice(refused: boolean, retry: string): string;
7
+ /** Logs which project the key belongs to, so a wrong key shows on load, and resolves with what testers see of it. */
8
+ export declare function checkProject(key: string): Promise<Pick<PublicProject, "name" | "signup"> | null>;
9
+ /** Who a reviewer link greets; null when `project` has no such reviewer, such as one who stopped the emails. */
10
+ export declare function findReviewer(project: string, id: string): Promise<Pick<PublicReviewer, "name"> | null>;
11
+ export declare function submit(submission: Submission, audio: Blob | null, signal: AbortSignal): Promise<Submitted>;
12
+ /** Sent only after `submit` succeeded, so the item exists. */
13
+ export declare function attach(id: string, jpeg: Blob, signal: AbortSignal): Promise<void>;
14
+ /** Starts a session; resolves with the tester's reviewer id when they play through their own link, else null. */
15
+ export declare function startSession(start: StartSession, signal: AbortSignal): Promise<string | null>;
16
+ /** Signs the session's tester up as a reviewer. The answer's id is a placeholder: their own link comes by email. */
17
+ export declare function joinReviewers(session: string, email: string, signal: AbortSignal): Promise<void>;
18
+ export declare function storeAnswers(session: string, answers: Answer[], signal: AbortSignal): Promise<void>;
19
+ /** Marks the session finished; it outlives the page, since the game may navigate away right after. */
20
+ export declare function finishSession(session: string): Promise<void>;
21
+ /** Marks the session seen from a page being hidden; without a body it needs no CORS preflight, which it can't wait for. */
22
+ export declare function seeSession(session: string): Promise<void>;
@@ -0,0 +1,147 @@
1
+ import { LIMITS, ORIGIN } from "../service.js";
2
+ /** Vite dev can point the SDK at a local Worker. */
3
+ const origin = (import.meta.env?.DEV && import.meta.env.VITE_DRAWCALL_FEEDBACK_URL) || ORIGIN;
4
+ /** Pauses between the first attempts, then `PAUSE_MS` until the time runs out. */
5
+ const PAUSES = [1000, 3000];
6
+ const PAUSE_MS = 6000;
7
+ /** One attempt may take this long, since the POST transcribes within its request. */
8
+ const ATTEMPT_MS = 30_000;
9
+ /** Attempts stop starting after this long. The player is already playing again, so it can be generous. */
10
+ const TOTAL_MS = 60_000;
11
+ /** The checks on load don't retry; the intro goes on without them. */
12
+ const CHECK_MS = 8000;
13
+ /** A request that can't succeed however often it is sent: the server refused it, or it breaks a limit. */
14
+ export class Refused extends Error {
15
+ }
16
+ /** What the tester can do after a send failed: nothing when it was `refused`, else check their connection and `retry`. */
17
+ export function advice(refused, retry) {
18
+ return refused ? "It was refused, so sending it again won't help." : `Check your connection and ${retry}.`;
19
+ }
20
+ /** Logs which project the key belongs to, so a wrong key shows on load, and resolves with what testers see of it. */
21
+ export async function checkProject(key) {
22
+ const response = await fetch(url(`/projects/${encodeURIComponent(key)}/public`), { signal: AbortSignal.timeout(CHECK_MS) });
23
+ const text = await response.text();
24
+ if (!response.ok) {
25
+ console.error(`feedback: invalid project key "${key}" (${message(response.status, text)})`);
26
+ return null;
27
+ }
28
+ const { name, signup } = parse(text);
29
+ if (typeof name !== "string" || typeof signup !== "boolean")
30
+ throw unexpected(text);
31
+ console.info(`feedback: project ${name}`);
32
+ return { name, signup };
33
+ }
34
+ /** Who a reviewer link greets; null when `project` has no such reviewer, such as one who stopped the emails. */
35
+ export async function findReviewer(project, id) {
36
+ const response = await fetch(url(`/reviewers/${encodeURIComponent(id)}`), { signal: AbortSignal.timeout(CHECK_MS) });
37
+ if (response.status === 404)
38
+ return null;
39
+ const text = await response.text();
40
+ if (!response.ok)
41
+ throw new Error(`feedback: ${message(response.status, text)}`);
42
+ const body = parse(text);
43
+ if (typeof body.project !== "string" || !isName(body.name))
44
+ throw unexpected(text);
45
+ return body.project === project ? { name: body.name } : null;
46
+ }
47
+ export async function submit(submission, audio, signal) {
48
+ if (audio && audio.size > LIMITS.audio)
49
+ throw new Refused(`feedback: the recording is ${audio.size} bytes, over the ${LIMITS.audio} byte cap`);
50
+ const body = new FormData();
51
+ body.append("meta", JSON.stringify(submission));
52
+ if (audio)
53
+ body.append("audio", audio, audio.type.startsWith("audio/mp4") ? "audio.mp4" : "audio.webm");
54
+ const text = await send("/items", { method: "POST", body }, signal);
55
+ const { status, n, visual } = parse(text);
56
+ if (status === "empty")
57
+ return { status };
58
+ if (status !== "stored" || typeof n !== "number" || typeof visual !== "boolean")
59
+ throw unexpected(text);
60
+ return { status, n, visual };
61
+ }
62
+ /** Sent only after `submit` succeeded, so the item exists. */
63
+ export async function attach(id, jpeg, signal) {
64
+ await send(`/items/${id}/screenshot`, { method: "PUT", body: jpeg, headers: { "content-type": "image/jpeg" } }, signal);
65
+ }
66
+ /** Starts a session; resolves with the tester's reviewer id when they play through their own link, else null. */
67
+ export async function startSession(start, signal) {
68
+ const text = await post("/sessions", start, signal);
69
+ const { reviewer } = parse(text);
70
+ if (!isName(reviewer))
71
+ throw unexpected(text);
72
+ return reviewer;
73
+ }
74
+ /** Signs the session's tester up as a reviewer. The answer's id is a placeholder: their own link comes by email. */
75
+ export async function joinReviewers(session, email, signal) {
76
+ await post(`/sessions/${session}/join`, { email }, signal);
77
+ }
78
+ export async function storeAnswers(session, answers, signal) {
79
+ await post(`/sessions/${session}/answers`, { answers }, signal);
80
+ }
81
+ /** Marks the session finished; it outlives the page, since the game may navigate away right after. */
82
+ export async function finishSession(session) {
83
+ await send(`/sessions/${session}/finish`, { method: "POST", keepalive: true });
84
+ }
85
+ /** Marks the session seen from a page being hidden; without a body it needs no CORS preflight, which it can't wait for. */
86
+ export async function seeSession(session) {
87
+ await send(`/sessions/${session}/seen`, { method: "POST", keepalive: true });
88
+ }
89
+ function url(path) {
90
+ return `${origin}/api/v1${path}`;
91
+ }
92
+ function post(path, body, signal) {
93
+ return send(path, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }, signal);
94
+ }
95
+ /**
96
+ * Retries network errors, timeouts and server-side failures, and resolves with the body of the answer. Safe because
97
+ * everything sent carries a client-made id. The body is read inside the attempt, so its timeout covers it too. Throws
98
+ * `Refused` for a 4xx answer, and the last failure once the time is up.
99
+ */
100
+ async function send(path, init, signal) {
101
+ const deadline = performance.now() + TOTAL_MS;
102
+ for (let i = 0;; i++) {
103
+ let failure;
104
+ try {
105
+ const timeout = AbortSignal.timeout(Math.min(ATTEMPT_MS, deadline - performance.now()));
106
+ const response = await fetch(url(path), { ...init, signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
107
+ const text = await response.text();
108
+ if (response.ok)
109
+ return text;
110
+ const error = `feedback: ${message(response.status, text)}`;
111
+ if (response.status < 500 && response.status !== 429)
112
+ throw new Refused(error);
113
+ failure = new Error(error);
114
+ }
115
+ catch (error) {
116
+ signal?.throwIfAborted();
117
+ // fetch rejects with a TypeError when the network fails, and a TimeoutError when the attempt ran out of time.
118
+ if (!(error instanceof TypeError || (error instanceof DOMException && error.name === "TimeoutError")))
119
+ throw error;
120
+ failure = error;
121
+ }
122
+ const pause = PAUSES[i] ?? PAUSE_MS;
123
+ if (performance.now() + pause >= deadline)
124
+ throw failure;
125
+ await new Promise(resolve => setTimeout(resolve, pause));
126
+ signal?.throwIfAborted();
127
+ }
128
+ }
129
+ const isName = (value) => value === null || typeof value === "string";
130
+ function unexpected(text) {
131
+ return new Error(`feedback: unexpected answer ${text.slice(0, 200)}`);
132
+ }
133
+ /** The status with the Worker's `{ message }`, or with the start of whatever else answered, such as a proxy's page. */
134
+ function message(status, text) {
135
+ const { message } = parse(text);
136
+ return `${status} ${typeof message === "string" ? message : text.slice(0, 200)}`;
137
+ }
138
+ /** The fields of a JSON object; none when the text is anything else. */
139
+ function parse(text) {
140
+ try {
141
+ const value = JSON.parse(text);
142
+ return typeof value === "object" && value !== null ? { ...value } : {};
143
+ }
144
+ catch {
145
+ return {};
146
+ }
147
+ }
@@ -0,0 +1,31 @@
1
+ import type { Answer, Submission } from "../api.ts";
2
+ import type { FeedbackOptions } from "../index.ts";
3
+ import type { Tester } from "../tester.ts";
4
+ /**
5
+ * The tester's playtest on this page load, as the service records it: started once, when the tester starts, seen again
6
+ * each time the page is hidden or closed, and finished when the game calls `finish()`. Its items point at it, so the
7
+ * owner sees who played which version, for how long, whether to the end, and what they said along the way.
8
+ */
9
+ export declare class Session {
10
+ #private;
11
+ readonly id: `${string}-${string}-${string}-${string}-${string}`;
12
+ /** Starts the session, retried since every item points at it. Playing through one's own reviewer link enlists. */
13
+ constructor(options: FeedbackOptions, tester: Tester, version: string | null, signal: AbortSignal);
14
+ finish(): void;
15
+ /** Stores the closing screen's answers with the session. Rejects when they can't be, also when it never started. */
16
+ answer(answers: Answer[]): Promise<void>;
17
+ /** Joins the tester to the project's reviewers with `email`, from the closing screen. Rejects like `answer`. */
18
+ join(email: string): Promise<void>;
19
+ /** An item of this session, from what the tester said or typed, in `xr` when given in an XR session. */
20
+ item({ xr, state, text, overall }: {
21
+ xr?: XRSession | null;
22
+ state?: Submission["state"];
23
+ text?: string;
24
+ overall?: true;
25
+ }): Submission;
26
+ /**
27
+ * Calls the game's `pause()` and returns its state as plain JSON within the size cap: the round trip applies toJSON
28
+ * and drops undefined and functions. Null when it threw, returned nothing JSON can hold, or too much; all logged.
29
+ */
30
+ pause(): Submission["state"];
31
+ }