@fabi-play/protocol 0.4.0 → 0.5.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
@@ -48,6 +48,33 @@ sentence for a locale and age.
48
48
  saying with `{ type: 'intro', step, steps, text, voice }`. Its `phase` message carries `intro: true` when the
49
49
  game has an intro: only then does the phone offer `replay`.
50
50
 
51
+ ## The cover
52
+
53
+ A manifest's optional `cover` is the game's card in the catalogue: a 16:9 image (1280×720 recommended) of the game's
54
+ own files, `cover.jpg`. `parseManifest` checks the path (`isCoverPath`: relative, no scheme, no leading `/`, no
55
+ backslash, no empty, `.` or `..` segments, `.jpg`, `.jpeg`, `.png` or `.webp` in lowercase, up to `COVER_MAX_PATH`,
56
+ 128, characters). The file itself is checked by Fabi Play's review, with the constants exported here:
57
+ `COVER_MAX_BYTES` (150 000), `COVER_ASPECT` (16 / 9) and `COVER_ASPECT_TOLERANCE` (0.02, relative), with
58
+ `isCoverAspect(width, height)`; `COVER_EXTENSIONS` and `COVER_RECOMMENDED` (1280×720) too.
59
+
60
+ ## Orientations
61
+
62
+ A manifest's optional `orientations` lists the screen orientations a game can be played in (`Orientation`:
63
+ `'landscape' | 'portrait'`, all of them in `ORIENTATIONS`): non-empty, without repeats; absent means
64
+ `['landscape']`, which `gameOrientations(manifest)` applies. A phone alone («Gioca qui / Play here») is held upright
65
+ and offers only games that list `'portrait'`; the TV and the tablet are landscape. The screen says the orientation a
66
+ game starts in with `init.orientation` (`orientationOf(width, height)`: width ≥ height is `'landscape'`); absent from
67
+ older hosts, it means `'landscape'`. It is informational: a game lays out from its real viewport size.
68
+
69
+ ## The carousel on two screens
70
+
71
+ The phone can show the TV's carousel of games, and both move together. While the grown-up swipes, the phone sends
72
+ `{ type: 'focus', game }` (ControllerMessage) for the card it centred: a preview only, nothing starts (`pick` starts
73
+ a game). The screen centres the same card and says the game's name with its voice (the screen owns the voice; the
74
+ phone stays silent). When the screen's carousel moves for any other reason (the remote, the hand cursor, another
75
+ phone), it sends `{ type: 'focus', game }` (ScreenMessage) and the phones follow. Both are checked like `pick`
76
+ (`isGameId`); a side that doesn't have the game in its catalogue ignores the message.
77
+
51
78
  ## Poses on the wire
52
79
 
53
80
  The phone streams `{ type: 'pose', t, bodies }` about 30 times a second over an unordered, no-retransmit data
package/dist/index.d.ts CHANGED
@@ -261,8 +261,9 @@ export declare const MAX_AVATAR_LENGTH = 32;
261
261
  export declare const isAvatarName: (v: unknown) => v is string;
262
262
  /**
263
263
  * The avatars of the four player slots when a child has not picked one, by age band: the first four of each band's
264
- * own avatars in @fabi-play/avatars (AVATAR_BANDS without the mascot, which is first in every band but is never a
265
- * default; a test checks they match there; the protocol does not depend on that package).
264
+ * list in @fabi-play/avatars (AVATAR_BANDS, where the band's own come first; the mascot, Fabi, is the host and in no
265
+ * band, so never a default, though a child saved with 'mascot' keeps it: any valid id is accepted). A test checks
266
+ * they match there; the protocol does not depend on that package.
266
267
  */
267
268
  export declare const DEFAULT_AVATARS_BY_AGE: Readonly<Record<AgeRange, readonly string[]>>;
268
269
  /** The slot defaults when the age is not known: 6–8's. */
@@ -320,6 +321,16 @@ export type ControllerMessage =
320
321
  type: "pick";
321
322
  game: string;
322
323
  }
324
+ /**
325
+ * The phone's carousel centred this game's card while the grown-up swipes (the phone shows the TV's carousel and
326
+ * both move together). Preview only: nothing starts (that is pick). The screen centres the same card and says the
327
+ * game's name with its voice (the screen owns the voice; the phone stays silent), and tells the other phones with
328
+ * its own focus message. A screen that does not know the game ignores it.
329
+ */
330
+ | {
331
+ type: "focus";
332
+ game: string;
333
+ }
323
334
  /** Buttons for the grown-up: pause, resume, quit (back to the games). */
