@thegeem/protocol 0.1.36 → 0.1.38

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
@@ -30,10 +30,15 @@ __export(index_exports, {
30
30
  MODES: () => MODES,
31
31
  MODE_IDS: () => MODE_IDS,
32
32
  PROTOCOL_VERSION: () => PROTOCOL_VERSION,
33
+ REJECT_REASONS: () => REJECT_REASONS,
33
34
  ROOM_ALPHABET: () => ROOM_ALPHABET,
35
+ SUBMISSION_KINDS: () => SUBMISSION_KINDS,
36
+ SUBMISSION_STATUSES: () => SUBMISSION_STATUSES,
34
37
  gameIdForPv: () => gameIdForPv,
35
38
  gamesForPv: () => gamesForPv,
36
- modesForGame: () => modesForGame
39
+ modesForGame: () => modesForGame,
40
+ normalizeWesternDigits: () => normalizeWesternDigits,
41
+ sanitizeName: () => sanitizeName
37
42
  });
38
43
  module.exports = __toCommonJS(index_exports);
39
44
  var import_zod = require("zod");
@@ -75,7 +80,9 @@ var EMOTE_IDS = ["laugh", "wow", "think", "gg"];
75
80
  var CHARACTER_IDS = ["m", "f"];
76
81
  var ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
77
82
  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");
83
+ 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)));
84
+ var sanitizeName = (s) => normalizeWesternDigits(s.replace(UNSAFE_CHARS, "")).trim();
85
+ var DisplayName = import_zod.z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform(sanitizeName).refine((s) => s.length >= 1, "name is empty after sanitizing");
79
86
  var RoomCode = import_zod.z.string().trim().toUpperCase().regex(new RegExp(`^[${ROOM_ALPHABET}]{${LIMITS.ROOM_CODE_LEN}}$`), "bad room code");
80
87
  var PlayerId = import_zod.z.string().max(40);
