@fabi-play/protocol 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -18,6 +18,36 @@ if ('errors' in result) console.error(result.errors)
18
18
  The manifest's JSON Schema: `@fabi-play/protocol/fabi-play.schema.json` (also served next to the SDK on
19
19
  https://cdn.fabiplay.app). ESM only, types included, no dependencies. Docs: https://docs.fabiplay.app.
20
20
 
21
+ ## The game intro
22
+
23
+ A manifest's optional `intro` (`GameIntro`: 1 to 4 steps, each a sentence over an image or a move) is what Fabi
24
+ says on the TV before calibration; see "Fabi explains your game" in the
25
+ [`@fabi-play/sdk` README](https://www.npmjs.com/package/@fabi-play/sdk). `parseManifest` reports what is wrong with
26
+ it; `parseIntro` gives the intro or null (the TV skips a bad one, unknown fields included), `introText` the
27
+ sentence for a locale and age.
28
+
29
+ - **Sentences**: up to 90 characters per language, counted as Unicode code points of the NFC form (an emoji is
30
+ one); no white space at either end; no control characters, lone surrogates or bidirectional controls
31
+ (U+202A–U+202E, U+2066–U+2069). The JSON Schema says the same, except lone surrogates and the NFC count, which
32
+ portable JSON Schema regexes can't express: `parseManifest` is the reference.
33
+ - **Paths** (`entry`, a step's `image`): relative to the game's address, no scheme, no leading `/`, no empty, `.` or
34
+ `..` segments, up to 200 characters; segments of ASCII letters, digits, `_` and `-` (dots inside, not first);
35
+ extensions in lowercase: `.html` for `entry`, `.jpg` or `.png` for images (`intro/track.jpg`, not
36
+ `intro/Track.PNG`). No WebP: old TV browsers (WebKit-based Tizen 2.x, webOS 1–2) may not decode it. JPG (quality
37
+ about 80) for screenshots, PNG only for flat art.
38
+ - **Voice clips**: a sentence's clip is at `voiceUrl(base, voiceKeySync(text, locale))` (`voiceKey` is the same as
39
+ a promise). The key is the first 32 lowercase hex characters of SHA-256 of the UTF-8 bytes of
40
+ `` `${FABI_VOICE}\n${locale}\n${t}` `` (`FABI_VOICE` is `fabi-v1`), where `t` is the text in Unicode NFC with
41
+ JavaScript's `trim()` applied; a valid sentence has no white space around it, so for a generator in any language
42
+ `t` is just the NFC text. So `voiceKeySync('Salta!', 'it')` is `cbd16ba39816bdc5afd34a3e84082331` and its clip
43
+ `https://cdn.fabiplay.app/voice/fabi-v1/cbd16ba39816bdc5afd34a3e84082331.mp3`. The SHA-256 is plain JavaScript,
44
+ synchronous: no `crypto.subtle`, so it works on TV pages served over http, old TV browsers and Node 18 (the
45
+ package's `engines`).
46
+ - **The phone's buttons**: during the intro the phone sends `{ type: 'intro', action }`, with action `next`,
47
+ `repeat`, `skip`, or `replay` (from the pause menu: the explanation again); the screen tells it what Fabi is
48
+ saying with `{ type: 'intro', step, steps, text, voice }`. Its `phase` message carries `intro: true` when the
49
+ game has an intro: only then does the phone offer `replay`.
50
+
21
51
  ## Poses on the wire
22
52
 
23
53
  The phone streams `{ type: 'pose', t, bodies }` about 30 times a second over an unordered, no-retransmit data
package/dist/index.d.ts CHANGED
@@ -140,6 +140,14 @@ export type ControllerMessage =
140
140
  type: "command";
141
141
  command: "pause" | "resume" | "quit";
142
142
  }
143
+ /**
144
+ * The intro's buttons (see GameIntro): next (the next step), repeat (the step again), skip (straight to
145
+ * calibration); replay: watch the game's explanation again, from the pause menu.
146
+ */
147
+ | {
148
+ type: "intro";
149
+ action: "next" | "repeat" | "skip" | "replay";
150
+ }
143
151
  /**
144
152
  * Mirror mode, the phone's side: 'on' while it sends its camera video to the screen, 'off' when the parent switch
145
153
  * is off, 'blocked' when the switch is on but video may not be sent (reason: not the home network, or the game).
@@ -160,11 +168,16 @@ export type ScreenMessage =
160
168
  screen: string;
161
169
  binaryPose?: number[];
162
170
  }
163
- /** What the phone should show: place the phone, step into the circle, playing, paused. */
171
+ /**
172
+ * What the phone should show: place the phone, step into the circle, playing, paused. intro: true when the current
173
+ * game has an intro (GameIntro), so the phone can offer the explanation again (the intro message, action 'replay');
174
+ * absent otherwise, and on screens before the intro.
175
+ */
164
176
  | {
165
177
  type: "phase";
166
178
  phase: Phase;
167
179
  game?: string;
180
+ intro?: true;
168
181
  }
169
182
  /** The games for these players, so the phone can pick one too. */
170
183
  | {
@@ -180,6 +193,18 @@ export type ScreenMessage =
180
193
  allowed: boolean;
181
194
  reason?: VideoBlock;
182
195
  }
196
+ /**
197
+ * The intro: what Fabi is saying on the TV now, so the phone shows it too (and its next, repeat and skip buttons).
198
+ * step: 0-based; steps: how many there are, the platform's final "ready" step included; text: the sentence, in the
199
+ * screen's language and for the children's age; voice: whether a voice clip is playing (false: only the bubble).
200
+ */
201
+ | {
202
+ type: "intro";
203
+ step: number;
204
+ steps: number;
205
+ text: string;
206
+ voice: boolean;
207
+ }
183
208
  /**
184
209
  * The screen's size. ask true: the screen is asking «Quanto è grande questa TV?» now (detected: the band read from
185
210
  * the TV's model, with model, its name), and the phone shows the same choice. ask false: settled; size is the
@@ -261,7 +286,8 @@ export declare function sizeForInches(inches: number, kind: "tv" | "tablet"): Sc
261
286
  export declare const SCREEN_SIZE_KEY = "fabi-play.screen-size";
262
287
  /** Where the app keeps «Pausa se un bambino è troppo vicino» ('on' / 'off'; on when missing). */
263
288
  export declare const TOO_CLOSE_KEY = "fabi-play.too-close-pause";
264
- export type Phase = "setup" | "calibrate" | "play" | "pause" | "break" | "end";
289
+ /** intro: Fabi explains the game before calibration (only for games whose manifest has one, see GameIntro). */
290
+ export type Phase = "setup" | "intro" | "calibrate" | "play" | "pause" | "break" | "end";
265
291
  export declare const PHASES: Phase[];
266
292
  export interface CatalogueGame {
267
293
  id: string;
@@ -500,7 +526,11 @@ export type Movement = "hands" | "jump" | "squat" | "clap" | "freeze" | "lanes"
500
526
  * "title": { "it": "Acchiappastelle", "en": "Star catcher" },
501
527
  * "description": { "it": "Prendi le stelle con le mani.", "en": "Catch the stars with your hands." },
502
528
  * "ageRanges": ["3-5", "6-8"], "movements": ["hands"], "players": { "min": 1, "max": 2 },
503
- * "minutes": 3, "entry": "index.html", "sdk": 1
529
+ * "minutes": 3, "entry": "index.html", "sdk": 1,
530
+ * "intro": { "steps": [
531
+ * { "say": { "it": "Prendi le stelle!", "en": "Catch the stars!" }, "image": "intro/stars.jpg" },
532
+ * { "say": { "it": "Alza le braccia!", "en": "Arms up!" }, "move": "arms-up", "practice": true }
533
+ * ] }
504
534
  * }
505
535
  */
506
536
  export interface GameManifest {
@@ -525,12 +555,17 @@ export interface GameManifest {
525
555
  };
526
556
  /** Minutes of a typical game, 1 to 20. */
527
557
  minutes: number;
528
- /** Page that starts the game, an .html file relative to the game's address (same site, no ".."). */
558
+ /**
559
+ * Page that starts the game: a path relative to the game's address (no scheme, no leading /, no "." or ".."
560
+ * segments, up to 200 characters), of letters, digits, _ and -, ending in lowercase .html: index.html, play/index.html.
561
+ */
529
562
  entry: string;
530
563
  /** Protocol version of the SDK it was built with: 1 (PROTOCOL_VERSION). */
531
564
  sdk: number;
532
565
  /** Optional: the backdrops it can draw over besides its own; ['video'] opts in to mirror mode. */
533
566
  backdrop?: GameBackdrop[];
567
+ /** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
568
+ intro?: GameIntro;
534
569
  }
535
570
  /** The backdrops a game may declare in its manifest (its own background is always supported). */
536
571
  export type GameBackdrop = Exclude<Backdrop, "none">;
@@ -543,6 +578,64 @@ export declare function parseManifest(data: unknown): {
543
578
  } | {
544
579
  errors: string[];
545
580
  };
581
+ /** The moves an intro step can show; the platform draws them with each child's avatar. Same names the SDK detects. */
582
+ export type IntroMove = "jump" | "squat" | "clap" | "lane-left" | "lane-right" | "arms-up" | "arms-wide" | "arms-v" | "star";
583
+ export declare const INTRO_MOVES: IntroMove[];
584
+ /** One sentence in every language, or one per age range (each with every language). */
585
+ export type IntroText = Record<Locale, string> | Partial<Record<AgeRange, Record<Locale, string>>>;
586
+ /** One step of the intro: Fabi says one sentence, over an image of the game or a move shown with the avatars. */
587
+ export interface IntroStep {
588
+ say: IntroText;
589
+ /**
590
+ * An image of the game, 16:9: a path relative to the game's address with the same rules as entry (no scheme, no
591
+ * leading /, no "." or ".." segments), ending in lowercase .jpg or .png (no WebP: old TV browsers may not decode it): intro/track.jpg.
592
+ */
593
+ image?: string;
594
+ /** Or a move, shown with the avatars. Exactly one of image and move. */
595
+ move?: IntroMove;
596
+ /** Only with move: the children try it and each gets a tick; the step moves on when all did, or after 8 s. Default false: Fabi only shows it. */
597
+ practice?: boolean;
598
+ }
599
+ /**
600
+ * Fabi explains the game on the TV, before calibration, in 1 to 4 steps (the platform adds its own final "ready"
601
+ * step). Only data: Fabi Play draws it, and speaks each sentence with a voice clip generated at publication from the
602
+ * text (voiceKey); a text without its clip yet is shown in the bubble only.
603
+ */
604
+ export interface GameIntro {
605
+ steps: IntroStep[];
606
+ }
607
+ export declare const INTRO_MAX_STEPS = 4;
608
+ /**
609
+ * The longest sentence of a step, per language, in Unicode code points of its NFC form (an emoji counts as one, as
610
+ * in the JSON Schema's maxLength).
611
+ */
612
+ export declare const INTRO_MAX_CHARS = 90;
613
+ /**
614
+ * The intro, or null if absent or invalid, unknown fields included (the TV then skips it: a bad intro never blocks
615
+ * a game). A copy: practice: false is dropped (it is the default).
616
+ */
617
+ export declare function parseIntro(data: unknown): GameIntro | null;
618
+ /**
619
+ * The sentence for a locale and age: the age-specific one if present, else (for age-keyed text) the closest range
620
+ * present (the younger of two as close: simpler words), else the plain one.
621
+ */
622
+ export declare function introText(say: IntroText, locale: Locale, ageRange: AgeRange): string;
623
+ /** The voice Fabi speaks with; a new voice becomes fabi-v2 and every clip is generated again. */
624
+ export declare const FABI_VOICE = "fabi-v1";
625
+ /**
626
+ * Key of a voice clip: the first 32 lowercase hex characters of SHA-256 of the UTF-8 bytes of
627
+ * `${voice}\n${locale}\n${t}` (\n: one U+000A), where t is the text in Unicode NFC, then with JavaScript's
628
+ * String.prototype.trim applied (it drops white space and line terminators at both ends: Unicode Zs, tab, vertical
629
+ * tab, form feed, U+FEFF, LF, CR, U+2028, U+2029). A valid intro sentence has none of those around it, so for a
630
+ * generator in another language t is simply the NFC text. The same text always finds the same clip; a changed text
631
+ * (or voice) finds none until it is generated, and the TV shows only the bubble meanwhile. Plain JavaScript,
632
+ * synchronous: no crypto.subtle needed (TV pages over http, old browsers, Node 18).
633
+ */
634
+ export declare function voiceKeySync(text: string, locale: Locale, voice?: string): string;
635
+ /** voiceKeySync, as a promise (the same key). */
636
+ export declare const voiceKey: (text: string, locale: Locale, voice?: string) => Promise<string>;
637
+ /** URL of a clip: `${base}/voice/${voice}/${key}.mp3` (base without trailing slash, e.g. https://cdn.fabiplay.app; one is dropped). */
638
+ export declare const voiceUrl: (base: string, key: string, voice?: string) => string;
546
639
  /** The games for a range and a number of players, in catalogue order. */
547
640
  export declare const gamesFor: <T extends Pick<GameManifest, "ageRanges" | "players">>(games: T[], range: AgeRange, players?: number) => T[];
548
641
  export declare const isGameId: (v: string) => boolean;
package/dist/index.js CHANGED
@@ -1,3 +1,138 @@
1
+ // src/sha256.ts
2
+ function utf8(s) {
3
+ const out = [];
4
+ for (let i = 0; i < s.length; i++) {
5
+ let c = s.charCodeAt(i);
6
+ if (c >= 55296 && c <= 56319 && i + 1 < s.length) {
7
+ const d = s.charCodeAt(i + 1);
8
+ if (d >= 56320 && d <= 57343) {
9
+ c = 65536 + (c - 55296 << 10) + (d - 56320);
10
+ i++;
11
+ }
12
+ }
13
+ if (c >= 55296 && c <= 57343) c = 65533;
14
+ if (c < 128) out.push(c);
15
+ else if (c < 2048) out.push(192 | c >> 6, 128 | c & 63);
16
+ else if (c < 65536) out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63);
17
+ else out.push(240 | c >> 18, 128 | c >> 12 & 63, 128 | c >> 6 & 63, 128 | c & 63);
18
+ }
19
+ return Uint8Array.from(out);
20
+ }
21
+ var K = Uint32Array.from([
22
+ 1116352408,
23
+ 1899447441,
24
+ 3049323471,
25
+ 3921009573,
26
+ 961987163,
27
+ 1508970993,
28
+ 2453635748,
29
+ 2870763221,
30
+ 3624381080,
31
+ 310598401,
32
+ 607225278,
33
+ 1426881987,
34
+ 1925078388,
35
+ 2162078206,
36
+ 2614888103,
37
+ 3248222580,
38
+ 3835390401,
39
+ 4022224774,
40
+ 264347078,
41
+ 604807628,
42
+ 770255983,
43
+ 1249150122,
44
+ 1555081692,
45
+ 1996064986,
46
+ 2554220882,
47
+ 2821834349,
48
+ 2952996808,
49
+ 3210313671,
50
+ 3336571891,
51
+ 3584528711,
52
+ 113926993,
53
+ 338241895,
54
+ 666307205,
55
+ 773529912,
56
+ 1294757372,
57
+ 1396182291,
58
+ 1695183700,
59
+ 1986661051,
60
+ 2177026350,
61
+ 2456956037,
62
+ 2730485921,
63
+ 2820302411,
64
+ 3259730800,
65
+ 3345764771,
66
+ 3516065817,
67
+ 3600352804,
68
+ 4094571909,
69
+ 275423344,
70
+ 430227734,
71
+ 506948616,
72
+ 659060556,
73
+ 883997877,
74
+ 958139571,
75
+ 1322822218,
76
+ 1537002063,
77
+ 1747873779,
78
+ 1955562222,
79
+ 2024104815,
80
+ 2227730452,
81
+ 2361852424,
82
+ 2428436474,
83
+ 2756734187,
84
+ 3204031479,
85
+ 3329325298
86
+ ]);
87
+ var rotr = (x, n) => x >>> n | x << 32 - n;
88
+ function sha256(bytes) {
89
+ const blocks = Math.ceil((bytes.length + 9) / 64);
90
+ const data = new Uint8Array(blocks * 64);
91
+ data.set(bytes);
92
+ data[bytes.length] = 128;
93
+ const view = new DataView(data.buffer);
94
+ const bits = bytes.length * 8;
95
+ view.setUint32(data.length - 8, Math.floor(bits / 4294967296));
96
+ view.setUint32(data.length - 4, bits >>> 0);
97
+ const h = Uint32Array.from([1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225]);
98
+ const w = new Uint32Array(64);
99
+ for (let off = 0; off < data.length; off += 64) {
100
+ for (let t = 0; t < 16; t++) w[t] = view.getUint32(off + t * 4);
101
+ for (let t = 16; t < 64; t++) {
102
+ const a2 = w[t - 15];
103
+ const b2 = w[t - 2];
104
+ const s0 = rotr(a2, 7) ^ rotr(a2, 18) ^ a2 >>> 3;
105
+ const s1 = rotr(b2, 17) ^ rotr(b2, 19) ^ b2 >>> 10;
106
+ w[t] = w[t - 16] + s0 + w[t - 7] + s1 | 0;
107
+ }
108
+ let [a, b, c, d, e, f, g, hh] = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]];
109
+ for (let t = 0; t < 64; t++) {
110
+ const t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[t] + w[t] | 0;
111
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) | 0;
112
+ hh = g;
113
+ g = f;
114
+ f = e;
115
+ e = d + t1 | 0;
116
+ d = c;
117
+ c = b;
118
+ b = a;
119
+ a = t1 + t2 | 0;
120
+ }
121
+ h[0] = h[0] + a;
122
+ h[1] = h[1] + b;
123
+ h[2] = h[2] + c;
124
+ h[3] = h[3] + d;
125
+ h[4] = h[4] + e;
126
+ h[5] = h[5] + f;
127
+ h[6] = h[6] + g;
128
+ h[7] = h[7] + hh;
129
+ }
130
+ const out = new Uint8Array(32);
131
+ const outView = new DataView(out.buffer);
132
+ for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i]);
133
+ return out;
134
+ }
135
+
1
136
  // src/index.ts
