@thegeem/protocol 0.1.35 → 0.1.37

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/dist/index.cjs CHANGED
@@ -33,7 +33,8 @@ __export(index_exports, {
33
33
  ROOM_ALPHABET: () => ROOM_ALPHABET,
34
34
  gameIdForPv: () => gameIdForPv,
35
35
  gamesForPv: () => gamesForPv,
36
- modesForGame: () => modesForGame
36
+ modesForGame: () => modesForGame,
37
+ normalizeWesternDigits: () => normalizeWesternDigits
37
38
  });
38
39
  module.exports = __toCommonJS(index_exports);
39
40
  var import_zod = require("zod");
@@ -75,7 +76,8 @@ var EMOTE_IDS = ["laugh", "wow", "think", "gg"];
75
76
  var CHARACTER_IDS = ["m", "f"];
76
77
  var ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
77
78
  var UNSAFE_CHARS = /[\p{Cc}\u202A-\u202E\u2066-\u2069<>]/gu;
78
- var DisplayName = import_zod.z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform((s) => s.replace(UNSAFE_CHARS, "").trim()).refine((s) => s.length >= 1, "name is empty after sanitizing");
79
+ var normalizeWesternDigits = (s) => s.replace(/[٠-٩۰-۹]/g, (d) => String("\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669".indexOf(d) >= 0 ? "\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669".indexOf(d) : "\u06F0\u06F1\u06F2\u06F3\u06F4\u06F5\u06F6\u06F7\u06F8\u06F9".indexOf(d)));
80
+ var DisplayName = import_zod.z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform((s) => normalizeWesternDigits(s.replace(UNSAFE_CHARS, "")).trim()).refine((s) => s.length >= 1, "name is empty after sanitizing");
79
81
  var RoomCode = import_zod.z.string().trim().toUpperCase().regex(new RegExp(`^[${ROOM_ALPHABET}]{${LIMITS.ROOM_CODE_LEN}}$`), "bad room code");
80
82
  var PlayerId = import_zod.z.string().max(40);
