@thegeem/protocol 0.1.36 → 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 {
@@ -786,6 +827,8 @@ interface TeamView {
786
827
  /** The player on THIS team whose turn it is to answer (rotates each turn); null if the team has
787
828
  * no seated players. In controller mode only this player may `answer` on the team's turn. */
788
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;
789
832
  /** The seated player's privacy-safe avatar (relay-enriched; engine view omits it). */
790
833
  avatar?: AvatarView;
791
834
  }
@@ -797,6 +840,10 @@ interface QuestionView {
797
840
  selectedAnswerIndex: number | null;
798
841
  /** Only present once revealed (anti-cheat). Null while the question is live. */
799
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;
800
847
  imageName?: string;
801
848
  videoName?: string;
802
849
  /** For video questions: seconds into the clip to stop playback (so the answer isn't shown). Absent = play through. */
@@ -958,6 +1005,7 @@ type ServerMsg = {
958
1005
  t: 'joinRequest';
959
1006
  playerId: string;
960
1007
  name: string;
1008
+ wasKicked?: boolean;
961
1009
  } | {
962
1010
  t: 'joined';
963
1011
  youId: string;
@@ -983,6 +1031,7 @@ type ServerMsg = {
983
1031
  hostId: string;
984
1032
  hostName: string;
985
1033
  hostAvatar?: AvatarView;
1034
+ hostFrame?: string;
986
1035
  hostPlaying?: boolean;
987
1036
  hostPinned?: boolean;
988
1037
  players: LobbyPlayer[];
@@ -994,6 +1043,9 @@ type ServerMsg = {
994
1043
  tally?: {
995
1044
  [playerId: string]: number;
996
1045
  };
1046
+ tallyNames?: {
1047
+ [playerId: string]: string;
1048
+ };
997
1049
  modeId: ModeId | null;
998
1050
  you: YouContext;
999
1051
  } | {
@@ -1040,10 +1092,16 @@ type ServerMsg = {
1040
1092
  message: string;
1041
1093
  level?: 'info' | 'warn' | 'maintenance';
1042
1094
  restartInSec?: number;
1043
- } | {
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
+ | {
1044
1101
  t: 'error';
1045
1102
  code: string;
1046
1103
  message: string;
1104
+ reason?: string;
1047
1105
  };
1048
1106
 
1049
- 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 };
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 {
@@ -786,6 +827,8 @@ interface TeamView {
786
827
  /** The player on THIS team whose turn it is to answer (rotates each turn); null if the team has
787
828
  * no seated players. In controller mode only this player may `answer` on the team's turn. */
788
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;
789
832
  /** The seated player's privacy-safe avatar (relay-enriched; engine view omits it). */
790
833
  avatar?: AvatarView;
791
834
  }
@@ -797,6 +840,10 @@ interface QuestionView {
797
840
  selectedAnswerIndex: number | null;
798
841
  /** Only present once revealed (anti-cheat). Null while the question is live. */
799
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;
800
847
  imageName?: string;
801
848
  videoName?: string;
802
849
  /** For video questions: seconds into the clip to stop playback (so the answer isn't shown). Absent = play through. */
@@ -958,6 +1005,7 @@ type ServerMsg = {
958
1005
  t: 'joinRequest';
959
1006
  playerId: string;
960
1007
  name: string;
1008
+ wasKicked?: boolean;
961
1009
  } | {
962
1010
  t: 'joined';
963
1011
  youId: string;
@@ -983,6 +1031,7 @@ type ServerMsg = {
983
1031
  hostId: string;
984
1032
  hostName: string;
985
1033
  hostAvatar?: AvatarView;
1034
+ hostFrame?: string;
986
1035
  hostPlaying?: boolean;
987
1036
  hostPinned?: boolean;
988
1037
  players: LobbyPlayer[];
@@ -994,6 +1043,9 @@ type ServerMsg = {
994
1043
  tally?: {
995
1044
  [playerId: string]: number;
996
1045
  };
1046
+ tallyNames?: {
1047
+ [playerId: string]: string;
1048
+ };
997
1049
  modeId: ModeId | null;
998
1050
  you: YouContext;
999
1051
  } | {
@@ -1040,10 +1092,16 @@ type ServerMsg = {
1040
1092
  message: string;
1041
1093
  level?: 'info' | 'warn' | 'maintenance';
1042
1094
  restartInSec?: number;
1043
- } | {
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
+ | {
1044
1101
  t: 'error';
1045
1102
  code: string;
1046
1103
  message: string;
1104
+ reason?: string;
1047
1105
  };
1048
1106
 
1049
- 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 };
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.36",
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",