2
137
  var PROTOCOL_VERSION = 1;
3
138
  var LANDMARKS = 33;
@@ -66,7 +201,7 @@ function sizeForInches(inches, kind) {
66
201
  }
67
202
  var SCREEN_SIZE_KEY = "fabi-play.screen-size";
68
203
  var TOO_CLOSE_KEY = "fabi-play.too-close-pause";
69
- var PHASES = ["setup", "calibrate", "play", "pause", "break", "end"];
204
+ var PHASES = ["setup", "intro", "calibrate", "play", "pause", "break", "end"];
70
205
  var NS = "fabi-play";
71
206
  var wire = (m) => ({ ns: NS, ...m });
72
207
  var BREAK_REASONS = ["level", "gameover"];
@@ -109,15 +244,84 @@ function parseManifest(data) {
109
244
  const pl = isObject(m.players) ? m.players : {};
110
245
  if (!(isNumber(pl.min) && isNumber(pl.max) && pl.min >= 1 && pl.max <= 4 && pl.min <= pl.max)) errors.push("players: min and max between 1 and 4");
111
246
  if (!(isNumber(m.minutes) && m.minutes >= 1 && m.minutes <= 20)) errors.push("minutes: from 1 to 20");
112
- if (!(typeof m.entry === "string" && /^[\w./-]+\.html$/.test(m.entry) && !m.entry.includes(".."))) errors.push("entry: an .html page of the game, relative to its address");
247
+ if (!isGamePath(m.entry, "html")) errors.push("entry: an .html page of the game, a path relative to its address (no scheme, no leading /)");
113
248
  if (m.sdk !== PROTOCOL_VERSION) errors.push(`sdk: this version of Fabi Play uses protocol ${PROTOCOL_VERSION}`);
114
249
  if (m.backdrop !== void 0 && !(Array.isArray(m.backdrop) && m.backdrop.every((b) => GAME_BACKDROPS.includes(b)) && new Set(m.backdrop).size === m.backdrop.length))
115
250
  errors.push(`backdrop: optional, from ${GAME_BACKDROPS.join(", ")}, without repeats`);
251
+ if (m.intro !== void 0) errors.push(...introErrors(m.intro));
116
252
  return errors.length ? { errors } : { manifest: m };
117
253
  }