81
83
  var HelpType = import_zod.z.enum([
@@ -89,7 +91,7 @@ var HelpType = import_zod.z.enum([
89
91
  ]);
90
92
  var Pv = import_zod.z.number().int().min(1).max(1e6).optional();
91
93
  var IdToken = import_zod.z.string().max(4096).optional();
92
- var Platform = import_zod.z.enum(["web", "ios", "android"]).optional();
94
+ var Platform = import_zod.z.enum(["web", "ios", "android", "tv"]).optional();
93
95
  var AppVersion = import_zod.z.string().trim().max(24).regex(/^[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]+)?$/, "bad app version").optional();
94
96
  var Avatar = import_zod.z.object({
95
97
  emoji: import_zod.z.string().min(1).max(16).transform((s) => s.replace(UNSAFE_CHARS, "")).refine((s) => s.length >= 1, "empty emoji"),
@@ -216,7 +218,11 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
216
218
  // trivia is 4-option (0–3)
217
219
  import_zod.z.object({ t: import_zod.z.literal("useHelp"), helpType: HelpType }),
218
220
  // sabotage a rival (active team only): spend a sabotage help to debuff targetTeam's next question
219
- import_zod.z.object({ t: import_zod.z.literal("sabotage"), helpType: HelpType, targetTeam: import_zod.z.number().int().min(0).max(LIMITS.MAX_PLAYERS - 1) }),
221
+ // `player` (gs#80): WHICH member of the target team sits out — required by استريح/restPlayer and
222
+ // ignored by عرجوب/tripLevel, which targets a whole team. Optional on the wire so pre-gs#80 clients
223
+ // are unchanged; the engine refuses to spend a restPlayer card without a valid one, so an old client
224
+ // cannot burn a lifeline by omitting it.
225
+ import_zod.z.object({ t: import_zod.z.literal("sabotage"), helpType: HelpType, targetTeam: import_zod.z.number().int().min(0).max(LIMITS.MAX_PLAYERS - 1), player: import_zod.z.string().max(64).optional() }),
220
226
  // 0-based team index
221
227
  // moderation
222
228
  // Report another player in the room (App Store 1.2 UGC — display names). Available in lobby or
@@ -275,5 +281,6 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
275
281
  ROOM_ALPHABET,
276
282
  gameIdForPv,
277
283
  gamesForPv,
278
- modesForGame
284
+ modesForGame,
285
+ normalizeWesternDigits
279
286
  });
package/dist/index.d.cts CHANGED
@@ -103,6 +103,15 @@ declare const CHARACTER_IDS: readonly ["m", "f"];
103
103
  type CharacterId = (typeof CHARACTER_IDS)[number];
104
104
  /** Unambiguous room-code alphabet — no 0/O, 1/I/L. */
105
105
  declare const ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
106
+ /**
107
+ * Arabic-Indic (٠-٩) and Extended/Persian (۰-۹) digits → Western, D-043 applied to NAMES at the
108
+ * gate (ruling 2026-08-13, the «سالم ١٧» leaderboard sighting). A digit is a GLYPH of a number,
109
+ * not identity — «سالم ١٧» and «سالم 17» are the same name — so normalising at write-time is the
110
+ * same class of edit as the bidi strip above, and every surface on every client agrees on what to
111
+ * call the person. Normalising at RENDER instead would have web and iOS disagreeing about a name.
112
+ * Letters are never touched; this maps exactly twenty codepoints.
113
+ */
114
+ declare const normalizeWesternDigits: (s: string) => string;
106
115
  declare const HelpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
107
116
  declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
108
117
  t: z.ZodLiteral<"createRoom">;
@@ -121,7 +130,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
121
130
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
122
131
  pv: z.ZodOptional<z.ZodNumber>;
123
132
  idToken: z.ZodOptional<z.ZodString>;
124
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
133
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
125
134
  appVersion: z.ZodOptional<z.ZodString>;
126
135
  }, "strip", z.ZodTypeAny, {
127
136
  t: "createRoom";
@@ -134,7 +143,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
134
143
  character?: "m" | "f" | undefined;
135
144
  pv?: number | undefined;
136
145
  idToken?: string | undefined;
137
- platform?: "web" | "ios" | "android" | undefined;
146
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
138
147
  appVersion?: string | undefined;
139
148
  }, {
140
149
  t: "createRoom";
@@ -147,7 +156,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
147
156
  character?: "m" | "f" | undefined;
148
157
  pv?: number | undefined;
149
158
  idToken?: string | undefined;
150
- platform?: "web" | "ios" | "android" | undefined;
159
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
151
160
  appVersion?: string | undefined;
152
161
  }>, z.ZodObject<{
153
162
  t: z.ZodLiteral<"renameHost">;
@@ -205,7 +214,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
205
214
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
206
215
  pv: z.ZodOptional<z.ZodNumber>;
207
216
  idToken: z.ZodOptional<z.ZodString>;
208
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
217
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
209
218
  appVersion: z.ZodOptional<z.ZodString>;
210
219
  }, "strip", z.ZodTypeAny, {
211
220
  code: string;
@@ -218,7 +227,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
218
227
  character?: "m" | "f" | undefined;
219
228
  pv?: number | undefined;
220
229
  idToken?: string | undefined;
221
- platform?: "web" | "ios" | "android" | undefined;
230
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
222
231
  appVersion?: string | undefined;
223
232
  }, {
224
233
  code: string;
@@ -231,7 +240,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
231
240
  character?: "m" | "f" | undefined;
232
241
  pv?: number | undefined;
233
242
  idToken?: string | undefined;
234
- platform?: "web" | "ios" | "android" | undefined;
243
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
235
244
  appVersion?: string | undefined;
236
245
  }>, z.ZodObject<{
237
246
  t: z.ZodLiteral<"resume">;
@@ -250,7 +259,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
250
259
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
251
260
  pv: z.ZodOptional<z.ZodNumber>;
252
261
  idToken: z.ZodOptional<z.ZodString>;
253
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
262
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
254
263
  appVersion: z.ZodOptional<z.ZodString>;
255
264
  }, "strip", z.ZodTypeAny, {
256
265
  code: string;
@@ -263,7 +272,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
263
272
  character?: "m" | "f" | undefined;
264
273
  pv?: number | undefined;
265
274
  idToken?: string | undefined;
266
- platform?: "web" | "ios" | "android" | undefined;
275
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
267
276
  appVersion?: string | undefined;
268
277
  }, {
269
278
  code: string;
@@ -276,7 +285,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
276
285
  character?: "m" | "f" | undefined;
277
286
  pv?: number | undefined;
278
287
  idToken?: string | undefined;
279
- platform?: "web" | "ios" | "android" | undefined;
288
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
280
289
  appVersion?: string | undefined;
281
290
  }>, z.ZodObject<{
282
291
  t: z.ZodLiteral<"setPlaying">;
@@ -505,14 +514,17 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
505
514
  t: z.ZodLiteral<"sabotage">;
506
515
  helpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
507
516
  targetTeam: z.ZodNumber;
517
+ player: z.ZodOptional<z.ZodString>;
508
518
  }, "strip", z.ZodTypeAny, {
509
519
  t: "sabotage";
510
520
  helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
511
521
  targetTeam: number;
522
+ player?: string | undefined;
512
523
  }, {
513
524
  t: "sabotage";
514
525
  helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
515
526
  targetTeam: number;
527
+ player?: string | undefined;
516
528
  }>, z.ZodObject<{
517
529
  t: z.ZodLiteral<"report">;
518
530
  playerId: z.ZodString;
@@ -691,6 +703,32 @@ interface LobbyPlayer {
691
703
  avatar?: AvatarView;
692
704
  /** Team index once the game has started; null/absent in the lobby. */
693
705
  teamIndex?: number | null;
706
+ /**
707
+ * Equipped avatar-frame slug (e.g. "frame-sadu") — resolves to art via /assets/store/catalog.json
708
+ * (the catalog is the ONLY resolver; nothing about the art is derived from the slug). Read from the
709
+ * account once at room entry, so an equip change shows on the player's next join/resume. Absent =
710
+ * frameless. Earnable-class cosmetics only ever ride the room wire (D-059) — a paid exclusive can
711
+ * never appear here, so there is no receipt check in the broadcast path and no have/have-not row
712
+ * between children on one sofa. (gs#84 contract, D-060.)
713
+ */
714
+ frame?: string;
715
+ /**
716
+ * This room kicked this exact person before — a HINT for the host, never a gate (the ruling is
717
+ * "label the re-knock, no ban list"). **Present ONLY in the HOST's copy of the frame**: telling a
718
+ * whole room who was once removed is a social leak, not a moderation aid.
719
+ *
720
+ * The same bit also rides `joinRequest` for the immediate ping. This one is the DURABLE carrier —
721
+ * it is RE-READ from the answer stored on the seat (decided once, at join; never recomputed by
722
+ * re-matching, which goes stale) and re-sent on every snapshot, so it survives a host reconnect,
723
+ * where a cached one-shot event would not. A client should prefer this and treat the event as a
724
+ * nudge. MID-GAME there are no lobby frames: there a host who resumes is re-sent the pending
725
+ * `joinRequest`s instead (labels included), so the durable promise holds in both halves.
726
+ *
727
+ * Absent means "no reason to think so", which includes the deliberate miss: an anonymous player who
728
+ * force-quits and rejoins reads as a stranger. Matched only on exact socket/sign-in identity — never
729
+ * a display name or IP, because a wrong label is worse than none.
730
+ */
731
+ wasKicked?: boolean;
694
732
  }
695
733
  /**
696
734
  * Who is in the room, carried ON the game-state push (gs#71).
@@ -719,6 +757,9 @@ interface RosterEntry {
719
757
  isHost?: boolean;
720
758
  /** Is this person currently connected? Mirrors `LobbyPlayer.connected`. */
721
759
  connected?: boolean;
760
+ /** Equipped avatar-frame slug — mirrors `LobbyPlayer.frame`, because the roster is the MID-GAME
761
+ * carrier (gs#71): a cosmetic with no mid-game carrier silently vanishes for watchers and the TV. */
762
+ frame?: string;
722
763
  }