81
88
  var HelpType = import_zod.z.enum([
@@ -89,7 +96,7 @@ var HelpType = import_zod.z.enum([
89
96
  ]);
90
97
  var Pv = import_zod.z.number().int().min(1).max(1e6).optional();
91
98
  var IdToken = import_zod.z.string().max(4096).optional();
92
- var Platform = import_zod.z.enum(["web", "ios", "android"]).optional();
99
+ var Platform = import_zod.z.enum(["web", "ios", "android", "tv"]).optional();
93
100
  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
101
  var Avatar = import_zod.z.object({
95
102
  emoji: import_zod.z.string().min(1).max(16).transform((s) => s.replace(UNSAFE_CHARS, "")).refine((s) => s.length >= 1, "empty emoji"),
@@ -216,7 +223,11 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
216
223
  // trivia is 4-option (0–3)
217
224
  import_zod.z.object({ t: import_zod.z.literal("useHelp"), helpType: HelpType }),
218
225
  // 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) }),
226
+ // `player` (gs#80): WHICH member of the target team sits out — required by استريح/restPlayer and
227
+ // ignored by عرجوب/tripLevel, which targets a whole team. Optional on the wire so pre-gs#80 clients
228
+ // are unchanged; the engine refuses to spend a restPlayer card without a valid one, so an old client
229
+ // cannot burn a lifeline by omitting it.
230
+ 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
231
  // 0-based team index
221
232
  // moderation
222
233
  // Report another player in the room (App Store 1.2 UGC — display names). Available in lobby or
@@ -260,6 +271,9 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
260
271
  // Drawer passes on the served word (capped per draw-turn; relay serves a fresh one).
261
272
  import_zod.z.object({ t: import_zod.z.literal("skipWord") })
262
273
  ]);
274
+ var SUBMISSION_STATUSES = ["pending", "accepted", "rejected"];
275
+ var SUBMISSION_KINDS = ["new", "edit"];
276
+ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate", "too_narrow", "other"];
263
277
  // Annotate the CommonJS export names for ESM import in node:
264
278
  0 && (module.exports = {
265
279
  CHARACTER_IDS,
@@ -272,8 +286,13 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
272
286
  MODES,
273
287
  MODE_IDS,
274
288
  PROTOCOL_VERSION,
289
+ REJECT_REASONS,
275
290
  ROOM_ALPHABET,
291
+ SUBMISSION_KINDS,
292
+ SUBMISSION_STATUSES,
276
293
  gameIdForPv,
277
294
  gamesForPv,
278
- modesForGame
295
+ modesForGame,
296
+ normalizeWesternDigits,
297
+ sanitizeName
279
298
  });
package/dist/index.d.cts CHANGED
@@ -103,6 +103,23 @@ 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;
115
+ /**
116
+ * THE definition of "a safe player-supplied string": strip the unsafe class, normalise digits.
117
+ * Exported because it was being hand-copied — the relay's HTTP gates (account PATCH, question
118
+ * submissions) each grew their own copy of this exact body, and one of those copies stored the
119
+ * bidi characters LITERALLY in source, where any formatter or copy-paste could silently change
120
+ * what it matches. A rule that can be edited by accident is not a rule.
121
+ */
122
+ declare const sanitizeName: (s: string) => string;
106
123
  declare const HelpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
107
124
  declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
108
125
  t: z.ZodLiteral<"createRoom">;
@@ -121,7 +138,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
121
138
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
122
139
  pv: z.ZodOptional<z.ZodNumber>;
123
140
  idToken: z.ZodOptional<z.ZodString>;
124
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
141
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
125
142
  appVersion: z.ZodOptional<z.ZodString>;
126
143
  }, "strip", z.ZodTypeAny, {
127
144
  t: "createRoom";
@@ -134,7 +151,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
134
151
  character?: "m" | "f" | undefined;
135
152
  pv?: number | undefined;
136
153
  idToken?: string | undefined;
137
- platform?: "web" | "ios" | "android" | undefined;
154
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
138
155
  appVersion?: string | undefined;
139
156
  }, {
140
157
  t: "createRoom";
@@ -147,7 +164,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
147
164
  character?: "m" | "f" | undefined;
148
165
  pv?: number | undefined;
149
166
  idToken?: string | undefined;
150
- platform?: "web" | "ios" | "android" | undefined;
167
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
151
168
  appVersion?: string | undefined;
152
169
  }>, z.ZodObject<{
153
170
  t: z.ZodLiteral<"renameHost">;
@@ -205,7 +222,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
205
222
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
206
223
  pv: z.ZodOptional<z.ZodNumber>;
207
224
  idToken: z.ZodOptional<z.ZodString>;
208
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
225
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
209
226
  appVersion: z.ZodOptional<z.ZodString>;
210
227
  }, "strip", z.ZodTypeAny, {
211
228
  code: string;
@@ -218,7 +235,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
218
235
  character?: "m" | "f" | undefined;
219
236
  pv?: number | undefined;
220
237
  idToken?: string | undefined;
221
- platform?: "web" | "ios" | "android" | undefined;
238
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
222
239
  appVersion?: string | undefined;
223
240
  }, {
224
241
  code: string;
@@ -231,7 +248,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
231
248
  character?: "m" | "f" | undefined;
232
249
  pv?: number | undefined;
233
250
  idToken?: string | undefined;
234
- platform?: "web" | "ios" | "android" | undefined;
251
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
235
252
  appVersion?: string | undefined;
236
253
  }>, z.ZodObject<{
237
254
  t: z.ZodLiteral<"resume">;
@@ -250,7 +267,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
250
267
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
251
268
  pv: z.ZodOptional<z.ZodNumber>;
252
269
  idToken: z.ZodOptional<z.ZodString>;
253
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
270
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
254
271
  appVersion: z.ZodOptional<z.ZodString>;
255
272
  }, "strip", z.ZodTypeAny, {
256
273
  code: string;
@@ -263,7 +280,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
263
280
  character?: "m" | "f" | undefined;
264
281
  pv?: number | undefined;
265
282
  idToken?: string | undefined;
266
- platform?: "web" | "ios" | "android" | undefined;
283
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
267
284
  appVersion?: string | undefined;
268
285
  }, {
269
286
  code: string;
@@ -276,7 +293,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
276
293
  character?: "m" | "f" | undefined;
277
294
  pv?: number | undefined;
278
295
  idToken?: string | undefined;
279
- platform?: "web" | "ios" | "android" | undefined;
296
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
280
297
  appVersion?: string | undefined;
281
298
  }>, z.ZodObject<{
282
299
  t: z.ZodLiteral<"setPlaying">;
@@ -505,14 +522,17 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
505
522
  t: z.ZodLiteral<"sabotage">;
506
523
  helpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
507
524
  targetTeam: z.ZodNumber;
525
+ player: z.ZodOptional<z.ZodString>;
508
526
  }, "strip", z.ZodTypeAny, {
509
527
  t: "sabotage";
510
528
  helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
511
529
  targetTeam: number;
530
+ player?: string | undefined;
512
531
  }, {
513
532
  t: "sabotage";
514
533
  helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
515
534
  targetTeam: number;
535
+ player?: string | undefined;
516
536
  }>, z.ZodObject<{
517
537
  t: z.ZodLiteral<"report">;
518
538
  playerId: z.ZodString;
@@ -691,6 +711,32 @@ interface LobbyPlayer {
691
711
  avatar?: AvatarView;
692
712
  /** Team index once the game has started; null/absent in the lobby. */
693
713
  teamIndex?: number | null;
714
+ /**
715
+ * Equipped avatar-frame slug (e.g. "frame-sadu") — resolves to art via /assets/store/catalog.json
716
+ * (the catalog is the ONLY resolver; nothing about the art is derived from the slug). Read from the
717
+ * account once at room entry, so an equip change shows on the player's next join/resume. Absent =
718
+ * frameless. Earnable-class cosmetics only ever ride the room wire (D-059) — a paid exclusive can
719
+ * never appear here, so there is no receipt check in the broadcast path and no have/have-not row
720
+ * between children on one sofa. (gs#84 contract, D-060.)
721
+ */
722
+ frame?: string;
723
+ /**
724
+ * This room kicked this exact person before — a HINT for the host, never a gate (the ruling is
725
+ * "label the re-knock, no ban list"). **Present ONLY in the HOST's copy of the frame**: telling a
726
+ * whole room who was once removed is a social leak, not a moderation aid.
727
+ *
728
+ * The same bit also rides `joinRequest` for the immediate ping. This one is the DURABLE carrier —
729
+ * it is RE-READ from the answer stored on the seat (decided once, at join; never recomputed by
730
+ * re-matching, which goes stale) and re-sent on every snapshot, so it survives a host reconnect,
731
+ * where a cached one-shot event would not. A client should prefer this and treat the event as a
732
+ * nudge. MID-GAME there are no lobby frames: there a host who resumes is re-sent the pending
733
+ * `joinRequest`s instead (labels included), so the durable promise holds in both halves.
734
+ *
735
+ * Absent means "no reason to think so", which includes the deliberate miss: an anonymous player who
736
+ * force-quits and rejoins reads as a stranger. Matched only on exact socket/sign-in identity — never
737
+ * a display name or IP, because a wrong label is worse than none.
738
+ */
739
+ wasKicked?: boolean;
694
740
  }
695
741
  /**
696
742
  * Who is in the room, carried ON the game-state push (gs#71).
@@ -719,6 +765,9 @@ interface RosterEntry {
719
765
  isHost?: boolean;
720
766
  /** Is this person currently connected? Mirrors `LobbyPlayer.connected`. */
721
767
  connected?: boolean;
768
+ /** Equipped avatar-frame slug — mirrors `LobbyPlayer.frame`, because the roster is the MID-GAME
769
+ * carrier (gs#71): a cosmetic with no mid-game carrier silently vanishes for watchers and the TV. */
770
+ frame?: string;
722
771
  }
723
772
  /** Per-recipient context attached to lobby/state pushes. */
724
773
  interface YouContext {
@@ -786,10 +835,26 @@ interface TeamView {
786
835
  /** The player on THIS team whose turn it is to answer (rotates each turn); null if the team has
787
836
  * no seated players. In controller mode only this player may `answer` on the team's turn. */
788
837
  currentPlayerId: string | null;
838
+ /** استريح (gs#80): this team's player who sits out their next question, if a rival named one. */
839
+ restedPlayerId?: string | null;
789
840
  /** The seated player's privacy-safe avatar (relay-enriched; engine view omits it). */
790
841
  avatar?: AvatarView;
791
842
  }
792
843
  interface QuestionView {
844
+ /** The category this question came from (gs#94 follow-up, geem-site#54). NOT reveal-gated: the
845
+ * category is already on the board tile the player picked, so it gives nothing away — unlike the
846
+ * id, which is a handle into the bank. It exists so «اقترح سؤال مشابه» can preset the category
847
+ * instead of asking the player to re-pick it; without it a client either asks again or INFERS one,
848
+ * and inferring files somebody's question under the wrong category. Optional: pre-gs#94 servers
849
+ * omit it and clients must cope. */
850
+ categoryId?: string;
851
+ /** The question's id — present ONLY once revealed (gs#90). It exists so a player can say «عدّل
852
+ * السؤال» about a specific question: text is neither unique (template-shaped questions collide on
853
+ * wording) nor stable, so an edit identified only by its text is not reliably applicable. Kept off
854
+ * the wire while the question is LIVE for the same reason `correctAnswerIndex` is — the bank is
855
+ * server-side and an id is a handle into it. Optional on the wire: shipped clients ignore it, and
856
+ * a client that wants it must tolerate its absence pre-reveal. */
857
+ id?: string;
793
858
  text: string;
794
859
  options: string[];
795
860
  hiddenOptionIndices: number[];
@@ -797,6 +862,14 @@ interface QuestionView {
797
862
  selectedAnswerIndex: number | null;
798
863
  /** Only present once revealed (anti-cheat). Null while the question is live. */
799
864
  correctAnswerIndex: number | null;
865
+ /** «سؤال من: …» — community submitter credit (owner ruling 2026-08-14). Present ONLY on
866
+ * questions promoted from community submissions; official questions never carry it. NOT
867
+ * reveal-gated — a name leaks nothing — so place it wherever it reads best. */
868
+ submittedBy?: string;
869
+ /** «معلومة» — optional explanation shown AFTER the reveal. Null while the question is live (it
870
+ * usually names the answer — same anti-cheat gate as correctAnswerIndex) and null when the
871
+ * question simply has none, which is most of the bank: render the card only when non-null. */
872
+ explain?: string | null;
800
873
  imageName?: string;
801
874
  videoName?: string;
802
875
  /** For video questions: seconds into the clip to stop playback (so the answer isn't shown). Absent = play through. */
@@ -958,6 +1031,7 @@ type ServerMsg = {
958
1031
  t: 'joinRequest';
959
1032
  playerId: string;
960
1033
  name: string;
1034
+ wasKicked?: boolean;
961
1035
  } | {
962
1036
  t: 'joined';
963
1037
  youId: string;
@@ -983,6 +1057,7 @@ type ServerMsg = {
983
1057
  hostId: string;
984
1058
  hostName: string;
985
1059
  hostAvatar?: AvatarView;
1060
+ hostFrame?: string;
986
1061
  hostPlaying?: boolean;
987
1062
  hostPinned?: boolean;
988
1063
  players: LobbyPlayer[];
@@ -994,6 +1069,9 @@ type ServerMsg = {
994
1069
  tally?: {
995
1070
  [playerId: string]: number;
996
1071
  };
1072
+ tallyNames?: {
1073
+ [playerId: string]: string;
1074
+ };
997
1075
  modeId: ModeId | null;
998
1076
  you: YouContext;
999
1077
  } | {
@@ -1040,10 +1118,51 @@ type ServerMsg = {
1040
1118
  message: string;
1041
1119
  level?: 'info' | 'warn' | 'maintenance';
1042
1120
  restartInSec?: number;
1043
- } | {
1121
+ }
1122
+ /** `reason` (gs#83, additive): today only `'ended'`, and only on `no_room` — the code WAS a room
1123
+ * until recently (stale WhatsApp link, knock after the host closed the night). Absent = "no such
1124
+ * room as far as we know". Lets a client say «انتهى اللعب» instead of «ما في غرفة بهذا الكود»
1125
+ * without guessing. Old clients ignore it. */
1126
+ | {
1044
1127
  t: 'error';
1045
1128
  code: string;
1046
1129
  message: string;
1130
+ reason?: string;
1047
1131
  };
1132
+ /** Review states for a community submission. ⚠️ `approved` is NOT here: it is a DEPRECATED alias
1133
+ * written by an older geem-admin, mapped to `accepted` by the relay and permitted by 0031's CHECK
1134
+ * only until that write is switched. It must never become a fourth state. */
1135
+ declare const SUBMISSION_STATUSES: readonly ["pending", "accepted", "rejected"];
1136
+ type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
1137
+ /** A brand-new question vs a correction to one already in the bank. */
1138
+ declare const SUBMISSION_KINDS: readonly ["new", "edit"];
1139
+ type SubmissionKind = (typeof SUBMISSION_KINDS)[number];
1140
+ /** Why a submission was turned down. Blame-free by construction — the closest to "you did badly" is
1141
+ * `duplicate`, which is about the BANK, not the writer. Clients translate these and must fall back
1142
+ * to `other` on anything unrecognised, which is exactly what makes adding a seventh survivable for a
1143
+ * build that shipped last month and will never be updated. */
1144
+ declare const REJECT_REASONS: readonly ["duplicate", "unclear_answer", "not_family", "inaccurate", "too_narrow", "other"];
1145
+ type RejectReason = (typeof REJECT_REASONS)[number];
1146
+ /** One row of «أسئلتي». Exists as a named type so the native generator EMITS the three unions above
1147
+ * — quicktype only reaches what a root references, and hand-typed constants are the whole bug. */
1148
+ interface MyQuestionRow {
1149
+ id: string;
1150
+ status: SubmissionStatus;
1151
+ kind: SubmissionKind;
1152
+ text: string;
1153
+ options: string[];
1154
+ correctIndex: number;
1155
+ explain: string | null;
1156
+ createdAt: string;
1157
+ accepted?: {
1158
+ plays: number;
1159
+ firstPlayedAt: string | null;
1160
+ coins: number;
1161
+ };
1162
+ rejected?: {
1163
+ reason: RejectReason;
1164
+ canResubmit: boolean;
1165
+ };
1166
+ }
1048
1167
 
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 };
1168
+ 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, type MyQuestionRow, PROTOCOL_VERSION, type PackView, type QuestionView, REJECT_REASONS, ROOM_ALPHABET, type RejectReason, type RosterEntry, SUBMISSION_KINDS, SUBMISSION_STATUSES, type ServerMsg, type SoundName, type StrokePointView, type SubmissionKind, type SubmissionStatus, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame, normalizeWesternDigits, sanitizeName };
package/dist/index.d.ts CHANGED
@@ -103,6 +103,23 @@ 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;
115
+ /**
116
+ * THE definition of "a safe player-supplied string": strip the unsafe class, normalise digits.
117
+ * Exported because it was being hand-copied — the relay's HTTP gates (account PATCH, question
118
+ * submissions) each grew their own copy of this exact body, and one of those copies stored the
119
+ * bidi characters LITERALLY in source, where any formatter or copy-paste could silently change
120
+ * what it matches. A rule that can be edited by accident is not a rule.
121
+ */
122
+ declare const sanitizeName: (s: string) => string;
106
123
  declare const HelpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
107
124
  declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
108
125
  t: z.ZodLiteral<"createRoom">;
@@ -121,7 +138,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
121
138
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
122
139
  pv: z.ZodOptional<z.ZodNumber>;
123
140
  idToken: z.ZodOptional<z.ZodString>;
124
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
141
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
125
142
  appVersion: z.ZodOptional<z.ZodString>;
126
143
  }, "strip", z.ZodTypeAny, {
127
144
  t: "createRoom";
@@ -134,7 +151,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
134
151
  character?: "m" | "f" | undefined;
135
152
  pv?: number | undefined;
136
153
  idToken?: string | undefined;
137
- platform?: "web" | "ios" | "android" | undefined;
154
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
138
155
  appVersion?: string | undefined;
139
156
  }, {
140
157
  t: "createRoom";
@@ -147,7 +164,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
147
164
  character?: "m" | "f" | undefined;
148
165
  pv?: number | undefined;
149
166
  idToken?: string | undefined;
150
- platform?: "web" | "ios" | "android" | undefined;
167
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
151
168
  appVersion?: string | undefined;
152
169
  }>, z.ZodObject<{
153
170
  t: z.ZodLiteral<"renameHost">;
@@ -205,7 +222,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
205
222
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
206
223
  pv: z.ZodOptional<z.ZodNumber>;
207
224
  idToken: z.ZodOptional<z.ZodString>;
208
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
225
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
209
226
  appVersion: z.ZodOptional<z.ZodString>;
210
227
  }, "strip", z.ZodTypeAny, {
211
228
  code: string;
@@ -218,7 +235,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
218
235
  character?: "m" | "f" | undefined;
219
236
  pv?: number | undefined;
220
237
  idToken?: string | undefined;
221
- platform?: "web" | "ios" | "android" | undefined;
238
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
222
239
  appVersion?: string | undefined;
223
240
  }, {
224
241
  code: string;
@@ -231,7 +248,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
231
248
  character?: "m" | "f" | undefined;
232
249
  pv?: number | undefined;
233
250
  idToken?: string | undefined;
234
- platform?: "web" | "ios" | "android" | undefined;
251
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
235
252
  appVersion?: string | undefined;
236
253
  }>, z.ZodObject<{
237
254
  t: z.ZodLiteral<"resume">;
@@ -250,7 +267,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
250
267
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
251
268
  pv: z.ZodOptional<z.ZodNumber>;
252
269
  idToken: z.ZodOptional<z.ZodString>;
253
- platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
270
+ platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
254
271
  appVersion: z.ZodOptional<z.ZodString>;
255
272
  }, "strip", z.ZodTypeAny, {
256
273
  code: string;
@@ -263,7 +280,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
263
280
  character?: "m" | "f" | undefined;
264
281
  pv?: number | undefined;
265
282
  idToken?: string | undefined;
266
- platform?: "web" | "ios" | "android" | undefined;
283
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
267
284
  appVersion?: string | undefined;
268
285
  }, {
269
286
  code: string;
@@ -276,7 +293,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
276
293
  character?: "m" | "f" | undefined;
277
294
  pv?: number | undefined;
278
295
  idToken?: string | undefined;
279
- platform?: "web" | "ios" | "android" | undefined;
296
+ platform?: "web" | "ios" | "android" | "tv" | undefined;
280
297
  appVersion?: string | undefined;
281
298
  }>, z.ZodObject<{
282
299
  t: z.ZodLiteral<"setPlaying">;
@@ -505,14 +522,17 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
505
522
  t: z.ZodLiteral<"sabotage">;
506
523
  helpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
507
524
  targetTeam: z.ZodNumber;
525
+ player: z.ZodOptional<z.ZodString>;
508
526
  }, "strip", z.ZodTypeAny, {
509
527
  t: "sabotage";
510
528
  helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
511
529
  targetTeam: number;
530
+ player?: string | undefined;
512
531
  }, {
513
532
  t: "sabotage";
514
533
  helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
515
534
  targetTeam: number;
535
+ player?: string | undefined;
516
536
  }>, z.ZodObject<{
517
537
  t: z.ZodLiteral<"report">;
518
538
  playerId: z.ZodString;
@@ -691,6 +711,32 @@ interface LobbyPlayer {
691
711
  avatar?: AvatarView;
692
712
  /** Team index once the game has started; null/absent in the lobby. */
693
713
  teamIndex?: number | null;
714
+ /**
715
+ * Equipped avatar-frame slug (e.g. "frame-sadu") — resolves to art via /assets/store/catalog.json
716
+ * (the catalog is the ONLY resolver; nothing about the art is derived from the slug). Read from the
717
+ * account once at room entry, so an equip change shows on the player's next join/resume. Absent =
718
+ * frameless. Earnable-class cosmetics only ever ride the room wire (D-059) — a paid exclusive can
719
+ * never appear here, so there is no receipt check in the broadcast path and no have/have-not row
720
+ * between children on one sofa. (gs#84 contract, D-060.)
721
+ */
722
+ frame?: string;
723
+ /**
724
+ * This room kicked this exact person before — a HINT for the host, never a gate (the ruling is
725
+ * "label the re-knock, no ban list"). **Present ONLY in the HOST's copy of the frame**: telling a
726
+ * whole room who was once removed is a social leak, not a moderation aid.
727
+ *
728
+ * The same bit also rides `joinRequest` for the immediate ping. This one is the DURABLE carrier —
729
+ * it is RE-READ from the answer stored on the seat (decided once, at join; never recomputed by
730
+ * re-matching, which goes stale) and re-sent on every snapshot, so it survives a host reconnect,
731
+ * where a cached one-shot event would not. A client should prefer this and treat the event as a
732
+ * nudge. MID-GAME there are no lobby frames: there a host who resumes is re-sent the pending
733
+ * `joinRequest`s instead (labels included), so the durable promise holds in both halves.
734
+ *
735
+ * Absent means "no reason to think so", which includes the deliberate miss: an anonymous player who
736
+ * force-quits and rejoins reads as a stranger. Matched only on exact socket/sign-in identity — never
737
+ * a display name or IP, because a wrong label is worse than none.
738
+ */
739
+ wasKicked?: boolean;
694
740
  }
695
741
  /**
696
742
  * Who is in the room, carried ON the game-state push (gs#71).
@@ -719,6 +765,9 @@ interface RosterEntry {
719
765
  isHost?: boolean;
720
766
  /** Is this person currently connected? Mirrors `LobbyPlayer.connected`. */
721
767
  connected?: boolean;
768
+ /** Equipped avatar-frame slug — mirrors `LobbyPlayer.frame`, because the roster is the MID-GAME
769
+ * carrier (gs#71): a cosmetic with no mid-game carrier silently vanishes for watchers and the TV. */
770
+ frame?: string;
722
771
  }
723
772
  /** Per-recipient context attached to lobby/state pushes. */
724
773
  interface YouContext {
@@ -786,10 +835,26 @@ interface TeamView {
786
835
  /** The player on THIS team whose turn it is to answer (rotates each turn); null if the team has
787
836
  * no seated players. In controller mode only this player may `answer` on the team's turn. */
788
837
  currentPlayerId: string | null;
838
+ /** استريح (gs#80): this team's player who sits out their next question, if a rival named one. */
839
+ restedPlayerId?: string | null;
789
840
  /** The seated player's privacy-safe avatar (relay-enriched; engine view omits it). */
790
841
  avatar?: AvatarView;
791
842
  }
792
843
  interface QuestionView {
844
+ /** The category this question came from (gs#94 follow-up, geem-site#54). NOT reveal-gated: the
845
+ * category is already on the board tile the player picked, so it gives nothing away — unlike the
846
+ * id, which is a handle into the bank. It exists so «اقترح سؤال مشابه» can preset the category
847
+ * instead of asking the player to re-pick it; without it a client either asks again or INFERS one,
848
+ * and inferring files somebody's question under the wrong category. Optional: pre-gs#94 servers
849
+ * omit it and clients must cope. */
850
+ categoryId?: string;
851
+ /** The question's id — present ONLY once revealed (gs#90). It exists so a player can say «عدّل
852
+ * السؤال» about a specific question: text is neither unique (template-shaped questions collide on
853
+ * wording) nor stable, so an edit identified only by its text is not reliably applicable. Kept off
854
+ * the wire while the question is LIVE for the same reason `correctAnswerIndex` is — the bank is
855
+ * server-side and an id is a handle into it. Optional on the wire: shipped clients ignore it, and
856
+ * a client that wants it must tolerate its absence pre-reveal. */
857
+ id?: string;
793
858
  text: string;
794
859
  options: string[];
795
860
  hiddenOptionIndices: number[];
@@ -797,6 +862,14 @@ interface QuestionView {
797
862
  selectedAnswerIndex: number | null;
798
863
  /** Only present once revealed (anti-cheat). Null while the question is live. */
799
864
  correctAnswerIndex: number | null;
865
+ /** «سؤال من: …» — community submitter credit (owner ruling 2026-08-14). Present ONLY on
866
+ * questions promoted from community submissions; official questions never carry it. NOT
867
+ * reveal-gated — a name leaks nothing — so place it wherever it reads best. */
868
+ submittedBy?: string;
869
+ /** «معلومة» — optional explanation shown AFTER the reveal. Null while the question is live (it
870
+ * usually names the answer — same anti-cheat gate as correctAnswerIndex) and null when the
871
+ * question simply has none, which is most of the bank: render the card only when non-null. */
872
+ explain?: string | null;
800
873
  imageName?: string;
801
874
  videoName?: string;
802
875
  /** For video questions: seconds into the clip to stop playback (so the answer isn't shown). Absent = play through. */
@@ -958,6 +1031,7 @@ type ServerMsg = {
958
1031
  t: 'joinRequest';
959
1032
  playerId: string;
960
1033
  name: string;
1034
+ wasKicked?: boolean;
961
1035
  } | {
962
1036
  t: 'joined';
963
1037
  youId: string;
@@ -983,6 +1057,7 @@ type ServerMsg = {
983
1057
  hostId: string;
984
1058
  hostName: string;
985
1059
  hostAvatar?: AvatarView;
1060
+ hostFrame?: string;
986
1061
  hostPlaying?: boolean;
987
1062
  hostPinned?: boolean;
988
1063
  players: LobbyPlayer[];
@@ -994,6 +1069,9 @@ type ServerMsg = {
994
1069
  tally?: {
995
1070
  [playerId: string]: number;
996
1071
  };
1072
+ tallyNames?: {
1073
+ [playerId: string]: string;
1074
+ };
997
1075
  modeId: ModeId | null;
998
1076
  you: YouContext;
999
1077
  } | {
@@ -1040,10 +1118,51 @@ type ServerMsg = {
1040
1118
  message: string;
1041
1119
  level?: 'info' | 'warn' | 'maintenance';
1042
1120
  restartInSec?: number;
1043
- } | {
1121
+ }
1122
+ /** `reason` (gs#83, additive): today only `'ended'`, and only on `no_room` — the code WAS a room
1123
+ * until recently (stale WhatsApp link, knock after the host closed the night). Absent = "no such
1124
+ * room as far as we know". Lets a client say «انتهى اللعب» instead of «ما في غرفة بهذا الكود»
1125
+ * without guessing. Old clients ignore it. */
1126
+ | {
1044
1127
  t: 'error';
1045
1128
  code: string;
1046
1129
  message: string;
1130
+ reason?: string;
1047
1131
  };
1132
+ /** Review states for a community submission. ⚠️ `approved` is NOT here: it is a DEPRECATED alias
1133
+ * written by an older geem-admin, mapped to `accepted` by the relay and permitted by 0031's CHECK
1134
+ * only until that write is switched. It must never become a fourth state. */
1135
+ declare const SUBMISSION_STATUSES: readonly ["pending", "accepted", "rejected"];
1136
+ type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
1137
+ /** A brand-new question vs a correction to one already in the bank. */
1138
+ declare const SUBMISSION_KINDS: readonly ["new", "edit"];
1139
+ type SubmissionKind = (typeof SUBMISSION_KINDS)[number];
1140
+ /** Why a submission was turned down. Blame-free by construction — the closest to "you did badly" is
1141
+ * `duplicate`, which is about the BANK, not the writer. Clients translate these and must fall back
1142
+ * to `other` on anything unrecognised, which is exactly what makes adding a seventh survivable for a
1143
+ * build that shipped last month and will never be updated. */
1144
+ declare const REJECT_REASONS: readonly ["duplicate", "unclear_answer", "not_family", "inaccurate", "too_narrow", "other"];
1145
+ type RejectReason = (typeof REJECT_REASONS)[number];
1146
+ /** One row of «أسئلتي». Exists as a named type so the native generator EMITS the three unions above
1147
+ * — quicktype only reaches what a root references, and hand-typed constants are the whole bug. */
1148
+ interface MyQuestionRow {
1149
+ id: string;
1150
+ status: SubmissionStatus;
1151
+ kind: SubmissionKind;
1152
+ text: string;
1153
+ options: string[];
1154
+ correctIndex: number;
1155
+ explain: string | null;
1156
+ createdAt: string;
1157
+ accepted?: {
1158
+ plays: number;
1159
+ firstPlayedAt: string | null;
1160
+ coins: number;
1161
+ };
1162
+ rejected?: {
1163
+ reason: RejectReason;
1164
+ canResubmit: boolean;
1165
+ };
1166
+ }
1048
1167
 
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 };
1168
+ 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, type MyQuestionRow, PROTOCOL_VERSION, type PackView, type QuestionView, REJECT_REASONS, ROOM_ALPHABET, type RejectReason, type RosterEntry, SUBMISSION_KINDS, SUBMISSION_STATUSES, type ServerMsg, type SoundName, type StrokePointView, type SubmissionKind, type SubmissionStatus, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame, normalizeWesternDigits, sanitizeName };
package/dist/index.js CHANGED
@@ -38,7 +38,9 @@ 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 sanitizeName = (s) => normalizeWesternDigits(s.replace(UNSAFE_CHARS, "")).trim();
43
+ var DisplayName = z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform(sanitizeName).refine((s) => s.length >= 1, "name is empty after sanitizing");
42
44
  var RoomCode = z.string().trim().toUpperCase().regex(new RegExp(`^[${ROOM_ALPHABET}]{${LIMITS.ROOM_CODE_LEN}}$`), "bad room code");
43
45
  var PlayerId = z.string().max(40);
44
46
  var HelpType = z.enum([
@@ -52,7 +54,7 @@ var HelpType = z.enum([
52
54
  ]);
53
55
  var Pv = z.number().int().min(1).max(1e6).optional();
54
56
  var IdToken = z.string().max(4096).optional();
55
- var Platform = z.enum(["web", "ios", "android"]).optional();
57
+ var Platform = z.enum(["web", "ios", "android", "tv"]).optional();
56
58
  var AppVersion = z.string().trim().max(24).regex(/^[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]+)?$/, "bad app version").optional();
57
59
  var Avatar = z.object({
58
60
  emoji: z.string().min(1).max(16).transform((s) => s.replace(UNSAFE_CHARS, "")).refine((s) => s.length >= 1, "empty emoji"),
@@ -179,7 +181,11 @@ var ClientMsg = z.discriminatedUnion("t", [
179
181
  // trivia is 4-option (0–3)
180
182
  z.object({ t: z.literal("useHelp"), helpType: HelpType }),
181
183
  // 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) }),
184
+ // `player` (gs#80): WHICH member of the target team sits out — required by استريح/restPlayer and
185
+ // ignored by عرجوب/tripLevel, which targets a whole team. Optional on the wire so pre-gs#80 clients
186
+ // are unchanged; the engine refuses to spend a restPlayer card without a valid one, so an old client
187
+ // cannot burn a lifeline by omitting it.
188
+ 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
189
  // 0-based team index
184
190
  // moderation
185
191
  // Report another player in the room (App Store 1.2 UGC — display names). Available in lobby or
@@ -223,6 +229,9 @@ var ClientMsg = z.discriminatedUnion("t", [
223
229
  // Drawer passes on the served word (capped per draw-turn; relay serves a fresh one).
224
230
  z.object({ t: z.literal("skipWord") })
225
231
  ]);
232
+ var SUBMISSION_STATUSES = ["pending", "accepted", "rejected"];
233
+ var SUBMISSION_KINDS = ["new", "edit"];
234
+ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate", "too_narrow", "other"];
226
235
  export {
227
236
  CHARACTER_IDS,
228
237
  ClientMsg,
@@ -234,8 +243,13 @@ export {
234
243
  MODES,
235
244
  MODE_IDS,
236
245
  PROTOCOL_VERSION,
246
+ REJECT_REASONS,
237
247
  ROOM_ALPHABET,
248
+ SUBMISSION_KINDS,
249
+ SUBMISSION_STATUSES,
238
250
  gameIdForPv,
239
251
  gamesForPv,
240
- modesForGame
252
+ modesForGame,
253
+ normalizeWesternDigits,
254
+ sanitizeName
241
255
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegeem/protocol",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
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",