254
+ var INTRO_MOVES = ["jump", "squat", "clap", "lane-left", "lane-right", "arms-up", "arms-wide", "arms-v", "star"];
255
+ var INTRO_MAX_STEPS = 4;
256
+ var INTRO_MAX_CHARS = 90;
257
+ var LOCALES = ["it", "en"];
258
+ var STEP_KEYS = ["say", "image", "move", "practice"];
259
+ var UNSPEAKABLE = /[\p{Cc}\p{Cs}\u202A-\u202E\u2066-\u2069]/u;
260
+ var sentence = (v) => typeof v === "string" && v.length > 0 && v === v.trim() && [...v.normalize("NFC")].length <= INTRO_MAX_CHARS && !UNSPEAKABLE.test(v);
261
+ var sentences = (v) => isObject(v) && Object.keys(v).every((k) => LOCALES.includes(k)) && LOCALES.every((l) => sentence(v[l]));
262
+ var isPlainText = (say) => LOCALES.some((l) => l in say);
263
+ function sayErrors(say, path) {
264
+ const plain = `Italian and English, up to ${INTRO_MAX_CHARS} characters, no spaces around, no control characters`;
265
+ if (!isObject(say)) return [`${path}: ${plain}`];
266
+ const keys = Object.keys(say);
267
+ if (keys.some((k) => LOCALES.includes(k))) return sentences(say) ? [] : [`${path}: ${plain}`];
268
+ if (keys.length === 0 || !keys.every(isAgeRange)) return [`${path}: ${plain}, or that for one or more of ${AGE_RANGES.join(", ")}`];
269
+ return keys.flatMap((k) => sentences(say[k]) ? [] : [`${path}.${k}: ${plain}`]);
270
+ }
271
+ function introErrors(intro) {
272
+ const steps = isObject(intro) ? intro.steps : void 0;
273
+ if (!(Array.isArray(steps) && steps.length >= 1 && steps.length <= INTRO_MAX_STEPS)) return [`intro: 1 to ${INTRO_MAX_STEPS} steps`];
274
+ const unknown = Object.keys(intro).filter((k) => k !== "steps").map((k) => `intro.${k}: not a field of intro (only steps)`);
275
+ return unknown.concat(steps.flatMap((step, i) => {
276
+ const path = `intro.steps[${i}]`;
277
+ if (!isObject(step)) return [`${path}: an object with say, and an image or a move`];
278
+ const errors = Object.keys(step).filter((k) => !STEP_KEYS.includes(k)).map((k) => `${path}.${k}: not a field of a step (${STEP_KEYS.join(", ")})`);
279
+ errors.push(...sayErrors(step.say, `${path}.say`));
280
+ const { image, move, practice } = step;
281
+ if (image !== void 0 && move !== void 0) errors.push(`${path}: an image or a move, not both`);
282
+ else if (image === void 0 && move === void 0) errors.push(`${path}: an image or a move`);
283
+ if (image !== void 0 && !isGamePath(image, "jpg|png"))
284
+ errors.push(`${path}.image: a .jpg or .png of the game, a path relative to its address (no scheme, no leading /)`);
285
+ if (move !== void 0 && !INTRO_MOVES.includes(move)) errors.push(`${path}.move: one of ${INTRO_MOVES.join(", ")}`);
286
+ if (practice !== void 0 && typeof practice !== "boolean") errors.push(`${path}.practice: true or false`);
287
+ else if (practice === true && move === void 0) errors.push(`${path}.practice: only with a move`);
288
+ return errors;
289
+ }));
290
+ }
291
+ function parseIntro(data) {
292
+ if (data === void 0 || introErrors(data).length) return null;
293
+ const steps = data.steps.map((s) => {
294
+ const say = s.say;
295
+ const copy = Object.fromEntries(Object.entries(say).map(([k, v]) => [k, typeof v === "string" ? v : { it: v.it, en: v.en }]));
296
+ return {
297
+ say: copy,
298
+ ...typeof s.image === "string" ? { image: s.image } : { move: s.move },
299
+ ...s.practice === true ? { practice: true } : {}
300
+ };
301
+ });
302
+ return { steps };
303
+ }
304
+ function introText(say, locale, ageRange) {
305
+ if (isPlainText(say)) return say[locale] ?? "";
306
+ const at = AGE_RANGES.indexOf(ageRange);
307
+ const present = AGE_RANGES.filter((r) => say[r]);
308
+ const closest = present.reduce((best, r) => best === void 0 || Math.abs(AGE_RANGES.indexOf(r) - at) < Math.abs(AGE_RANGES.indexOf(best) - at) ? r : best, void 0);
309
+ return closest ? say[closest]?.[locale] ?? "" : "";
310
+ }
311
+ var FABI_VOICE = "fabi-v1";
312
+ function voiceKeySync(text, locale, voice = FABI_VOICE) {
313
+ const hash = sha256(utf8(`${voice}
314
+ ${locale}
315
+ ${text.normalize("NFC").trim()}`));
316
+ return Array.from(hash.subarray(0, 16), (b) => b.toString(16).padStart(2, "0")).join("");
317
+ }
318
+ var voiceKey = (text, locale, voice = FABI_VOICE) => Promise.resolve(voiceKeySync(text, locale, voice));
319
+ var voiceUrl = (base, key, voice = FABI_VOICE) => `${base.replace(/\/+$/, "")}/voice/${voice}/${key}.mp3`;
118
320
  var gamesFor = (games, range, players = 1) => games.filter((g) => g.ageRanges.includes(range) && players >= g.players.min && players <= g.players.max);