723
764
  /** Per-recipient context attached to lobby/state pushes. */
724
765
  interface YouContext {
@@ -738,6 +779,25 @@ interface CategoryView {
738
779
  /** Category's brand color (hex, e.g. '#E8473F') for board coloring; absent if the category has none. */
739
780
  color?: string;
740
781
  }
782
+ /**
783
+ * A content pack — the grouping the category strip renders as a row (geem-server#76).
784
+ *
785
+ * **The ARRAY ORDER is the pack order.** That is the point of this type: pack order previously had no
786
+ * home on the wire, so all four clients derived it by first-seen from `categories[]` (Android's
787
+ * `onlineGroupedPacks`, iOS's `order: [String]`, and web/TV their own). Four derivations of one fact,
788
+ * and when the server's own category order turned out to be non-deterministic the strip reshuffled
789
+ * between visits (gs#74) with nothing on the wire to pin it. One ordered array replaces all four.
790
+ *
791
+ * Deliberately NO `sortOrder` field: an ordered array already carries the order, and a positional index
792
+ * beside it would be the same fact stored twice — which is exactly the drift argument that made
793
+ * `has_image` a boolean rather than a filename. Position cannot disagree with itself.
794
+ */
795
+ interface PackView {
796
+ id: string;
797
+ name: string;
798
+ /** Does this pack have cover art at `/assets/cat/pack-<id>.png`? Absent until the admin field ships. */
799
+ hasImage?: boolean;
800
+ }
741
801
  type GameStatus = 'setup' | 'categorySelection' | 'inProgress' | 'completed';
742
802
  type GameplayFlow = 'classic' | 'openJudged' | 'noHost' | 'buzzer';
743
803
  type BuzzerGameMode = 'off' | 'solo' | 'race' | 'controller';
@@ -767,6 +827,8 @@ interface TeamView {
767
827
  /** The player on THIS team whose turn it is to answer (rotates each turn); null if the team has
768
828
  * no seated players. In controller mode only this player may `answer` on the team's turn. */
769
829
  currentPlayerId: string | null;
830
+ /** استريح (gs#80): this team's player who sits out their next question, if a rival named one. */
831
+ restedPlayerId?: string | null;
770
832
  /** The seated player's privacy-safe avatar (relay-enriched; engine view omits it). */
771
833
  avatar?: AvatarView;
772
834
  }
@@ -778,6 +840,10 @@ interface QuestionView {
778
840
  selectedAnswerIndex: number | null;
779
841
  /** Only present once revealed (anti-cheat). Null while the question is live. */
780
842
  correctAnswerIndex: number | null;
843
+ /** «معلومة» — optional explanation shown AFTER the reveal. Null while the question is live (it
844
+ * usually names the answer — same anti-cheat gate as correctAnswerIndex) and null when the
845
+ * question simply has none, which is most of the bank: render the card only when non-null. */
846
+ explain?: string | null;
781
847
  imageName?: string;
782
848
  videoName?: string;
783
849
  /** For video questions: seconds into the clip to stop playback (so the answer isn't shown). Absent = play through. */
@@ -939,6 +1005,7 @@ type ServerMsg = {
939
1005
  t: 'joinRequest';
940
1006
  playerId: string;
941
1007
  name: string;
1008
+ wasKicked?: boolean;
942
1009
  } | {
943
1010
  t: 'joined';
944
1011
  youId: string;
@@ -964,16 +1031,21 @@ type ServerMsg = {
964
1031
  hostId: string;
965
1032
  hostName: string;
966
1033
  hostAvatar?: AvatarView;
1034
+ hostFrame?: string;
967
1035
  hostPlaying?: boolean;
968
1036
  hostPinned?: boolean;
969
1037
  players: LobbyPlayer[];
970
1038
  categories: CategoryView[];
1039
+ packs?: PackView[];
971
1040
  selectedCategoryIds: string[];
972
1041
  teams: TeamSlotView[];
973
1042
  hostActivity: 'configuring' | 'idle';
974
1043
  tally?: {
975
1044
  [playerId: string]: number;
976
1045
  };
1046
+ tallyNames?: {
1047
+ [playerId: string]: string;
1048
+ };
977
1049
  modeId: ModeId | null;
978
1050
  you: YouContext;
979
1051
  } | {
@@ -1020,10 +1092,16 @@ type ServerMsg = {
1020
1092
  message: string;
1021
1093
  level?: 'info' | 'warn' | 'maintenance';
1022
1094
  restartInSec?: number;
1023
- } | {
1095
+ }
1096
+ /** `reason` (gs#83, additive): today only `'ended'`, and only on `no_room` — the code WAS a room
1097
+ * until recently (stale WhatsApp link, knock after the host closed the night). Absent = "no such
1098
+ * room as far as we know". Lets a client say «انتهى اللعب» instead of «ما في غرفة بهذا الكود»
1099
+ * without guessing. Old clients ignore it. */
1100
+ | {
1024
1101
  t: 'error';
1025
1102
  code: string;
1026
1103
  message: string;
1104
+ reason?: string;
1027
1105
  };
1028
1106
 
1029
- export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, type CategoryView, type CharacterId, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, EMOTE_IDS, type Effect, type EmoteId, type ErsimhaStateView, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type QuestionView, ROOM_ALPHABET, type RosterEntry, type ServerMsg, type SoundName, type StrokePointView, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame };
1107
+ export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, type CategoryView, type CharacterId, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, EMOTE_IDS, type Effect, type EmoteId, type ErsimhaStateView, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type PackView, type QuestionView, ROOM_ALPHABET, type RosterEntry, type ServerMsg, type SoundName, type StrokePointView, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame, normalizeWesternDigits };
package/dist/index.d.ts CHANGED
@@ -103,6 +103,15 @@ declare const CHARACTER_IDS: readonly ["m", "f"];
103
103
  type CharacterId = (typeof CHARACTER_IDS)[number];
104
104
  /** Unambiguous room-code alphabet — no 0/O, 1/I/L. */
105
105
  declare const ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
106
+ /**
107
+ * Arabic-Indic (٠-٩) and Extended/Persian (۰-۹) digits → Western, D-043 applied to NAMES at the
108
+ * gate (ruling 2026-08-13, the «سالم ١٧» leaderboard sighting). A digit is a GLYPH of a number,
109
+ * not identity — «سالم ١٧» and «سالم 17» are the same name — so normalising at write-time is the
110
+ * same class of edit as the bidi strip above, and every surface on every client agrees on what to
111
+ * call the person. Normalising at RENDER instead would have web and iOS disagreeing about a name.
112
+ * Letters are never touched; this maps exactly twenty codepoints.
113
+ */
114
+ declare const normalizeWesternDigits: (s: string) => string;
106
115
  declare const HelpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
107
116
  declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
108
117
  t: z.ZodLiteral<"createRoom">;
@@ -121,7 +130,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
121
130
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
122
131
  pv: z.ZodOptional<z.ZodNumber>;
123
132
  idToken: z.ZodOptional<z.ZodString>;
124
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
133
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
125
134
  appVersion: z.ZodOptional<z.ZodString>;
126
135
  }, "strip", z.ZodTypeAny, {
127
136
  t: "createRoom";
@@ -134,7 +143,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
134
143
  character?: "m" | "f" | undefined;
135
144
  pv?: number | undefined;
136
145
  idToken?: string | undefined;
137
- platform?: "web" | "ios" | "android" | undefined;
146
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
138
147
  appVersion?: string | undefined;
139
148
  }, {
140
149
  t: "createRoom";
@@ -147,7 +156,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
147
156
  character?: "m" | "f" | undefined;
148
157
  pv?: number | undefined;
149
158
  idToken?: string | undefined;
150
- platform?: "web" | "ios" | "android" | undefined;
159
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
151
160
  appVersion?: string | undefined;
152
161
  }>, z.ZodObject<{
153
162
  t: z.ZodLiteral<"renameHost">;
@@ -205,7 +214,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
205
214
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
206
215
  pv: z.ZodOptional<z.ZodNumber>;
207
216
  idToken: z.ZodOptional<z.ZodString>;
208
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
217
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
209
218
  appVersion: z.ZodOptional<z.ZodString>;
210
219
  }, "strip", z.ZodTypeAny, {
211
220
  code: string;
@@ -218,7 +227,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
218
227
  character?: "m" | "f" | undefined;
219
228
  pv?: number | undefined;
220
229
  idToken?: string | undefined;
221
- platform?: "web" | "ios" | "android" | undefined;
230
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
222
231
  appVersion?: string | undefined;
223
232
  }, {
224
233
  code: string;
@@ -231,7 +240,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
231
240
  character?: "m" | "f" | undefined;
232
241
  pv?: number | undefined;
233
242
  idToken?: string | undefined;
234
- platform?: "web" | "ios" | "android" | undefined;
243
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
235
244
  appVersion?: string | undefined;
236
245
  }>, z.ZodObject<{
237
246
  t: z.ZodLiteral<"resume">;
@@ -250,7 +259,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
250
259
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
251
260
  pv: z.ZodOptional<z.ZodNumber>;
252
261
  idToken: z.ZodOptional<z.ZodString>;
253
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
262
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
254
263
  appVersion: z.ZodOptional<z.ZodString>;
255
264
  }, "strip", z.ZodTypeAny, {
256
265
  code: string;
@@ -263,7 +272,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
263
272
  character?: "m" | "f" | undefined;
264
273
  pv?: number | undefined;
265
274
  idToken?: string | undefined;
266
- platform?: "web" | "ios" | "android" | undefined;
275
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
267
276
  appVersion?: string | undefined;
268
277
  }, {
269
278
  code: string;
@@ -276,7 +285,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
276
285
  character?: "m" | "f" | undefined;
277
286
  pv?: number | undefined;
278
287
  idToken?: string | undefined;
279
- platform?: "web" | "ios" | "android" | undefined;
288
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
280
289
  appVersion?: string | undefined;
281
290
  }>, z.ZodObject<{
282
291
  t: z.ZodLiteral<"setPlaying">;
@@ -505,14 +514,17 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
505
514
  t: z.ZodLiteral<"sabotage">;
506
515
  helpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
507
516
  targetTeam: z.ZodNumber;
517
+ player: z.ZodOptional<z.ZodString>;
508
518
  }, "strip", z.ZodTypeAny, {
509
519
  t: "sabotage";
510
520
  helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
511
521
  targetTeam: number;
522
+ player?: string | undefined;
512
523
  }, {
513
524
  t: "sabotage";
514
525
  helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
515
526
  targetTeam: number;
527
+ player?: string | undefined;
516
528
  }>, z.ZodObject<{
517
529
  t: z.ZodLiteral<"report">;
518
530
  playerId: z.ZodString;
@@ -691,6 +703,32 @@ interface LobbyPlayer {
691
703
  avatar?: AvatarView;
692
704
  /** Team index once the game has started; null/absent in the lobby. */
693
705
  teamIndex?: number | null;
706
+ /**
707
+ * Equipped avatar-frame slug (e.g. "frame-sadu") — resolves to art via /assets/store/catalog.json
708
+ * (the catalog is the ONLY resolver; nothing about the art is derived from the slug). Read from the
709
+ * account once at room entry, so an equip change shows on the player's next join/resume. Absent =
710
+ * frameless. Earnable-class cosmetics only ever ride the room wire (D-059) — a paid exclusive can
711
+ * never appear here, so there is no receipt check in the broadcast path and no have/have-not row
712
+ * between children on one sofa. (gs#84 contract, D-060.)
713
+ */
714
+ frame?: string;
715
+ /**
716
+ * This room kicked this exact person before — a HINT for the host, never a gate (the ruling is
717
+ * "label the re-knock, no ban list"). **Present ONLY in the HOST's copy of the frame**: telling a
718
+ * whole room who was once removed is a social leak, not a moderation aid.
719
+ *
720
+ * The same bit also rides `joinRequest` for the immediate ping. This one is the DURABLE carrier —
721
+ * it is RE-READ from the answer stored on the seat (decided once, at join; never recomputed by
722
+ * re-matching, which goes stale) and re-sent on every snapshot, so it survives a host reconnect,
723
+ * where a cached one-shot event would not. A client should prefer this and treat the event as a
724
+ * nudge. MID-GAME there are no lobby frames: there a host who resumes is re-sent the pending
725
+ * `joinRequest`s instead (labels included), so the durable promise holds in both halves.
726
+ *
727
+ * Absent means "no reason to think so", which includes the deliberate miss: an anonymous player who
728
+ * force-quits and rejoins reads as a stranger. Matched only on exact socket/sign-in identity — never
729
+ * a display name or IP, because a wrong label is worse than none.
730
+ */
731
+ wasKicked?: boolean;
694
732
  }
695
733
  /**
696
734
  * Who is in the room, carried ON the game-state push (gs#71).
@@ -719,6 +757,9 @@ interface RosterEntry {
719
757
  isHost?: boolean;
720
758
  /** Is this person currently connected? Mirrors `LobbyPlayer.connected`. */
721
759
  connected?: boolean;
760
+ /** Equipped avatar-frame slug — mirrors `LobbyPlayer.frame`, because the roster is the MID-GAME
761
+ * carrier (gs#71): a cosmetic with no mid-game carrier silently vanishes for watchers and the TV. */
762
+ frame?: string;
722
763
  }
723
764
  /** Per-recipient context attached to lobby/state pushes. */
724
765
  interface YouContext {
@@ -738,6 +779,25 @@ interface CategoryView {
738
779
  /** Category's brand color (hex, e.g. '#E8473F') for board coloring; absent if the category has none. */
739
780
  color?: string;
740
781
  }
782
+ /**
783
+ * A content pack — the grouping the category strip renders as a row (geem-server#76).
784
+ *
785
+ * **The ARRAY ORDER is the pack order.** That is the point of this type: pack order previously had no
786
+ * home on the wire, so all four clients derived it by first-seen from `categories[]` (Android's
787
+ * `onlineGroupedPacks`, iOS's `order: [String]`, and web/TV their own). Four derivations of one fact,
788
+ * and when the server's own category order turned out to be non-deterministic the strip reshuffled
789
+ * between visits (gs#74) with nothing on the wire to pin it. One ordered array replaces all four.
790
+ *
791
+ * Deliberately NO `sortOrder` field: an ordered array already carries the order, and a positional index
792
+ * beside it would be the same fact stored twice — which is exactly the drift argument that made
793
+ * `has_image` a boolean rather than a filename. Position cannot disagree with itself.
794
+ */
795
+ interface PackView {
796
+ id: string;
797
+ name: string;
798
+ /** Does this pack have cover art at `/assets/cat/pack-<id>.png`? Absent until the admin field ships. */
799
+ hasImage?: boolean;
800
+ }
741
801
  type GameStatus = 'setup' | 'categorySelection' | 'inProgress' | 'completed';
742
802
  type GameplayFlow = 'classic' | 'openJudged' | 'noHost' | 'buzzer';
743
803
  type BuzzerGameMode = 'off' | 'solo' | 'race' | 'controller';
@@ -767,6 +827,8 @@ interface TeamView {
767
827
  /** The player on THIS team whose turn it is to answer (rotates each turn); null if the team has
768
828
  * no seated players. In controller mode only this player may `answer` on the team's turn. */
769
829
  currentPlayerId: string | null;
830
+ /** استريح (gs#80): this team's player who sits out their next question, if a rival named one. */
831
+ restedPlayerId?: string | null;
770
832
  /** The seated player's privacy-safe avatar (relay-enriched; engine view omits it). */
771
833
  avatar?: AvatarView;
772
834
  }
@@ -778,6 +840,10 @@ interface QuestionView {
778
840
  selectedAnswerIndex: number | null;
779
841
  /** Only present once revealed (anti-cheat). Null while the question is live. */
780
842
  correctAnswerIndex: number | null;
843
+ /** «معلومة» — optional explanation shown AFTER the reveal. Null while the question is live (it
844
+ * usually names the answer — same anti-cheat gate as correctAnswerIndex) and null when the
845
+ * question simply has none, which is most of the bank: render the card only when non-null. */
846
+ explain?: string | null;
781
847
  imageName?: string;
782
848
  videoName?: string;
783
849
  /** For video questions: seconds into the clip to stop playback (so the answer isn't shown). Absent = play through. */
@@ -939,6 +1005,7 @@ type ServerMsg = {
939
1005
  t: 'joinRequest';
940
1006
  playerId: string;
941
1007
  name: string;
1008
+ wasKicked?: boolean;
942
1009
  } | {
943
1010
  t: 'joined';
944
1011
  youId: string;
@@ -964,16 +1031,21 @@ type ServerMsg = {
964
1031
  hostId: string;
965
1032
  hostName: string;
966
1033
  hostAvatar?: AvatarView;
1034
+ hostFrame?: string;
967
1035
  hostPlaying?: boolean;
968
1036
  hostPinned?: boolean;
969
1037
  players: LobbyPlayer[];
970
1038
  categories: CategoryView[];
1039
+ packs?: PackView[];
971
1040
  selectedCategoryIds: string[];
972
1041
  teams: TeamSlotView[];
973
1042
  hostActivity: 'configuring' | 'idle';
974
1043
  tally?: {
975
1044
  [playerId: string]: number;
976
1045
  };
1046
+ tallyNames?: {
1047
+ [playerId: string]: string;
1048
+ };
977
1049
  modeId: ModeId | null;
978
1050
  you: YouContext;
979
1051
  } | {
@@ -1020,10 +1092,16 @@ type ServerMsg = {
1020
1092
  message: string;
1021
1093
  level?: 'info' | 'warn' | 'maintenance';
1022
1094
  restartInSec?: number;
1023
- } | {
1095
+ }
1096
+ /** `reason` (gs#83, additive): today only `'ended'`, and only on `no_room` — the code WAS a room
1097
+ * until recently (stale WhatsApp link, knock after the host closed the night). Absent = "no such
1098
+ * room as far as we know". Lets a client say «انتهى اللعب» instead of «ما في غرفة بهذا الكود»
1099
+ * without guessing. Old clients ignore it. */
1100
+ | {
1024
1101
  t: 'error';
1025
1102
  code: string;
1026
1103
  message: string;
1104
+ reason?: string;
1027
1105
  };
1028
1106
 
1029
- export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, type CategoryView, type CharacterId, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, EMOTE_IDS, type Effect, type EmoteId, type ErsimhaStateView, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type QuestionView, ROOM_ALPHABET, type RosterEntry, type ServerMsg, type SoundName, type StrokePointView, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame };
1107
+ export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, type CategoryView, type CharacterId, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, EMOTE_IDS, type Effect, type EmoteId, type ErsimhaStateView, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type PackView, type QuestionView, ROOM_ALPHABET, type RosterEntry, type ServerMsg, type SoundName, type StrokePointView, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame, normalizeWesternDigits };
package/dist/index.js CHANGED
@@ -38,7 +38,8 @@ var EMOTE_IDS = ["laugh", "wow", "think", "gg"];
38
38
  var CHARACTER_IDS = ["m", "f"];
39
39
  var ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
40
40
  var UNSAFE_CHARS = /[\p{Cc}\u202A-\u202E\u2066-\u2069<>]/gu;
41
- var DisplayName = z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform((s) => s.replace(UNSAFE_CHARS, "").trim()).refine((s) => s.length >= 1, "name is empty after sanitizing");
41
+ var normalizeWesternDigits = (s) => s.replace(/[٠-٩۰-۹]/g, (d) => String("\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669".indexOf(d) >= 0 ? "\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669".indexOf(d) : "\u06F0\u06F1\u06F2\u06F3\u06F4\u06F5\u06F6\u06F7\u06F8\u06F9".indexOf(d)));
42
+ var DisplayName = z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform((s) => normalizeWesternDigits(s.replace(UNSAFE_CHARS, "")).trim()).refine((s) => s.length >= 1, "name is empty after sanitizing");
42
43
  var RoomCode = z.string().trim().toUpperCase().regex(new RegExp(`^[${ROOM_ALPHABET}]{${LIMITS.ROOM_CODE_LEN}}$`), "bad room code");
43
44
  var PlayerId = z.string().max(40);
44
45
  var HelpType = z.enum([
@@ -52,7 +53,7 @@ var HelpType = z.enum([
52
53
  ]);
53
54
  var Pv = z.number().int().min(1).max(1e6).optional();
54
55
  var IdToken = z.string().max(4096).optional();
55
- var Platform = z.enum(["web", "ios", "android"]).optional();
56
+ var Platform = z.enum(["web", "ios", "android", "tv"]).optional();
56
57
  var AppVersion = z.string().trim().max(24).regex(/^[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]+)?$/, "bad app version").optional();
57
58
  var Avatar = z.object({
58
59
  emoji: z.string().min(1).max(16).transform((s) => s.replace(UNSAFE_CHARS, "")).refine((s) => s.length >= 1, "empty emoji"),
@@ -179,7 +180,11 @@ var ClientMsg = z.discriminatedUnion("t", [
179
180
  // trivia is 4-option (0–3)
180
181
  z.object({ t: z.literal("useHelp"), helpType: HelpType }),
181
182
  // sabotage a rival (active team only): spend a sabotage help to debuff targetTeam's next question
182
- z.object({ t: z.literal("sabotage"), helpType: HelpType, targetTeam: z.number().int().min(0).max(LIMITS.MAX_PLAYERS - 1) }),
183
+ // `player` (gs#80): WHICH member of the target team sits out — required by استريح/restPlayer and
184
+ // ignored by عرجوب/tripLevel, which targets a whole team. Optional on the wire so pre-gs#80 clients
185
+ // are unchanged; the engine refuses to spend a restPlayer card without a valid one, so an old client
186
+ // cannot burn a lifeline by omitting it.
187
+ z.object({ t: z.literal("sabotage"), helpType: HelpType, targetTeam: z.number().int().min(0).max(LIMITS.MAX_PLAYERS - 1), player: z.string().max(64).optional() }),
183
188
  // 0-based team index
184
189
  // moderation
185
190
  // Report another player in the room (App Store 1.2 UGC — display names). Available in lobby or
@@ -237,5 +242,6 @@ export {
237
242
  ROOM_ALPHABET,
238
243
  gameIdForPv,
239
244
  gamesForPv,
240
- modesForGame
245
+ modesForGame,
246
+ normalizeWesternDigits
241
247
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegeem/protocol",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Geem's wire protocol — shared zod schemas + TypeScript types for talking to the Geem game server over Socket.IO. The source of truth all clients implement against.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://geem.tv",