@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
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @drawcall/feedback
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Voice feedback for three.js apps and experiences, on flat screens and in WebXR. Testers open your app with `?feedback`, or a published playtest link, describe what they notice and carry on. The app supplies the context.
4
+
5
+ ```sh
6
+ npx drawcall auth login
7
+ npx drawcall feedback project create "Moonbase"
8
+ ```
9
+
10
+ ```ts
11
+ import { feedback } from "@drawcall/feedback";
12
+
13
+ let wasPaused = false;
14
+ const fb = feedback({
15
+ project: "fbk_pub_…",
16
+ renderer,
17
+ minutes: 10, // shown to testers before they start, with the task
18
+ task: "Find the crater and say where you got lost.",
19
+ pause: () => {
20
+ wasPaused = app.paused;
21
+ app.paused = true; // freeze the world, never the camera or the user
22
+ return { scene: app.scene.id }; // the facts someone fixing a report needs first
23
+ },
24
+ resume: () => { app.paused = wasPaused; },
25
+ });
26
+ ```
27
+
28
+ Publish a build for testers, always in feedback mode, and send them its link:
29
+
30
+ ```sh
31
+ npx drawcall feedback publish dist
32
+ ```
33
+
34
+ Reviewers get one invitation with their own link, and once they try it out, an email when a new version is ready:
35
+
36
+ ```sh
37
+ npx drawcall feedback reviewer add sam@studio.com --name Sam --trust high
38
+ ```
39
+
40
+ Then see who tested what and their reviewer trust, inspect reports, and resolve verified fixes:
41
+
42
+ ```sh
43
+ npx drawcall feedback list
44
+ npx drawcall feedback show 42 --out ./feedback
45
+ npx drawcall feedback resolve 42
46
+ ```
47
+
48
+ Every command prints JSON, errors too (on stderr, exiting 2 for misuse and 1 otherwise), and `--help` lists the commands. Coding agents read the `feedback` skill in `skills/feedback`, also printed by `npx drawcall feedback skill`.
package/dist/api.d.ts ADDED
@@ -0,0 +1,328 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The HTTP contract between the browser SDK, the CLI and the Worker at feedback.drawcall.ai. Players write with a
4
+ * project key, which ships in game code and is therefore public: it can only add sessions and items. Everything that
5
+ * reads or changes a project needs a Drawcall sign-in.
6
+ */
7
+ /** A project key: `fbk_pub_` and 16 base62 characters. */
8
+ export declare const keySchema: z.ZodString;
9
+ /** Such as `1.4.0`; see `VERSION_FORMAT`. */
10
+ export declare const versionSchema: z.ZodString;
11
+ /** A reviewer's id, in their link as `?reviewer=<id>`. */
12
+ export declare const reviewerIdSchema: z.ZodString;
13
+ /** The smallest version change a reviewer hears about: `major` for 2.0.0 after 1.4.0, `minor` also for 1.5.0, `patch` for any. */
14
+ export declare const levelSchema: z.ZodEnum<{
15
+ major: "major";
16
+ minor: "minor";
17
+ patch: "patch";
18
+ }>;
19
+ /** The owner's confidence in a reviewer's feedback, independent of their email subscription. New reviewers start low. */
20
+ export declare const trustSchema: z.ZodEnum<{
21
+ low: "low";
22
+ medium: "medium";
23
+ high: "high";
24
+ }>;
25
+ export type Trust = z.infer<typeof trustSchema>;
26
+ /**
27
+ * `POST /api/v1/sessions`: a tester started the playtest, on one page load. The client makes the id with
28
+ * `crypto.randomUUID()`, so a retry creates nothing new.
29
+ */
30
+ export declare const startSessionSchema: z.ZodObject<{
31
+ id: z.ZodUUID;
32
+ project: z.ZodString;
33
+ tester: z.ZodNullable<z.ZodString>;
34
+ contact: z.ZodNullable<z.ZodEmail>;
35
+ version: z.ZodNullable<z.ZodString>;
36
+ url: z.ZodPipe<z.ZodString, z.ZodURL>;
37
+ device: z.ZodString;
38
+ reviewer: z.ZodDefault<z.ZodNullable<z.ZodString>>;
39
+ minutes: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
40
+ }, z.core.$strip>;
41
+ export type StartSession = z.input<typeof startSessionSchema>;
42
+ /**
43
+ * The answer to `POST /api/v1/sessions`: the tester's reviewer id only when they play through their own emailed link,
44
+ * else null. `…/:id/join` answers with a random placeholder, never stored, which older published builds need non-null.
45
+ */
46
+ export interface Started {
47
+ reviewer: string | null;
48
+ }
49
+ /**
50
+ * One answer to a closing question: its id and wording as asked, the scale's number or the options picked, and what it
51
+ * was picked from, so it reads right on its own. Older answers have no `scale` or `options`.
52
+ */
53
+ export declare const answerSchema: z.ZodObject<{
54
+ id: z.ZodString;
55
+ ask: z.ZodString;
56
+ answer: z.ZodUnion<readonly [z.ZodNumber, z.ZodArray<z.ZodString>]>;
57
+ scale: z.ZodOptional<z.ZodObject<{
58
+ min: z.ZodNumber;
59
+ max: z.ZodNumber;
60
+ labels: z.ZodOptional<z.ZodTuple<[z.ZodString, z.ZodString], null>>;
61
+ }, z.core.$strip>>;
62
+ options: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ }, z.core.$strip>;
64
+ export type Answer = z.infer<typeof answerSchema>;
65
+ /** `POST /api/v1/sessions/:id/join`: a tester leaves their email on the closing screen, to hear about new versions. */
66
+ export declare const joinSchema: z.ZodObject<{
67
+ email: z.ZodEmail;
68
+ }, z.core.$strip>;
69
+ /** `POST /api/v1/sessions/:id/answers`; a later post replaces it. */
70
+ export declare const answersSchema: z.ZodObject<{
71
+ answers: z.ZodArray<z.ZodObject<{
72
+ id: z.ZodString;
73
+ ask: z.ZodString;
74
+ answer: z.ZodUnion<readonly [z.ZodNumber, z.ZodArray<z.ZodString>]>;
75
+ scale: z.ZodOptional<z.ZodObject<{
76
+ min: z.ZodNumber;
77
+ max: z.ZodNumber;
78
+ labels: z.ZodOptional<z.ZodTuple<[z.ZodString, z.ZodString], null>>;
79
+ }, z.core.$strip>>;
80
+ options: z.ZodOptional<z.ZodArray<z.ZodString>>;
81
+ }, z.core.$strip>>;
82
+ }, z.core.$strip>;
83
+ /** `GET /api/v1/reviewers/:id`: who a reviewer link greets, and for which project. */
84
+ export interface PublicReviewer {
85
+ project: string;
86
+ name: string | null;
87
+ }
88
+ /** The `meta` part of `POST /api/v1/items`, sent as JSON next to an `audio` file part. */
89
+ export declare const submissionSchema: z.ZodObject<{
90
+ id: z.ZodUUID;
91
+ project: z.ZodString;
92
+ session: z.ZodUUID;
93
+ tester: z.ZodNullable<z.ZodString>;
94
+ contact: z.ZodNullable<z.ZodEmail>;
95
+ url: z.ZodPipe<z.ZodString, z.ZodURL>;
96
+ device: z.ZodString;
97
+ xr: z.ZodNullable<z.ZodString>;
98
+ state: z.ZodNullable<z.ZodJSONSchema>;
99
+ text: z.ZodOptional<z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>>;
100
+ overall: z.ZodDefault<z.ZodBoolean>;
101
+ }, z.core.$strip>;
102
+ export type Submission = z.input<typeof submissionSchema>;
103
+ /** The answer to `POST /api/v1/items`. `empty`: nothing was heard, so nothing was stored. */
104
+ export type Submitted = {
105
+ status: "stored";
106
+ n: number;
107
+ visual: boolean;
108
+ } | {
109
+ status: "empty";
110
+ };
111
+ /**
112
+ * `GET /api/v1/projects/:key/public`: confirms a key at startup, and what the intro shows. `minutes`, `note` and
113
+ * `welcome` never change, for games built with an earlier SDK.
114
+ */
115
+ export interface PublicProject {
116
+ name: string;
117
+ signup: boolean;
118
+ minutes: null;
119
+ note: null;
120
+ welcome: false;
121
+ }
122
+ /** The shapes of the responses, as stored: input rules are checked on the way in, so these use plain types. */
123
+ export declare const projectSchema: z.ZodObject<{
124
+ key: z.ZodString;
125
+ name: z.ZodString;
126
+ notify: z.ZodNullable<z.ZodString>;
127
+ signup: z.ZodBoolean;
128
+ site: z.ZodNullable<z.ZodString>;
129
+ version: z.ZodNullable<z.ZodString>;
130
+ createdAt: z.ZodISODateTime;
131
+ }, z.core.$strip>;
132
+ export type Project = z.infer<typeof projectSchema>;
133
+ export declare const createProjectSchema: z.ZodObject<{
134
+ name: z.ZodString;
135
+ }, z.core.$strip>;
136
+ export declare const updateProjectSchema: z.ZodObject<{
137
+ notify: z.ZodOptional<z.ZodBoolean>;
138
+ signup: z.ZodOptional<z.ZodBoolean>;
139
+ }, z.core.$strip>;
140
+ /** Why an item was resolved, as in an issue tracker. */
141
+ export declare const resolutionSchema: z.ZodEnum<{
142
+ fixed: "fixed";
143
+ duplicate: "duplicate";
144
+ "cannot-reproduce": "cannot-reproduce";
145
+ "wont-fix": "wont-fix";
146
+ }>;
147
+ export type Resolution = z.infer<typeof resolutionSchema>;
148
+ /** Which items `GET …/sessions` lists; `all` also lists sessions without any. */
149
+ export declare const statusSchema: z.ZodEnum<{
150
+ open: "open";
151
+ resolved: "resolved";
152
+ all: "all";
153
+ }>;
154
+ export declare const itemSchema: z.ZodObject<{
155
+ n: z.ZodNumber;
156
+ status: z.ZodEnum<{
157
+ open: "open";
158
+ resolved: "resolved";
159
+ }>;
160
+ resolution: z.ZodNullable<z.ZodEnum<{
161
+ fixed: "fixed";
162
+ duplicate: "duplicate";
163
+ "cannot-reproduce": "cannot-reproduce";
164
+ "wont-fix": "wont-fix";
165
+ }>>;
166
+ duplicateOf: z.ZodNullable<z.ZodNumber>;
167
+ comment: z.ZodNullable<z.ZodString>;
168
+ tester: z.ZodNullable<z.ZodString>;
169
+ contact: z.ZodNullable<z.ZodString>;
170
+ trust: z.ZodEnum<{
171
+ low: "low";
172
+ medium: "medium";
173
+ high: "high";
174
+ }>;
175
+ at: z.ZodISODateTime;
176
+ transcript: z.ZodNullable<z.ZodString>;
177
+ visual: z.ZodBoolean;
178
+ overall: z.ZodBoolean;
179
+ screenshot: z.ZodBoolean;
180
+ audio: z.ZodNullable<z.ZodString>;
181
+ state: z.ZodNullable<z.ZodJSONSchema>;
182
+ url: z.ZodString;
183
+ device: z.ZodString;
184
+ xr: z.ZodNullable<z.ZodString>;
185
+ session: z.ZodString;
186
+ version: z.ZodNullable<z.ZodString>;
187
+ }, z.core.$strip>;
188
+ export type Item = z.infer<typeof itemSchema>;
189
+ /**
190
+ * One tester's playtest on one page load. `seenAt` is the last sign of them, so `seenAt - startedAt` is about how long
191
+ * they played; `finishedAt` is when the game called `finish()`.
192
+ */
193
+ export declare const sessionSchema: z.ZodObject<{
194
+ id: z.ZodString;
195
+ tester: z.ZodNullable<z.ZodString>;
196
+ contact: z.ZodNullable<z.ZodString>;
197
+ version: z.ZodNullable<z.ZodString>;
198
+ url: z.ZodString;
199
+ device: z.ZodString;
200
+ startedAt: z.ZodISODateTime;
201
+ seenAt: z.ZodISODateTime;
202
+ finishedAt: z.ZodNullable<z.ZodISODateTime>;
203
+ items: z.ZodArray<z.ZodObject<{
204
+ n: z.ZodNumber;
205
+ status: z.ZodEnum<{
206
+ open: "open";
207
+ resolved: "resolved";
208
+ }>;
209
+ resolution: z.ZodNullable<z.ZodEnum<{
210
+ fixed: "fixed";
211
+ duplicate: "duplicate";
212
+ "cannot-reproduce": "cannot-reproduce";
213
+ "wont-fix": "wont-fix";
214
+ }>>;
215
+ duplicateOf: z.ZodNullable<z.ZodNumber>;
216
+ comment: z.ZodNullable<z.ZodString>;
217
+ tester: z.ZodNullable<z.ZodString>;
218
+ contact: z.ZodNullable<z.ZodString>;
219
+ trust: z.ZodEnum<{
220
+ low: "low";
221
+ medium: "medium";
222
+ high: "high";
223
+ }>;
224
+ at: z.ZodISODateTime;
225
+ transcript: z.ZodNullable<z.ZodString>;
226
+ visual: z.ZodBoolean;
227
+ overall: z.ZodBoolean;
228
+ screenshot: z.ZodBoolean;
229
+ audio: z.ZodNullable<z.ZodString>;
230
+ state: z.ZodNullable<z.ZodJSONSchema>;
231
+ url: z.ZodString;
232
+ device: z.ZodString;
233
+ xr: z.ZodNullable<z.ZodString>;
234
+ session: z.ZodString;
235
+ version: z.ZodNullable<z.ZodString>;
236
+ }, z.core.$strip>>;
237
+ reviewer: z.ZodNullable<z.ZodString>;
238
+ trust: z.ZodEnum<{
239
+ low: "low";
240
+ medium: "medium";
241
+ high: "high";
242
+ }>;
243
+ answers: z.ZodNullable<z.ZodArray<z.ZodObject<{
244
+ id: z.ZodString;
245
+ ask: z.ZodString;
246
+ answer: z.ZodUnion<readonly [z.ZodNumber, z.ZodArray<z.ZodString>]>;
247
+ scale: z.ZodOptional<z.ZodObject<{
248
+ min: z.ZodNumber;
249
+ max: z.ZodNumber;
250
+ labels: z.ZodOptional<z.ZodTuple<[z.ZodString, z.ZodString], null>>;
251
+ }, z.core.$strip>>;
252
+ options: z.ZodOptional<z.ZodArray<z.ZodString>>;
253
+ }, z.core.$strip>>>;
254
+ }, z.core.$strip>;
255
+ export type Session = z.infer<typeof sessionSchema>;
256
+ /**
257
+ * Someone told about new versions, as `GET /api/v1/projects/:key/reviewers` shows them to the owner. Whether the owner
258
+ * added them or they left their email, they get one invitation with their own link and nothing more until they open
259
+ * it, which makes them `active`; only then does the owner see their `link`. `toldVersion`: the version last emailed, or
260
+ * the one live when they joined while playing. `stopped`: they stopped the emails, for good.
261
+ */
262
+ export declare const reviewerSchema: z.ZodObject<{
263
+ name: z.ZodNullable<z.ZodString>;
264
+ email: z.ZodString;
265
+ trust: z.ZodEnum<{
266
+ low: "low";
267
+ medium: "medium";
268
+ high: "high";
269
+ }>;
270
+ every: z.ZodNumber;
271
+ on: z.ZodEnum<{
272
+ major: "major";
273
+ minor: "minor";
274
+ patch: "patch";
275
+ }>;
276
+ link: z.ZodNullable<z.ZodString>;
277
+ createdAt: z.ZodISODateTime;
278
+ toldAt: z.ZodNullable<z.ZodISODateTime>;
279
+ toldVersion: z.ZodNullable<z.ZodString>;
280
+ active: z.ZodBoolean;
281
+ stopped: z.ZodBoolean;
282
+ }, z.core.$strip>;
283
+ export type Reviewer = z.infer<typeof reviewerSchema>;
284
+ /**
285
+ * `POST /api/v1/projects/:key/reviewers`: adds a reviewer, or changes the one with that email. Fields left out keep
286
+ * their value. An address that stopped the emails is refused with 409, as is `DELETE …/reviewers/:email` on it.
287
+ */
288
+ export declare const addReviewerSchema: z.ZodObject<{
289
+ email: z.ZodEmail;
290
+ name: z.ZodOptional<z.ZodString>;
291
+ every: z.ZodOptional<z.ZodNumber>;
292
+ on: z.ZodOptional<z.ZodEnum<{
293
+ major: "major";
294
+ minor: "minor";
295
+ patch: "patch";
296
+ }>>;
297
+ trust: z.ZodOptional<z.ZodEnum<{
298
+ low: "low";
299
+ medium: "medium";
300
+ high: "high";
301
+ }>>;
302
+ }, z.core.$strip>;
303
+ /** `POST /api/v1/projects/:key/releases`: starts uploading a version, which can be published only once. */
304
+ export declare const releaseSchema: z.ZodObject<{
305
+ version: z.ZodString;
306
+ }, z.core.$strip>;
307
+ /** `POST …/releases/:version/publish`. The images and videos the notes point to go to `…/media/<path>` first. */
308
+ export declare const publishSchema: z.ZodObject<{
309
+ notes: z.ZodOptional<z.ZodString>;
310
+ }, z.core.$strip>;
311
+ /** A path inside a build, as `PUT …/releases/:version/files/<path>` takes it: relative and `/`-separated. */
312
+ export declare const filePathSchema: z.ZodString;
313
+ /** `POST …/items/resolve`. */
314
+ export declare const resolveSchema: z.ZodObject<{
315
+ n: z.ZodArray<z.ZodNumber>;
316
+ resolution: z.ZodDefault<z.ZodEnum<{
317
+ fixed: "fixed";
318
+ duplicate: "duplicate";
319
+ "cannot-reproduce": "cannot-reproduce";
320
+ "wont-fix": "wont-fix";
321
+ }>>;
322
+ of: z.ZodOptional<z.ZodNumber>;
323
+ comment: z.ZodOptional<z.ZodString>;
324
+ }, z.core.$strip>;
325
+ /** `POST …/items/reopen`: drops the items' resolution. */
326
+ export declare const reopenSchema: z.ZodObject<{
327
+ n: z.ZodArray<z.ZodNumber>;
328
+ }, z.core.$strip>;
package/dist/api.js ADDED
@@ -0,0 +1,202 @@
1
+ import { z } from "zod";
2
+ import { CONTROL, LIMITS, stripControl, VERSION_FORMAT } from "./service.js";
3
+ /**
4
+ * The HTTP contract between the browser SDK, the CLI and the Worker at feedback.drawcall.ai. Players write with a
5
+ * project key, which ships in game code and is therefore public: it can only add sessions and items. Everything that
6
+ * reads or changes a project needs a Drawcall sign-in.
7
+ */
8
+ /** A project key: `fbk_pub_` and 16 base62 characters. */
9
+ export const keySchema = z.string().regex(/^fbk_pub_[0-9A-Za-z]{16}$/);
10
+ /** No control characters, see `CONTROL`. Checked before URL parsing, which would silently drop tabs and newlines. */
11
+ const plain = z.string().refine(text => !CONTROL.test(text), "Remove control characters");
12
+ /** The control characters typed text may hold: tabs and line breaks. */
13
+ const LINES = "\t\n";
14
+ /** Typed text, which may also hold `LINES`. */
15
+ const typed = z.string().refine(text => stripControl(text, LINES) === text, "Remove control characters");
16
+ /** Only http(s): other schemes such as `javascript:` would be links nobody should follow. */
17
+ const urlSchema = plain.pipe(z.url({ protocol: /^https?$/, normalize: true }));
18
+ /** Checked like the browser's email field, so an address the intro accepts is never refused later. */
19
+ const contactSchema = z.email({ pattern: z.regexes.html5Email }).max(LIMITS.contact);
20
+ /** Such as `1.4.0`; see `VERSION_FORMAT`. */
21
+ export const versionSchema = z.string().regex(VERSION_FORMAT, `Use up to ${LIMITS.version} letters, digits and . _ + -`);
22
+ /** A reviewer's id, in their link as `?reviewer=<id>`. */
23
+ export const reviewerIdSchema = z.string().regex(/^[0-9A-Za-z]{12}$/);
24
+ /** The smallest version change a reviewer hears about: `major` for 2.0.0 after 1.4.0, `minor` also for 1.5.0, `patch` for any. */
25
+ export const levelSchema = z.enum(["major", "minor", "patch"]);
26
+ /** The owner's confidence in a reviewer's feedback, independent of their email subscription. New reviewers start low. */
27
+ export const trustSchema = z.enum(["low", "medium", "high"]);
28
+ /** At most how often a reviewer is emailed, in days. */
29
+ const everySchema = z.number().int().min(1).max(365);
30
+ const nameSchema = plain.trim().min(1).max(LIMITS.name);
31
+ /**
32
+ * `POST /api/v1/sessions`: a tester started the playtest, on one page load. The client makes the id with
33
+ * `crypto.randomUUID()`, so a retry creates nothing new.
34
+ */
35
+ export const startSessionSchema = z.object({
36
+ id: z.uuid(),
37
+ project: keySchema,
38
+ tester: plain.min(1).max(LIMITS.name).nullable(),
39
+ contact: contactSchema.nullable(),
40
+ version: versionSchema.nullable(),
41
+ url: urlSchema,
42
+ device: plain.max(LIMITS.device),
43
+ /** The reviewer the link or this browser names; their stored name and email stand for the tester's. */
44
+ reviewer: reviewerIdSchema.nullable().default(null),
45
+ /** The game's `minutes` option. */
46
+ minutes: z.number().int().min(1).max(LIMITS.minutes).nullable().default(null),
47
+ });
48
+ const optionSchema = plain.min(1).max(LIMITS.option);
49
+ /**
50
+ * One answer to a closing question: its id and wording as asked, the scale's number or the options picked, and what it
51
+ * was picked from, so it reads right on its own. Older answers have no `scale` or `options`.
52
+ */
53
+ export const answerSchema = z.object({
54
+ id: plain.min(1).max(LIMITS.id),
55
+ ask: plain.min(1).max(LIMITS.ask),
56
+ answer: z.union([z.number().finite(), z.array(optionSchema).min(1).max(LIMITS.options)]),
57
+ scale: z.object({ min: z.number().finite(), max: z.number().finite(), labels: z.tuple([optionSchema, optionSchema]).optional() }).optional(),
58
+ options: z.array(optionSchema).min(2).max(LIMITS.options).optional(),
59
+ });
60
+ /** `POST /api/v1/sessions/:id/join`: a tester leaves their email on the closing screen, to hear about new versions. */
61
+ export const joinSchema = z.object({ email: contactSchema });
62
+ /** `POST /api/v1/sessions/:id/answers`; a later post replaces it. */
63
+ export const answersSchema = z.object({ answers: z.array(answerSchema).min(1).max(LIMITS.questions) });
64
+ /** The `meta` part of `POST /api/v1/items`, sent as JSON next to an `audio` file part. */
65
+ export const submissionSchema = z.object({
66
+ /** Made with `crypto.randomUUID()`, so a retried POST never creates a second item. */
67
+ id: z.uuid(),
68
+ project: keySchema,
69
+ session: z.uuid(),
70
+ tester: plain.min(1).max(LIMITS.name).nullable(),
71
+ contact: contactSchema.nullable(),
72
+ url: urlSchema,
73
+ /** "Quest 3 · Quest Browser 37", "Mac · Chrome 141". */
74
+ device: plain.max(LIMITS.device),
75
+ /** The XR session mode while recording, such as "immersive-vr". */
76
+ xr: plain.max(32).nullable(),
77
+ /** What the game's `pause()` returned. */
78
+ state: z.json().nullable(),
79
+ /**
80
+ * Typed feedback, when the microphone was denied; an item has either `text` or an audio part. Control characters
81
+ * beyond `LINES` are stripped rather than refused, since the tester never sees why and the feedback would be lost.
82
+ */
83
+ text: z.string().max(LIMITS.text).transform(text => stripControl(text, LINES)).pipe(z.string().min(1)).optional(),
84
+ /** Feedback on the whole experience from the closing screen, so never visual. */
85
+ overall: z.boolean().default(false),
86
+ });
87
+ /** The shapes of the responses, as stored: input rules are checked on the way in, so these use plain types. */
88
+ export const projectSchema = z.object({
89
+ key: z.string(),
90
+ name: z.string(),
91
+ /** The owner's account address that "new feedback" emails go to, or null when they are off. */
92
+ notify: z.string().nullable(),
93
+ /** Whether testers are asked for their name and email; leaving an email makes them a reviewer. */
94
+ signup: z.boolean(),
95
+ /** Where `publish` serves the game, and the version live there. */
96
+ site: z.string().nullable(),
97
+ version: z.string().nullable(),
98
+ createdAt: z.iso.datetime(),
99
+ });
100
+ export const createProjectSchema = z.object({ name: plain.trim().min(1).max(100) });
101
+ /** Changes only the fields given. */
102
+ const change = (update) => Object.values(update).some(value => value !== undefined);
103
+ export const updateProjectSchema = z.object({ notify: z.boolean(), signup: z.boolean() }).partial().refine(change, "Give a setting to change");
104
+ /** Why an item was resolved, as in an issue tracker. */
105
+ export const resolutionSchema = z.enum(["fixed", "duplicate", "cannot-reproduce", "wont-fix"]);
106
+ /** Which items `GET …/sessions` lists; `all` also lists sessions without any. */
107
+ export const statusSchema = z.enum(["open", "resolved", "all"]);
108
+ export const itemSchema = z.object({
109
+ n: z.number().int().positive(),
110
+ status: z.enum(["open", "resolved"]),
111
+ resolution: resolutionSchema.nullable(),
112
+ duplicateOf: z.number().int().positive().nullable(),
113
+ /** The owner's word on the resolution. */
114
+ comment: z.string().nullable(),
115
+ tester: z.string().nullable(),
116
+ contact: z.string().nullable(),
117
+ /** The linked reviewer's current trust; low without a reviewer link, even when the typed email matches. */
118
+ trust: trustSchema,
119
+ at: z.iso.datetime(),
120
+ /** Null when transcription failed. */
121
+ transcript: z.string().nullable(),
122
+ /** Whether it is about something visible; never for overall feedback. */
123
+ visual: z.boolean(),
124
+ overall: z.boolean(),
125
+ /** Whether the player showed something, served at `…/items/:n/screenshot`. */
126
+ screenshot: z.boolean(),
127
+ /** The recording's MIME type, served at `…/items/:n/audio`, or null for typed feedback. */
128
+ audio: z.string().nullable(),
129
+ state: z.json().nullable(),
130
+ url: z.string(),
131
+ device: z.string(),
132
+ xr: z.string().nullable(),
133
+ session: z.string(),
134
+ version: z.string().nullable(),
135
+ });
136
+ /**
137
+ * One tester's playtest on one page load. `seenAt` is the last sign of them, so `seenAt - startedAt` is about how long
138
+ * they played; `finishedAt` is when the game called `finish()`.
139
+ */
140
+ export const sessionSchema = z.object({
141
+ id: z.string(),
142
+ tester: z.string().nullable(),
143
+ contact: z.string().nullable(),
144
+ version: z.string().nullable(),
145
+ url: z.string(),
146
+ device: z.string(),
147
+ startedAt: z.iso.datetime(),
148
+ seenAt: z.iso.datetime(),
149
+ finishedAt: z.iso.datetime().nullable(),
150
+ /** Its items of the listed status, oldest first. */
151
+ items: z.array(itemSchema),
152
+ reviewer: z.string().nullable(),
153
+ /** The linked reviewer's current trust, also for sessions without items; low without a reviewer link. */
154
+ trust: trustSchema,
155
+ answers: z.array(answerSchema).nullable(),
156
+ });
157
+ /**
158
+ * Someone told about new versions, as `GET /api/v1/projects/:key/reviewers` shows them to the owner. Whether the owner
159
+ * added them or they left their email, they get one invitation with their own link and nothing more until they open
160
+ * it, which makes them `active`; only then does the owner see their `link`. `toldVersion`: the version last emailed, or
161
+ * the one live when they joined while playing. `stopped`: they stopped the emails, for good.
162
+ */
163
+ export const reviewerSchema = z.object({
164
+ name: z.string().nullable(),
165
+ email: z.string(),
166
+ trust: trustSchema,
167
+ every: z.number(),
168
+ on: levelSchema,
169
+ link: z.string().nullable(),
170
+ createdAt: z.iso.datetime(),
171
+ toldAt: z.iso.datetime().nullable(),
172
+ toldVersion: z.string().nullable(),
173
+ active: z.boolean(),
174
+ stopped: z.boolean(),
175
+ });
176
+ /**
177
+ * `POST /api/v1/projects/:key/reviewers`: adds a reviewer, or changes the one with that email. Fields left out keep
178
+ * their value. An address that stopped the emails is refused with 409, as is `DELETE …/reviewers/:email` on it.
179
+ */
180
+ export const addReviewerSchema = z.object({
181
+ email: contactSchema, name: nameSchema.optional(), every: everySchema.optional(), on: levelSchema.optional(), trust: trustSchema.optional(),
182
+ });
183
+ /** `POST /api/v1/projects/:key/releases`: starts uploading a version, which can be published only once. */
184
+ export const releaseSchema = z.object({ version: versionSchema });
185
+ /** `POST …/releases/:version/publish`. The images and videos the notes point to go to `…/media/<path>` first. */
186
+ export const publishSchema = z.object({ notes: typed.trim().min(1).max(LIMITS.notes).optional() });
187
+ /** A path inside a build, as `PUT …/releases/:version/files/<path>` takes it: relative and `/`-separated. */
188
+ export const filePathSchema = plain.max(512).refine(path => path.split("/").every(part => part !== "" && part !== "." && part !== ".." && !part.includes("\\")), "Not a relative file path");
189
+ const numbersSchema = z.array(z.number().int().positive()).min(1).max(100);
190
+ /** `POST …/items/resolve`. */
191
+ export const resolveSchema = z
192
+ .object({
193
+ n: numbersSchema,
194
+ resolution: resolutionSchema.default("fixed"),
195
+ /** The item a duplicate repeats. */
196
+ of: z.number().int().positive().optional(),
197
+ comment: plain.trim().min(1).max(500).optional(),
198
+ })
199
+ .refine(body => (body.resolution === "duplicate") === (body.of !== undefined), "A duplicate needs `of`, the item it repeats, and only a duplicate takes one")
200
+ .refine(body => body.of === undefined || !body.n.includes(body.of), "An item can't be a duplicate of itself");
201
+ /** `POST …/items/reopen`: drops the items' resolution. */
202
+ export const reopenSchema = z.object({ n: numbersSchema });
@@ -0,0 +1,13 @@
1
+ import type { Target } from "../index.ts";
2
+ /** "Quest 3 · Quest Browser 37", "Mac · Chrome 141": for reproducing, so only what the user agent states. */
3
+ export declare function describeDevice(agent: string): string;
4
+ /**
5
+ * Where feedback runs, as the `devices` presets name it. Quest Browser hides the controllers from 2D pages, so there the
6
+ * walkthrough only shows them.
7
+ */
8
+ export type Device = Extract<Target, string>;
9
+ export declare function detect(): Device;
10
+ /** Whether this device is one the playtest is for. A game's own test that throws is a bug, so it rejects. */
11
+ export declare function targeted(targets: Target[], device: Device): Promise<boolean>;
12
+ /** The targets as a phrase: "Meta Quest", "a computer or a phone", "Meta Quest, Apple Vision Pro or a phone". */
13
+ export declare function phrase(targets: Target[]): string;
@@ -0,0 +1,64 @@
1
+ import { LIMITS } from "../service.js";
2
+ /** "Quest 3 · Quest Browser 37", "Mac · Chrome 141": for reproducing, so only what the user agent states. */
3
+ export function describeDevice(agent) {
4
+ return [platform(agent), browser(agent)].filter(part => part !== null).join(" · ").slice(0, LIMITS.device);
5
+ }
6
+ /** How the presets read in a sentence such as "This test is for Meta Quest", each kept on one line. */
7
+ const PRESETS = { desktop: "a\u00a0computer", phone: "a\u00a0phone", quest: "Meta\u00a0Quest" };
8
+ export function detect() {
9
+ if (isQuest(navigator.userAgent))
10
+ return "quest";
11
+ return matchMedia("(pointer: coarse)").matches ? "phone" : "desktop";
12
+ }
13
+ /** Whether this device is one the playtest is for. A game's own test that throws is a bug, so it rejects. */
14
+ export async function targeted(targets, device) {
15
+ for (const target of targets) {
16
+ if (typeof target === "string" ? target === device : await target.matches())
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ /** The targets as a phrase: "Meta Quest", "a computer or a phone", "Meta Quest, Apple Vision Pro or a phone". */
22
+ export function phrase(targets) {
23
+ const names = targets.map(target => (typeof target === "string" ? PRESETS[target] : target.name));
24
+ return names.length < 2 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} or ${names.at(-1)}`;
25
+ }
26
+ function isQuest(agent) {
27
+ return agent.includes("OculusBrowser");
28
+ }
29
+ function platform(agent) {
30
+ if (isQuest(agent))
31
+ return agent.match(/Quest (?:\d+S?|Pro)/)?.[0] ?? "Quest";
32
+ if (agent.includes("iPhone"))
33
+ return "iPhone";
34
+ if (agent.includes("iPad"))
35
+ return "iPad";
36
+ if (agent.includes("Android"))
37
+ return "Android";
38
+ if (agent.includes("Mac OS X"))
39
+ return "Mac";
40
+ if (agent.includes("Windows"))
41
+ return "Windows";
42
+ if (agent.includes("CrOS"))
43
+ return "ChromeOS";
44
+ if (agent.includes("Linux"))
45
+ return "Linux";
46
+ return null;
47
+ }
48
+ const BROWSERS = [
49
+ ["Quest Browser", /OculusBrowser\/(\d+)/],
50
+ ["Edge", /Edg(?:A|iOS)?\/(\d+)/],
51
+ ["Opera", /OPR\/(\d+)/],
52
+ ["Samsung Internet", /SamsungBrowser\/(\d+)/],
53
+ ["Firefox", /(?:Firefox|FxiOS)\/(\d+)/],
54
+ ["Chrome", /(?:Chrome|CriOS)\/(\d+)/],
55
+ ["Safari", /Version\/(\d+(?:\.\d+)?).*Safari/],
56
+ ];
57
+ function browser(agent) {
58
+ for (const [name, pattern] of BROWSERS) {
59
+ const version = agent.match(pattern)?.[1];
60
+ if (version)
61
+ return `${name} ${version}`;
62
+ }
63
+ return null;
64
+ }
@@ -0,0 +1,8 @@
1
+ import type { Context } from "./run.ts";
2
+ import type { Session } from "./session.ts";
3
+ /**
4
+ * The close of the playtest, once the game called `finish()`: a cover asking the game's questions and for an optional
5
+ * last word on the whole experience, written or spoken. Finish stores the answers with the session and sends the last
6
+ * word as an overall item; then thanks, by name when known, and stays. The cover is a page, so an XR session ends first.
7
+ */
8
+ export declare function ending(ctx: Context, session: Session): Promise<void>;