119
321
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
120
322
  var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
323
+ var SEGMENT = "[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*";
324
+ var isGamePath = (v, ext) => typeof v === "string" && v.length <= 200 && new RegExp(`^(?:${SEGMENT}/)*${SEGMENT}\\.(?:${ext})$`).test(v);
121
325
  var isGameId = (v) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v) && v.length <= 60;
122
326
  var isBody = (v) => Array.isArray(v) && v.length === LANDMARKS * 3 && v.every(isNumber);
123
327
  function parseGameMessage(data) {
@@ -142,6 +346,8 @@ function parseGameMessage(data) {
142
346
  return null;
143
347
  }
144
348
  }
349
+ var COMMANDS = ["pause", "resume", "quit"];
350
+ var INTRO_ACTIONS = ["next", "repeat", "skip", "replay"];
145
351
  function parseControllerMessage(data) {
146
352
  if (!isObject(data)) return null;
147
353
  switch (data.type) {
@@ -174,7 +380,9 @@ function parseControllerMessage(data) {
174
380
  case "pick":
175
381
  return typeof data.game === "string" && isGameId(data.game) ? { type: "pick", game: data.game } : null;
176
382
  case "command":
177
- return data.command === "pause" || data.command === "resume" || data.command === "quit" ? { type: "command", command: data.command } : null;
383
+ return COMMANDS.includes(data.command) ? { type: "command", command: data.command } : null;
384
+ case "intro":
385
+ return INTRO_ACTIONS.includes(data.action) ? { type: "intro", action: data.action } : null;
178
386
  case "video":
179
387
  return VIDEO_STATES.includes(data.state) ? { type: "video", state: data.state, ...VIDEO_BLOCKS.includes(data.reason) ? { reason: data.reason } : {} } : null;
180
388
  default:
@@ -191,7 +399,12 @@ function parseScreenMessage(data) {
191
399
  return { type: "welcome", v: data.v, screen: data.screen, ...binaryPose.length ? { binaryPose } : {} };
192
400
  }
193
401
  case "phase":
194
- return PHASES.includes(data.phase) ? { type: "phase", phase: data.phase, ...typeof data.game === "string" ? { game: data.game } : {} } : null;
402
+ return PHASES.includes(data.phase) ? {
403
+ type: "phase",
404
+ phase: data.phase,
405
+ ...typeof data.game === "string" ? { game: data.game } : {},
406
+ ...data.intro === true ? { intro: true } : {}
407
+ } : null;
195
408
  case "catalogue": {
196
409
  if (!Array.isArray(data.games) || data.games.length > 50) return null;
197
410
  const games = data.games.flatMap(
@@ -203,6 +416,12 @@ function parseScreenMessage(data) {
203
416
  return isNumber(data.ms) ? { type: "vibrate", ms: Math.min(Math.max(data.ms, 0), 500) } : null;
204
417
  case "video-allowed":
205
418
  return typeof data.allowed === "boolean" ? { type: "video-allowed", allowed: data.allowed, ...VIDEO_BLOCKS.includes(data.reason) ? { reason: data.reason } : {} } : null;
419
+ case "intro": {
420
+ const { step, steps, text, voice } = data;
421
+ if (!(Number.isInteger(step) && Number.isInteger(steps) && typeof text === "string" && typeof voice === "boolean")) return null;
422
+ const [s, n] = [step, steps];
423
+ return s >= 0 && s < n && n <= INTRO_MAX_STEPS + 1 ? { type: "intro", step: s, steps: n, text: Array.from(text.trim()).slice(0, 200).join(""), voice } : null;
424
+ }
206
425
  case "screen-size": {
207
426
  if (typeof data.ask !== "boolean") return null;
208
427
  const zone = parseZone(data.zone);
@@ -324,7 +543,11 @@ export {
324
543
  DEFAULT_AVATARS,
325
544
  DEFAULT_AVATARS_BY_AGE,
326
545
  DEVICE_TIERS,
546
+ FABI_VOICE,
327
547
  GAME_BACKDROPS,
548
+ INTRO_MAX_CHARS,
549
+ INTRO_MAX_STEPS,
550
+ INTRO_MOVES,
328
551
  LANDMARKS,
329
552
  MAX_AVATAR_LENGTH,
330
553
  MAX_PLAYERS,
@@ -349,6 +572,7 @@ export {
349
572
  defaultAvatarFor,
350
573
  encodePose,
351
574
  gamesFor,
575
+ introText,
352
576
  isAgeRange,
353
577
  isAvatarName,
354
578
  isBody,
@@ -362,6 +586,7 @@ export {
362
586
  parseFov,
363
587
  parseGameMessage,
364
588
  parseGameStats,
589
+ parseIntro,
365
590
  parseManifest,
366
591
  parseScreenMessage,
367
592
  parseView,
@@ -372,6 +597,9 @@ export {
372
597
  sizeForInches,
373
598
  toPlayerSpace,
374
599
  toScreen,
600
+ voiceKey,
601
+ voiceKeySync,
602
+ voiceUrl,
375
603
  wire,
376
604
  youngest
377
605
  };
@@ -63,9 +63,9 @@
63
63
  },
64
64
  "entry": {
65
65
  "type": "string",
66
- "description": "The page that starts the game: an .html file relative to the game's address (same site, no \"..\").",
67
- "pattern": "^[\\w./-]+\\.html$",
68
- "not": { "pattern": "\\.\\." },
66
+ "description": "The page that starts the game: a path relative to the game's address (no scheme, no leading /, no \".\" or \"..\" segments, up to 200 characters) of letters, digits, _ and -, ending in lowercase .html.",
67
+ "pattern": "^(?:[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*/)*[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*\\.(?:html)$",
68
+ "maxLength": 200,
69
69
  "examples": ["index.html"]
70
70
  },
71
71
  "sdk": {
@@ -77,9 +77,83 @@
77
77
  "description": "Optional: the backdrops the game can draw over besides its own background. [\"video\"] opts in to mirror mode (paint no opaque background while backdrop is 'video').",
78
78
  "uniqueItems": true,
79
79
  "items": { "enum": ["video"] }
80
+ },
81
+ "intro": {
82
+ "type": "object",
83
+ "description": "Optional: Fabi explains the game on the TV before calibration, in 1 to 4 steps. Each step: one sentence (Italian and English, up to 90 characters; or one per age range) over an image of the game or a move shown with the children's avatars. The voice is generated by Fabi Play at publication from the text. Without intro the game goes straight to calibration; an invalid one is skipped by the TV.",
84
+ "required": ["steps"],
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "steps": {
88
+ "type": "array",
89
+ "minItems": 1,
90
+ "maxItems": 4,
91
+ "items": { "anyOf": [{ "$ref": "#/definitions/imageStep" }, { "$ref": "#/definitions/moveStep" }] }
92
+ }
93
+ }
80
94
  }
81
95
  },
82
96
  "definitions": {
97
+ "imageStep": {
98
+ "type": "object",
99
+ "description": "A step over an image of the game.",
100
+ "required": ["say", "image"],
101
+ "additionalProperties": false,
102
+ "properties": {
103
+ "say": { "$ref": "#/definitions/say" },
104
+ "image": {
105
+ "type": "string",
106
+ "description": "An image of the game, 16:9, at least 1280×720: a path relative to the game's address, with the same rules as entry (no scheme, no leading /, no \".\" or \"..\" segments, up to 200 characters), ending in lowercase .jpg or .png (no WebP: old TV browsers may not decode it). JPG (quality about 80) for screenshots, PNG only for flat art.",
107
+ "pattern": "^(?:[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*/)*[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*\\.(?:jpg|png)$",
108
+ "maxLength": 200,
109
+ "examples": ["intro/track.jpg"]
110
+ },
111
+ "practice": { "const": false, "description": "Only with a move." }
112
+ }
113
+ },
114
+ "moveStep": {
115
+ "type": "object",
116
+ "description": "A step showing a move with the children's avatars.",
117
+ "required": ["say", "move"],
118
+ "additionalProperties": false,
119
+ "properties": {
120
+ "say": { "$ref": "#/definitions/say" },
121
+ "move": {
122
+ "description": "The move Fabi shows, drawn with each child's avatar.",
123
+ "enum": ["jump", "squat", "clap", "lane-left", "lane-right", "arms-up", "arms-wide", "arms-v", "star"]
124
+ },
125
+ "practice": {
126
+ "type": "boolean",
127
+ "description": "true: the children try it and each gets a tick; the step moves on when all did, or after 8 s. Default false: Fabi only shows it."
128
+ }
129
+ }
130
+ },
131
+ "say": {
132
+ "description": "What Fabi says: Italian and English, up to 90 characters each; or one such per age range (at least one; the closest range present is used for the others).",
133
+ "anyOf": [
134
+ { "$ref": "#/definitions/texts90" },
135
+ {
136
+ "type": "object",
137
+ "minProperties": 1,
138
+ "additionalProperties": false,
139
+ "properties": {
140
+ "3-5": { "$ref": "#/definitions/texts90" },
141
+ "6-8": { "$ref": "#/definitions/texts90" },
142
+ "9-12": { "$ref": "#/definitions/texts90" }
143
+ }
144
+ }
145
+ ]
146
+ },
147
+ "texts90": {
148
+ "type": "object",
149
+ "description": "One sentence in Italian and English: up to 90 characters (code points), no white space at either end, no control or bidirectional control characters. Fabi Play's check (parseIntro) also rejects lone surrogates and counts the characters of the NFC form.",
150
+ "required": ["it", "en"],
151
+ "additionalProperties": false,
152
+ "properties": {
153
+ "it": { "type": "string", "minLength": 1, "maxLength": 90, "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069](?:[^\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069]*[^\\s\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069])?$" },
154
+ "en": { "type": "string", "minLength": 1, "maxLength": 90, "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069](?:[^\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069]*[^\\s\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069])?$" }
155
+ }
156
+ },
83
157
  "texts60": {
84
158
  "type": "object",
85
159
  "required": ["it", "en"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabi-play/protocol",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Messages between the phone, the screen and the games, with their checks.",
5
5
  "keywords": [
6
6
  "fabi-play",