324
335
  | {
325
336
  type: "command";
@@ -377,6 +388,15 @@ export type ScreenMessage =
377
388
  | {
378
389
  type: "catalogue";
379
390
  games: CatalogueGame[];
391
+ }
392
+ /**
393
+ * The screen's carousel centred this game's card (the remote, the hand cursor, or another phone's focus), so the
394
+ * phone's carousel follows. Preview only, like the controller's focus: nothing starts. A phone that does not have
395
+ * the game in its catalogue ignores it.
396
+ */
397
+ | {
398
+ type: "focus";
399
+ game: string;
380
400
  } | {
381
401
  type: "vibrate";
382
402
  ms: number;
@@ -648,6 +668,11 @@ export type HostMessage =
648
668
  * pictures: a phone that can save pictures is linked now (its hello said pictures), so the game may send picture
649
669
  * messages; it changes with a capabilities message. Absent = false.
650
670
  */
671
+ /**
672
+ * orientation: the screen's orientation when the game starts (width ≥ height: 'landscape', see orientationOf);
673
+ * only a game whose manifest lists 'portrait' gets 'portrait'. Informational, to choose a layout: lay out from the
674
+ * real viewport size all the same. Absent (older hosts) = 'landscape'.
675
+ */
651
676
  {
652
677
  type: "init";
653
678
  v: number;
@@ -664,6 +689,7 @@ export type HostMessage =
664
689
  turns?: number[];
665
690
  pointer?: boolean;
666
691
  pictures?: boolean;
692
+ orientation?: Orientation;
667
693
  }
668
694
  /** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
669
695
  | {
@@ -868,6 +894,14 @@ export interface GameManifest {
868
894
  backdrop?: GameBackdrop[];
869
895
  /** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
870
896
  intro?: GameIntro;
897
+ /**
898
+ * Optional: the game's cover, the card in the catalogue (TV carousel, phone, admin): a path relative to the game's
899
+ * address (no scheme, no leading /, no "." or ".." segments, no backslash, up to COVER_MAX_PATH characters, see
900
+ * isCoverPath), ending in lowercase .jpg, .jpeg, .png or .webp: cover.jpg. A 16:9 image (COVER_ASPECT, within
901
+ * COVER_ASPECT_TOLERANCE), recommended 1280×720 and at most COVER_MAX_BYTES; JPEG is the safest for old TV
902
+ * browsers. The review checks the file; absent or broken, the catalogue falls back to another image.
903
+ */
904
+ cover?: string;
871
905
  /** Optional: how it can be played; absent = ['body']. Must include 'body' (Fabi Play is a movement platform); add 'remote' to be offered without a phone. */
872
906
  inputs?: GameInput[];
873
907
  /**
@@ -892,7 +926,25 @@ export interface GameManifest {
892
926
  * false: arrows and OK make gestures.
893
927
  */
894
928
  remoteCursor?: boolean;
929
+ /**
930
+ * Optional: the screen orientations the game can be played in; absent = ['landscape'] (see gameOrientations).
931
+ * Non-empty, without repeats, from ORIENTATIONS. A phone alone («Gioca qui / Play here») is held upright, so it
932
+ * offers only games that list 'portrait'; the TV and the tablet are landscape. A game that lists both still lays
933
+ * out from its real viewport size; init.orientation says which one it started in.
934
+ */
935
+ orientations?: Orientation[];
895
936
  }
937
+ /**
938
+ * A screen's orientation: 'landscape' when its width is at least its height, else 'portrait' (see orientationOf).
939
+ * GameManifest.orientations lists those a game supports; HostMessage init.orientation says the one it starts in.
940
+ */
941
+ export type Orientation = "landscape" | "portrait";
942
+ export declare const ORIENTATIONS: Orientation[];
943
+ export declare const isOrientation: (v: unknown) => v is Orientation;
944
+ /** The orientations of a manifest, with the default (['landscape']). */
945
+ export declare function gameOrientations(m: Pick<GameManifest, "orientations">): Orientation[];
946
+ /** The orientation of a width × height screen: landscape when width ≥ height. */
947
+ export declare const orientationOf: (width: number, height: number) => Orientation;
896
948
  /** How the screen frames the camera image for a game: see GameManifest.framing. */
897
949
  export type GameFraming = "auto" | "fixed";
898
950
  export declare const GAME_FRAMINGS: GameFraming[];
@@ -926,6 +978,34 @@ export declare function parseManifest(data: unknown): {
926
978
  } | {
927
979
  errors: string[];
928
980
  };
981
+ /** The longest cover path a manifest may give (GameManifest.cover). */
982
+ export declare const COVER_MAX_PATH = 128;
983
+ /** The largest cover file the review accepts, in bytes (150 KB): a 1280×720 JPEG at about quality 80. */
984
+ export declare const COVER_MAX_BYTES = 150000;
985
+ /** A cover's shape: width / height, 16:9. */
986
+ export declare const COVER_ASPECT: number;
987
+ /** How far a cover's width / height may be from COVER_ASPECT, relative to it (0.02: 2%; 1280×720 is exact). */
988
+ export declare const COVER_ASPECT_TOLERANCE = 0.02;
989
+ /** The cover's extensions, lowercase: JPEG, PNG or WebP. */
990
+ export declare const COVER_EXTENSIONS: readonly [
991
+ "jpg",
992
+ "jpeg",
993
+ "png",
994
+ "webp"
995
+ ];
996
+ /** The recommended size of a cover, in pixels. */
997
+ export declare const COVER_RECOMMENDED: {
998
+ readonly width: 1280;
999
+ readonly height: 720;
1000
+ };
1001
+ /**
1002
+ * Whether v is a valid cover path (GameManifest.cover): a path of the game's own files, relative to its address (no
1003
+ * scheme, no leading /, no backslash, no empty, "." or ".." segments; segments of letters, digits, _ and -, dots
1004
+ * inside), up to COVER_MAX_PATH characters, ending in .jpg, .jpeg, .png or .webp (lowercase).
1005
+ */
1006
+ export declare const isCoverPath: (v: unknown) => v is string;
1007
+ /** Whether an image of width × height pixels has the cover's shape (COVER_ASPECT within COVER_ASPECT_TOLERANCE). */
1008
+ export declare const isCoverAspect: (width: number, height: number) => boolean;
929
1009
  /** The moves an intro step can show; the platform draws them with each child's avatar. Same names the SDK detects. */
930
1010
  export type IntroMove = "jump" | "squat" | "clap" | "lane-left" | "lane-right" | "arms-up" | "arms-wide" | "arms-v" | "star";
931
1011
  export declare const INTRO_MOVES: IntroMove[];
package/dist/index.js CHANGED
@@ -440,6 +440,12 @@ function parseTurnOrder(v) {
440
440
  return [...v];
441
441
  }
442
442
  var PAUSE_REASONS = ["user", "distance"];
443
+ var ORIENTATIONS = ["landscape", "portrait"];
444
+ var isOrientation = (v) => ORIENTATIONS.includes(v);
445
+ function gameOrientations(m) {
446
+ return m.orientations?.length ? m.orientations : ["landscape"];
447
+ }
448
+ var orientationOf = (width, height) => width >= height ? "landscape" : "portrait";
443
449
  var GAME_FRAMINGS = ["auto", "fixed"];
444
450
  function gameFraming(m) {
445
451
  return m.framing ?? "auto";
@@ -476,8 +482,20 @@ function parseManifest(data) {
476
482
  errors.push('remoteCursor: optional, true or false; true only with "remote" in inputs');
477
483
  if (m.framing !== void 0 && !GAME_FRAMINGS.includes(m.framing)) errors.push(`framing: optional, ${GAME_FRAMINGS.join(" or ")}`);
478
484
  if (m.intro !== void 0) errors.push(...introErrors(m.intro));
485
+ if (m.cover !== void 0 && !isCoverPath(m.cover))
486
+ errors.push(`cover: optional, a .jpg, .jpeg, .png or .webp of the game, a path relative to its address (no scheme, no leading /, no ..), up to ${COVER_MAX_PATH} characters`);
487
+ if (m.orientations !== void 0 && !(Array.isArray(m.orientations) && m.orientations.length > 0 && m.orientations.every(isOrientation) && new Set(m.orientations).size === m.orientations.length))
488
+ errors.push(`orientations: optional, at least one of ${ORIENTATIONS.join(", ")}, without repeats`);
479
489
  return errors.length ? { errors } : { manifest: m };
480
490
  }
491
+ var COVER_MAX_PATH = 128;
492
+ var COVER_MAX_BYTES = 15e4;
493
+ var COVER_ASPECT = 16 / 9;
494
+ var COVER_ASPECT_TOLERANCE = 0.02;
495
+ var COVER_EXTENSIONS = ["jpg", "jpeg", "png", "webp"];
496
+ var COVER_RECOMMENDED = { width: 1280, height: 720 };
497
+ var isCoverPath = (v) => typeof v === "string" && v.length <= COVER_MAX_PATH && isGamePath(v, COVER_EXTENSIONS.join("|"));
498
+ var isCoverAspect = (width, height) => isNumber(width) && isNumber(height) && width > 0 && height > 0 && Math.abs(width / height / COVER_ASPECT - 1) <= COVER_ASPECT_TOLERANCE;
481
499
  var INTRO_MOVES = ["jump", "squat", "clap", "lane-left", "lane-right", "arms-up", "arms-wide", "arms-v", "star"];
482
500
  var INTRO_MAX_STEPS = 4;
483
501
  var INTRO_MAX_CHARS = 90;
@@ -611,7 +629,8 @@ function parseControllerMessage(data) {
611
629
  if (!isScreenSizeId(data.size)) return null;
612
630
  return data.change === true ? { type: "screen-size", size: data.size, change: true } : { type: "screen-size", size: data.size };
613
631
  case "pick":
614
- return typeof data.game === "string" && isGameId(data.game) ? { type: "pick", game: data.game } : null;
632
+ case "focus":
633
+ return typeof data.game === "string" && isGameId(data.game) ? { type: data.type, game: data.game } : null;
615
634
  case "command":
616
635
  return COMMANDS.includes(data.command) ? { type: "command", command: data.command } : null;
617
636
  case "intro":
@@ -648,6 +667,8 @@ function parseScreenMessage(data) {
648
667
  );
649
668
  return games.length === data.games.length ? { type: "catalogue", games } : null;
650
669
  }
670
+ case "focus":
671
+ return typeof data.game === "string" && isGameId(data.game) ? { type: "focus", game: data.game } : null;
651
672
  case "vibrate":
652
673
  return isNumber(data.ms) ? { type: "vibrate", ms: Math.min(Math.max(data.ms, 0), 500) } : null;
653
674
  case "video-allowed":
@@ -780,6 +801,12 @@ async function migrateStorageKeyAsync(store, newKey, oldKey = legacyStorageKey(n
780
801
  export {
781
802
  AGE_RANGES,
782
803
  BREAK_REASONS,
804
+ COVER_ASPECT,
805
+ COVER_ASPECT_TOLERANCE,
806
+ COVER_EXTENSIONS,
807
+ COVER_MAX_BYTES,
808
+ COVER_MAX_PATH,
809
+ COVER_RECOMMENDED,
783
810
  DEFAULT_AVATARS,
784
811
  DEFAULT_AVATARS_BY_AGE,
785
812
  DEVICE_TIERS,
@@ -800,6 +827,7 @@ export {
800
827
  MIRROR_ENABLED,
801
828
  MOVEMENTS,
802
829
  NS,
830
+ ORIENTATIONS,
803
831
  P,
804
832
  PAUSE_REASONS,
805
833
  PHASES,
@@ -827,17 +855,22 @@ export {
827
855
  encodePose,
828
856
  gameFraming,
829
857
  gameInputs,
858
+ gameOrientations,
830
859
  gamesFor,
831
860
  introText,
832
861
  isAgeRange,
833
862
  isAvatarName,
834
863
  isBody,
864
+ isCoverAspect,
865
+ isCoverPath,
835
866
  isGameId,
867
+ isOrientation,
836
868
  isPictureId,
837
869
  isScreenSizeId,
838
870
  legacyStorageKey,
839
871
  migrateStorageKey,
840
872
  migrateStorageKeyAsync,
873
+ orientationOf,
841
874
  parseControllerMessage,
842
875
  parseCursor,
843
876
  parseDeviceHint,
@@ -113,6 +113,21 @@
113
113
  "enum": ["auto", "fixed"],
114
114
  "description": "Optional, \"auto\" by default: the screen zooms in on children standing far away (up to 1.8×), so every child's avatar is about the same size near or far; the view changes only at calibration and in breaks, never during play. \"fixed\": always the whole camera image (for a game drawn on exact spots of the live video)."
115
115
  },
116
+ "cover": {
117
+ "type": "string",
118
+ "description": "Optional: the game's cover, its card in the catalogue (TV carousel, phone, admin). A 16:9 image (1280×720 recommended, at most 150 KB; JPEG is the safest on old TV browsers): a path relative to the game's address, with the same rules as entry (no scheme, no leading /, no backslash, no \".\" or \"..\" segments), up to 128 characters, ending in lowercase .jpg, .jpeg, .png or .webp. The review checks the file; absent or broken, the catalogue shows another image.",
119
+ "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|jpeg|png|webp)$",
120
+ "maxLength": 128,
121
+ "examples": ["cover.jpg"]
122
+ },
123
+ "orientations": {
124
+ "type": "array",
125
+ "description": "Optional, [\"landscape\"] by default: the screen orientations the game can be played in. A phone alone (Play here) is held upright and offers only games that list \"portrait\"; the TV and the tablet are landscape. Lay out from the real viewport size all the same; the SDK's play.orientation says the one the game started in.",
126
+ "items": { "enum": ["landscape", "portrait"] },
127
+ "minItems": 1,
128
+ "uniqueItems": true,
129
+ "examples": [["landscape", "portrait"]]
130
+ },
116
131
  "intro": {
117
132
  "type": "object",
118
133
  "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.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabi-play/protocol",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Messages between the phone, the screen and the games, with their checks.",
5
5
  "keywords": [
6
6
  "fabi-play",