@thegeem/protocol 0.1.38 → 0.1.41

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.d.ts CHANGED
@@ -36,7 +36,7 @@ declare const LIMITS: {
36
36
  * `seedha`/`snag`). Raise the floor SLOWLY to retire ancient builds. Unversioned clients (no `pv`,
37
37
  * e.g. the legacy web harness) are treated as the floor and never rejected.
38
38
  */
39
- declare const PROTOCOL_VERSION = 4;
39
+ declare const PROTOCOL_VERSION = 7;
40
40
  declare const MIN_SUPPORTED_PV = 1;
41
41
  /**
42
42
  * Geem is a PLATFORM of GAMES (jackbox-style). The trivia board split (D-023) into TWO player-facing
@@ -45,7 +45,7 @@ declare const MIN_SUPPORTED_PV = 1;
45
45
  * pre-pv2 clients (they never saw seedha/snag), and what old clients send on startGame. Every room
46
46
  * runs one game, identified by `gameId`.
47
47
  */
48
- declare const GAME_IDS: readonly ["seedha", "snag", "trivia", "dama", "ersimha"];
48
+ declare const GAME_IDS: readonly ["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"];
49
49
  type GameId = (typeof GAME_IDS)[number];
50
50
  /**
51
51
  * The GAME REGISTRY — player-facing games + the version gating (D-024/D-025). A client renders a tile
@@ -60,6 +60,56 @@ interface GameInfo {
60
60
  }
61
61
  declare const GAMES: readonly GameInfo[];
62
62
  /** Games to OFFER a client of version `pv` (tiles). Excludes the deprecated alias + anything too new. */
63
+ /**
64
+ * ⚠️ THE OUTBOUND ADAPTER FOR `hostActivity`, AND IT PREVENTS A FROZEN LOBBY — not a wrong banner.
65
+ *
66
+ * The generated Swift type is `enum Activity: String, Codable` — a STRICT enum. A build that predates
67
+ * `options` has only `configuring` and `idle` in it, so receiving `"options"` makes the decode THROW,
68
+ * and because `hostActivity` sits inside the lobby struct **the entire lobby frame is dropped**. Every
69
+ * old joiner in the room stops updating: no new players, no team changes, no category picks. Nothing
70
+ * errors on either side.
71
+ *
72
+ * That is not a hypothetical — it is the same «one unknown key drops the whole frame» failure the iOS
73
+ * desk described for `SebaqStateView`, arriving through an enum instead of a missing field.
74
+ *
75
+ * ⚠️ SO IT IS DOWN-MAPPED PER RECIPIENT, and the alternative was worse: without this, the Options page
76
+ * could not ship until an App Store release carrying a lenient decoder had reached every player —
77
+ * and frozen app-store builds never fully do. This is [[D-025]]'s adapt-within-a-window applied to a
78
+ * second field: the server speaks each client's dialect on the way out.
79
+ *
80
+ * `configuring` and not `idle`, deliberately: the host IS busy setting up. An old joiner reads
81
+ * «… يختار الفئات» a step early, which is slightly wrong and completely harmless — where `idle` would
82
+ * say «nothing is happening» while the host configures, which is the wrong sentence.
83
+ */
84
+ declare const hostActivityForPv: (activity: "options" | "configuring" | "idle", pv?: number) => "options" | "configuring" | "idle";
85
+ /**
86
+ * The pv at which a client can decode the `comebackBonus` effect. Below this it is WITHHELD, never
87
+ * down-mapped: there is nothing older to translate it into, and the alternative is worse than useless.
88
+ *
89
+ * ⚠️ THE FAILURE IT PREVENTS IS NOT «the card does not show». quicktype emits `Effect` as a strict
90
+ * Swift enum, so a build that predates this variant throws on decode — and because effects travel as
91
+ * an ARRAY inside one `fx` frame, the whole frame is dropped. Sounds, reveals, celebrations and score
92
+ * pops in the same batch die with it, silently, for the rest of that game. **One new variant can stop
93
+ * an old client updating at all.**
94
+ *
95
+ * So an old build gets the +100 (the score is in the state it already renders) and no card. That is
96
+ * the honest degradation: the game stays correct, the animation is missing.
97
+ */
98
+ declare const COMEBACK_BONUS_MIN_PV = 7;
99
+ /**
100
+ * The effects safe to send a client of version `pv`.
101
+ *
102
+ * ⚠️ WITHHOLDING IS THE ONLY OPTION FOR AN EFFECT, and it is why this is a filter rather than a
103
+ * mapper like `hostActivityForPv`. An unknown `hostActivity` string could be down-mapped to an older
104
+ * word that means nearly the same thing; an unknown effect VARIANT has no older equivalent, and
105
+ * inventing one (sending `scorePop` instead) would make the client animate a normal score change for
106
+ * an event that is not one.
107
+ */
108
+ /** Every comeback effect, as ONE list. ⚠️ Exported because the relay's `CLIENT_FX` allow-list must be
109
+ * built from it rather than re-typed: I re-typed it, listed one of the three, and shipped a feature
110
+ * whose refusal rendered as silence on prod. A hand-list that fails by OMISSION errors nowhere. */
111
+ declare const COMEBACK_EFFECTS: readonly ["comebackOffer", "comebackBonus", "comebackDeclined"];
112
+ declare const effectsForPv: (effects: Effect[], pv: number | undefined) => Effect[];
63
113
  declare const gamesForPv: (pv: number) => GameInfo[];
64
114
  /** The gameId to SEND a client of version `pv`: new games need pv >= their minPv; older clients see 'trivia'. */
65
115
  declare const gameIdForPv: (gameId: GameId, pv: number | undefined) => GameId;
@@ -134,11 +184,12 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
134
184
  emoji: string;
135
185
  color: string;
136
186
  }>>;
137
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
187
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
138
188
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
139
189
  pv: z.ZodOptional<z.ZodNumber>;
140
190
  idToken: z.ZodOptional<z.ZodString>;
141
191
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
192
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
142
193
  appVersion: z.ZodOptional<z.ZodString>;
143
194
  }, "strip", z.ZodTypeAny, {
144
195
  t: "createRoom";
@@ -147,11 +198,12 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
147
198
  emoji: string;
148
199
  color: string;
149
200
  } | undefined;
150
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
201
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
151
202
  character?: "m" | "f" | undefined;
152
203
  pv?: number | undefined;
153
204
  idToken?: string | undefined;
154
205
  platform?: "web" | "ios" | "android" | "tv" | undefined;
206
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
155
207
  appVersion?: string | undefined;
156
208
  }, {
157
209
  t: "createRoom";
@@ -160,11 +212,12 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
160
212
  emoji: string;
161
213
  color: string;
162
214
  } | undefined;
163
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
215
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
164
216
  character?: "m" | "f" | undefined;
165
217
  pv?: number | undefined;
166
218
  idToken?: string | undefined;
167
219
  platform?: "web" | "ios" | "android" | "tv" | undefined;
220
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
168
221
  appVersion?: string | undefined;
169
222
  }>, z.ZodObject<{
170
223
  t: z.ZodLiteral<"renameHost">;
@@ -223,6 +276,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
223
276
  pv: z.ZodOptional<z.ZodNumber>;
224
277
  idToken: z.ZodOptional<z.ZodString>;
225
278
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
279
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
226
280
  appVersion: z.ZodOptional<z.ZodString>;
227
281
  }, "strip", z.ZodTypeAny, {
228
282
  code: string;
@@ -236,6 +290,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
236
290
  pv?: number | undefined;
237
291
  idToken?: string | undefined;
238
292
  platform?: "web" | "ios" | "android" | "tv" | undefined;
293
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
239
294
  appVersion?: string | undefined;
240
295
  }, {
241
296
  code: string;
@@ -249,6 +304,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
249
304
  pv?: number | undefined;
250
305
  idToken?: string | undefined;
251
306
  platform?: "web" | "ios" | "android" | "tv" | undefined;
307
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
252
308
  appVersion?: string | undefined;
253
309
  }>, z.ZodObject<{
254
310
  t: z.ZodLiteral<"resume">;
@@ -268,6 +324,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
268
324
  pv: z.ZodOptional<z.ZodNumber>;
269
325
  idToken: z.ZodOptional<z.ZodString>;
270
326
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
327
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
271
328
  appVersion: z.ZodOptional<z.ZodString>;
272
329
  }, "strip", z.ZodTypeAny, {
273
330
  code: string;
@@ -281,6 +338,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
281
338
  pv?: number | undefined;
282
339
  idToken?: string | undefined;
283
340
  platform?: "web" | "ios" | "android" | "tv" | undefined;
341
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
284
342
  appVersion?: string | undefined;
285
343
  }, {
286
344
  code: string;
@@ -294,6 +352,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
294
352
  pv?: number | undefined;
295
353
  idToken?: string | undefined;
296
354
  platform?: "web" | "ios" | "android" | "tv" | undefined;
355
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
297
356
  appVersion?: string | undefined;
298
357
  }>, z.ZodObject<{
299
358
  t: z.ZodLiteral<"setPlaying">;
@@ -340,6 +399,15 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
340
399
  }, {
341
400
  t: "kick";
342
401
  playerId: string;
402
+ }>, z.ZodObject<{
403
+ t: z.ZodLiteral<"comebackDecide">;
404
+ grant: z.ZodBoolean;
405
+ }, "strip", z.ZodTypeAny, {
406
+ t: "comebackDecide";
407
+ grant: boolean;
408
+ }, {
409
+ t: "comebackDecide";
410
+ grant: boolean;
343
411
  }>, z.ZodObject<{
344
412
  t: z.ZodLiteral<"setCategories">;
345
413
  categoryIds: z.ZodArray<z.ZodString, "many">;
@@ -367,30 +435,51 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
367
435
  }, {
368
436
  t: "pickTeam";
369
437
  teamIndex: number;
438
+ }>, z.ZodObject<{
439
+ t: z.ZodLiteral<"sebaqAnswer">;
440
+ optionIndex: z.ZodNumber;
441
+ }, "strip", z.ZodTypeAny, {
442
+ t: "sebaqAnswer";
443
+ optionIndex: number;
444
+ }, {
445
+ t: "sebaqAnswer";
446
+ optionIndex: number;
447
+ }>, z.ZodObject<{
448
+ t: z.ZodLiteral<"sebaqRetry">;
449
+ }, "strip", z.ZodTypeAny, {
450
+ t: "sebaqRetry";
451
+ }, {
452
+ t: "sebaqRetry";
453
+ }>, z.ZodObject<{
454
+ t: z.ZodLiteral<"sebaqSkip">;
455
+ }, "strip", z.ZodTypeAny, {
456
+ t: "sebaqSkip";
457
+ }, {
458
+ t: "sebaqSkip";
370
459
  }>, z.ZodObject<{
371
460
  t: z.ZodLiteral<"setMode">;
372
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
461
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
373
462
  modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
374
463
  }, "strip", z.ZodTypeAny, {
375
464
  t: "setMode";
376
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
465
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
377
466
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
378
467
  }, {
379
468
  t: "setMode";
380
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
469
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
381
470
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
382
471
  }>, z.ZodObject<{
383
472
  t: z.ZodLiteral<"hostActivity">;
384
- activity: z.ZodEnum<["configuring", "idle"]>;
473
+ activity: z.ZodEnum<["options", "configuring", "idle"]>;
385
474
  }, "strip", z.ZodTypeAny, {
386
475
  t: "hostActivity";
387
- activity: "configuring" | "idle";
476
+ activity: "options" | "configuring" | "idle";
388
477
  }, {
389
478
  t: "hostActivity";
390
- activity: "configuring" | "idle";
479
+ activity: "options" | "configuring" | "idle";
391
480
  }>, z.ZodObject<{
392
481
  t: z.ZodLiteral<"startGame">;
393
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
482
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
394
483
  totalRounds: z.ZodNumber;
395
484
  categoryIds: z.ZodArray<z.ZodString, "many">;
396
485
  hostPlays: z.ZodOptional<z.ZodBoolean>;
@@ -409,7 +498,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
409
498
  t: "startGame";
410
499
  categoryIds: string[];
411
500
  totalRounds: number;
412
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
501
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
413
502
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
414
503
  hostPlays?: boolean | undefined;
415
504
  players?: string[] | undefined;
@@ -422,7 +511,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
422
511
  t: "startGame";
423
512
  categoryIds: string[];
424
513
  totalRounds: number;
425
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
514
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
426
515
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
427
516
  hostPlays?: boolean | undefined;
428
517
  players?: string[] | undefined;
@@ -901,6 +990,26 @@ interface GameStateView {
901
990
  * paused (e.g. steal intro), or after time-up. Present ⇒ animate a countdown from
902
991
  * it; the server remains the authority on actual time-up. */
903
992
  timerRemainingMs?: number;
993
+ /**
994
+ * A comeback bonus awaiting the host's verdict (gs#123), or ABSENT when there is none.
995
+ *
996
+ * ⚠️ IT IS STATE, NOT ONLY AN EFFECT, AND THE DIFFERENCE IS A WEDGE. `comebackOffer` arrives as an
997
+ * `fx` — and `fx` is transient and never replayed. So a host who reconnects between the offer and
998
+ * their verdict would come back to a state with no mention of it: no card, no buttons, and no way
999
+ * to resolve. The server would still hold the pending offer, and a pending offer BLOCKS the next
1000
+ * one, so the feature would be permanently dead for the rest of that game with nothing in any log.
1001
+ *
1002
+ * Found by the iOS desk asking «a resume snapshot after the accept carries the new score only,
1003
+ * yes?» — a question about the happy path, whose answer exposed the unhappy one.
1004
+ *
1005
+ * ABSENT rather than null when there is no offer: the card is either on screen or it is not, and an
1006
+ * optional key is the shape a client already has to handle for `timerRemainingMs`.
1007
+ */
1008
+ comebackOffer?: {
1009
+ teamId: string;
1010
+ questionText: string;
1011
+ scoreGapBeforeBonus: number;
1012
+ };
904
1013
  }
905
1014
  /** Transient presentation effects the relay emits; clients play them once. */
906
1015
  type Effect = {
@@ -931,6 +1040,21 @@ type Effect = {
931
1040
  t: 'scorePop';
932
1041
  amount: number;
933
1042
  streakBonus: number;
1043
+ } | {
1044
+ t: 'comebackBonus';
1045
+ teamId: string;
1046
+ points: number;
1047
+ previousScore: number;
1048
+ newScore: number;
1049
+ scoreGapBeforeBonus: number;
1050
+ } | {
1051
+ t: 'comebackOffer';
1052
+ teamId: string;
1053
+ questionText: string;
1054
+ scoreGapBeforeBonus: number;
1055
+ } | {
1056
+ t: 'comebackDeclined';
1057
+ teamId: string;
934
1058
  } | {
935
1059
  t: 'chargeCredit';
936
1060
  } | {
@@ -989,6 +1113,73 @@ interface DamaStateView {
989
1113
  moveCount: number;
990
1114
  black?: string;
991
1115
  white?: string;
1116
+ /**
1117
+ * When the side to move runs out of time, as absolute epoch ms — render it as a countdown.
1118
+ *
1119
+ * دامة is strictly 1v1, so ONE absent player stops the board completely: every other engine bounded
1120
+ * its equivalent and this one did not, which left a stalled game sitting until the 30-minute room
1121
+ * reaper. On expiry the game ends with NO winner and NO rating change — the same ruling as a
1122
+ * mid-game kick (2026-08-08): counting an abandonment as a win would let someone farm ELO by
1123
+ * walking away from a losing board.
1124
+ *
1125
+ * ADDITIVE and optional: absent while a bot is to move, after the game is over, and from any older
1126
+ * relay. A client that ignores it is correct, just less kind — it simply sees the game end.
1127
+ */
1128
+ moveDeadlineMs?: number;
1129
+ }
1130
+ /**
1131
+ * سباق — the race. Everyone runs the SAME questions, in the SAME order, privately, at their own speed.
1132
+ *
1133
+ * ⚠️ WHY THIS IS A RELAY GAME AND NOT A LOCAL ONE, in one sentence from the iOS desk: **timing cannot
1134
+ * be client-reported or the player with the best connection wins.** Every advance is stamped by the
1135
+ * server, the question set is dealt by the server, and a client never asks for a question — that last
1136
+ * one is how two players would otherwise end up racing different sets.
1137
+ *
1138
+ * The rules, all the owner's:
1139
+ * 1. same questions, same order, for every player — it is what makes «who finished first» mean anything
1140
+ * 2. right on the first tap → FULL points
1141
+ * 3. wrong → the player CHOOSES: try again for HALF, or move on. The cost of a retry is TIME, which
1142
+ * is why it needs no extra penalty and why the choice changes depending on whether you are ahead
1143
+ * 4. wrong twice → 0 for that question, next question. ⚠️ TWO TIERS ONLY — full → half → gone is
1144
+ * something a child can hold; a third tier turns it into arithmetic. Do not add one.
1145
+ * 5. finishing faster → BONUS points
1146
+ * 6. 🔴 HIGHEST SCORE WINS. SPEED ADDS, IT NEVER OVERRIDES. A player who mashes through in 30
1147
+ * seconds finishes first and still loses — that is the intended outcome and it is load-bearing.
1148
+ * 7. live standings visible during play
1149
+ * 8. the host races too
1150
+ *
1151
+ * ⚠️ QUESTION COUNT IS NOT FIXED: it is the selected categories × 6 levels (owner, 2026-08-25 —
1152
+ * «Questions are as players selected categories»). «3 categories» was his example, not the rule.
1153
+ */
1154
+ interface SebaqStandingRow {
1155
+ playerId: string;
1156
+ /** How many questions this player has FINISHED (not the one they are on). */
1157
+ done: number;
1158
+ score: number;
1159
+ /** Set only when they have finished the whole set — the bonus is not knowable before then. */
1160
+ speedBonus?: number;
1161
+ finished: boolean;
1162
+ }
1163
+ interface SebaqStateView {
1164
+ /** Total questions in this race = categories × 6. Every player runs all of them. */
1165
+ total: number;
1166
+ /** Live standings for everyone, always visible (rule 7). Ordered by the server, best first. */
1167
+ standings: SebaqStandingRow[];
1168
+ /** Set when every racer has finished. Ties on total score break on base score, then on time. */
1169
+ winnerId?: string | null;
1170
+ /** ⚠️ YOUR question only, and only the one you are ON. Never another player's, never the next one —
1171
+ * a client that could see ahead could pre-read while others cannot, which is the whole race. */
1172
+ you?: {
1173
+ index: number;
1174
+ /** ⚠️ The SAME anti-cheat boundary as trivia's: `correctAnswerIndex` is null while the question is
1175
+ * live and set only on reveal. In a race the temptation is stronger — every player holds a live
1176
+ * question at the same moment, so a leaked answer index is a leak to everyone at once. */
1177
+ question: QuestionView;
1178
+ /** true once you have answered wrong ONCE on this question: the half-or-skip choice is live. */
1179
+ retryOffered: boolean;
1180
+ score: number;
1181
+ finished: boolean;
1182
+ };
992
1183
  }
993
1184
  /**
994
1185
  * The authoritative Ersimha round state. Anti-cheat boundary (D-030): `word` is set ONLY in the
@@ -1065,7 +1256,7 @@ type ServerMsg = {
1065
1256
  packs?: PackView[];
1066
1257
  selectedCategoryIds: string[];
1067
1258
  teams: TeamSlotView[];
1068
- hostActivity: 'configuring' | 'idle';
1259
+ hostActivity: 'options' | 'configuring' | 'idle';
1069
1260
  tally?: {
1070
1261
  [playerId: string]: number;
1071
1262
  };
@@ -1087,6 +1278,12 @@ type ServerMsg = {
1087
1278
  view: DamaStateView;
1088
1279
  you: YouContext;
1089
1280
  roster?: RosterEntry[];
1281
+ } | {
1282
+ t: 'sebaqState';
1283
+ gameId: 'sebaq';
1284
+ view: SebaqStateView;
1285
+ you: YouContext;
1286
+ roster?: RosterEntry[];
1090
1287
  } | {
1091
1288
  t: 'ersimhaState';
1092
1289
  gameId: 'ersimha';
@@ -1165,4 +1362,4 @@ interface MyQuestionRow {
1165
1362
  };
1166
1363
  }
1167
1364
 
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 };
1365
+ export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, COMEBACK_BONUS_MIN_PV, COMEBACK_EFFECTS, 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 SebaqStandingRow, type SebaqStateView, type ServerMsg, type SoundName, type StrokePointView, type SubmissionKind, type SubmissionStatus, type TeamSlotView, type TeamView, type YouContext, effectsForPv, gameIdForPv, gamesForPv, hostActivityForPv, modesForGame, normalizeWesternDigits, sanitizeName };
package/dist/index.js CHANGED
@@ -9,9 +9,9 @@ var LIMITS = {
9
9
  * Clients batch pen points to stay under this — ~150ms batching lands at ~7/sec. */
10
10
  STROKE_BATCH_PER_SEC: 20
11
11
  };
12
- var PROTOCOL_VERSION = 4;
12
+ var PROTOCOL_VERSION = 7;
13
13
  var MIN_SUPPORTED_PV = 1;
14
- var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha"];
14
+ var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"];
15
15
  var GAMES = [
16
16
  { id: "seedha", label: "Seedha", minPv: 2 },
17
17
  { id: "snag", label: "Snag", minPv: 2 },
@@ -19,8 +19,31 @@ var GAMES = [
19
19
  // engine #2 (D-027) — offered only to pv≥3 clients
20
20
  { id: "ersimha", label: "Ersimha", minPv: 4 },
21
21
  // engine #3 (D-030) draw-and-guess — offered only to pv≥4 clients
22
+ // ⚠️ A NEW GAME, NOT A MODE OF SNAG, AND THE NAME COLLISION IS WHY IT HAD TO BE. `buzzerMode: 'race'`
23
+ // ALREADY MEANS ديوانية's buzz-race — everyone racing to buzz for ONE shared question. Sebaq is the
24
+ // opposite: no shared question, no buzzer, no turns. Every player runs the same set privately, at
25
+ // their own speed. Putting a mode called `race` beside a buzzerMode called `race`, meaning opposite
26
+ // things, one string apart on the wire, is the worst naming available here.
27
+ //
28
+ // It is also the wrong SHAPE for a mode: every MODES row is a way of playing seedha's turn loop —
29
+ // one question, one board, a shared clock. Sebaq uses none of it. A mode that shares no mechanic
30
+ // with its game is a game.
31
+ //
32
+ // ✅ NAME RULED BY THE OWNER, 2026-08-25: «سباق sebaq». Renamed from the provisional `sabag` before
33
+ // any client had shipped the string — which was the entire reason for saying it was provisional.
34
+ //
35
+ // ⚠️ `q` IS ق, AND IT IS NOT AN EXCEPTION TO THE BRAND RULE — I filed it as one and the owner
36
+ // corrected me. `CLAUDE.md`'s «Najdi ق = g» is about how ق SOUNDS in قييم; `q` is what the LETTER
37
+ // is. Two different questions, and `sebaq` answers the second. **Do not "fix" this to `sebag`.**
38
+ { id: "sebaq", label: "Sebaq", minPv: 5 },
39
+ // سباق — the race tile, offered only to pv≥5 clients
22
40
  { id: "trivia", label: "Trivia", minPv: 1, deprecated: true }
23
41
  ];
42
+ var hostActivityForPv = (activity, pv) => activity === "options" && (pv ?? MIN_SUPPORTED_PV) < 6 ? "configuring" : activity;
43
+ var COMEBACK_BONUS_MIN_PV = 7;
44
+ var COMEBACK_EFFECTS = ["comebackOffer", "comebackBonus", "comebackDeclined"];
45
+ var COMEBACK_EFFECT_SET = new Set(COMEBACK_EFFECTS);
46
+ var effectsForPv = (effects, pv) => (pv ?? MIN_SUPPORTED_PV) >= COMEBACK_BONUS_MIN_PV ? effects : effects.filter((e) => !COMEBACK_EFFECT_SET.has(e.t));
24
47
  var gamesForPv = (pv) => GAMES.filter((g) => !g.deprecated && g.minPv <= pv);
25
48
  var gameIdForPv = (gameId, pv) => {
26
49
  const g = GAMES.find((x) => x.id === gameId);
@@ -55,6 +78,7 @@ var HelpType = z.enum([
55
78
  var Pv = z.number().int().min(1).max(1e6).optional();
56
79
  var IdToken = z.string().max(4096).optional();
57
80
  var Platform = z.enum(["web", "ios", "android", "tv"]).optional();
81
+ var Games = z.array(z.enum(GAME_IDS)).max(GAME_IDS.length).optional();
58
82
  var AppVersion = z.string().trim().max(24).regex(/^[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]+)?$/, "bad app version").optional();
59
83
  var Avatar = z.object({
60
84
  emoji: z.string().min(1).max(16).transform((s) => s.replace(UNSAFE_CHARS, "")).refine((s) => s.length >= 1, "empty emoji"),
@@ -63,7 +87,7 @@ var Avatar = z.object({
63
87
  var Character = z.enum(CHARACTER_IDS);
64
88
  var ClientMsg = z.discriminatedUnion("t", [
65
89
  // room / lobby
66
- z.object({ t: z.literal("createRoom"), name: DisplayName.optional(), avatar: Avatar.optional(), game: z.enum(GAME_IDS).optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
90
+ z.object({ t: z.literal("createRoom"), name: DisplayName.optional(), avatar: Avatar.optional(), game: z.enum(GAME_IDS).optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
67
91
  // Host sets/changes their display name AFTER createRoom (for anonymous one-tap
68
92
  // hosts who started nameless). Host-only; the relay re-broadcasts the lobby.
69
93
  // SUPERSEDED by setProfile (kept working for shipped builds).
@@ -77,8 +101,8 @@ var ClientMsg = z.discriminatedUnion("t", [
77
101
  // view carries names+avatars so it updates live; dama/ersimha views are id-keyed (clients
78
102
  // map ids→names from the lobby roster), so a change there lands on the next lobby.
79
103
  z.object({ t: z.literal("setProfile"), name: DisplayName.optional(), avatar: Avatar.optional(), character: Character.optional() }),
80
- z.object({ t: z.literal("joinRoom"), code: RoomCode, name: DisplayName, avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
81
- z.object({ t: z.literal("resume"), code: RoomCode, token: z.string().max(120), avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
104
+ z.object({ t: z.literal("joinRoom"), code: RoomCode, name: DisplayName, avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
105
+ z.object({ t: z.literal("resume"), code: RoomCode, token: z.string().max(120), avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
82
106
  // `tally` (gs#46) = wins per PLAYER across every game this room has played tonight — «نتيجة الليلة».
83
107
  // Keyed by playerId, NOT by team: team names are recycled each game, so a per-team count means nothing
84
108
  // across a night. Survives playAgain. A One Device game credits nobody (its team slots hold no players).
@@ -97,6 +121,42 @@ var ClientMsg = z.discriminatedUnion("t", [
97
121
  z.object({ t: z.literal("kick"), playerId: PlayerId }),
98
122
  // Host's IN-PROGRESS category selection in the lobby — a live preview for waiting
99
123
  // players. Distinct from startGame.categoryIds, which COMMITS the board. Empty = cleared.
124
+ // ⚠️ AN EMPTY `categoryIds` IS A MEANINGFUL MESSAGE — DO NOT ADD `.min(1)`.
125
+ //
126
+ // The host sends `setCategories([])` the moment they press «التالي», before picking anything.
127
+ //
128
+ // ⚠️ THE THREE-STATE READING RECORDED IN a33f305 IS NOT DELIVERABLE ON THIS WIRE, AND A CLIENT
129
+ // WRITTEN TO IT WILL BE «FIXED» IN THE WRONG DIRECTION. It said:
130
+ // absent → still in the lobby
131
+ // present but EMPTY → the host is choosing
132
+ // non-empty → the picks card
133
+ // The first state does not exist. `socket.ts` creates every room with `selectedCategoryIds: []`
134
+ // and `rooms.ts` serialises it on EVERY lobby frame, so a joiner's very first frame already carries
135
+ // `[]` — byte-identical to the host having pressed «التالي». QA measured it on prod (room J7UY,
136
+ // raw frames in geem-qa `findings/sebaq/race2.jsonl`): the joiner's lobby before and after the
137
+ // host's empty `setCategories` differ in nothing on this field. On iOS the visible result is that
138
+ // joiners read «يختار الفئات» the moment they land. (geem-server#118.)
139
+ //
140
+ // ⚠️ `hostActivity` IS THE CARRIER FOR «THE HOST IS CHOOSING» — it is the field built for this exact
141
+ // question and it distinguishes the state this one cannot: `'options'` (the host is on the settings
142
+ // surface) vs `'configuring'` vs `'idle'`. Read THAT for the waiting-screen line; read
143
+ // `selectedCategoryIds` only for WHICH categories, never for WHETHER the host has begun.
144
+ //
145
+ // What stays true from a33f305, and is still worth its test: an empty array must PARSE. Adding
146
+ // `.min(1)` would reject a message the client is right to send.
147
+ //
148
+ // Without it, joiners sat on «ناطرين … يبدأ اللعبة» for as long as the host browsed and only learned
149
+ // anything had happened when the FIRST pick landed (owner, 2026-08-26; fixed by the iOS desk).
150
+ //
151
+ // ⚠️ THE FAILURE IF SOMEONE TIGHTENS THIS IS SILENT AND LOOKS LIKE A CLIENT BUG. Nothing crashes:
152
+ // every joiner in every category game just goes back to «waiting to start» until the first pick.
153
+ // The relay would be rejecting a message the client is right to send, and the search would start on
154
+ // the client. Applies to صيدها, بالدور and سباق alike — they share one lobby.
155
+ // The host's verdict on a comeback offer (owner ruling 2026-08-27). `grant: false` is a real and
156
+ // expected outcome — «if he refuse then he will not get it» — and closes the offer with nothing.
157
+ // ⚠️ HOST-ONLY, and the relay enforces it. A trailing team that could grant its own bonus is not a
158
+ // bonus, it is a button that says «+100».
159
+ z.object({ t: z.literal("comebackDecide"), grant: z.boolean() }),
100
160
  z.object({ t: z.literal("setCategories"), categoryIds: z.array(z.string().max(60)).max(12) }),
101
161
  // Host defines the lobby teams players can join (0–12; empty = disband back to the
102
162
  // one-team-per-player default). Resets picks that fall out of range.
@@ -113,10 +173,32 @@ var ClientMsg = z.discriminatedUnion("t", [
113
173
  // `modeId` alone still works (pre-gs#49 clients); `game` is what lets a host announce a tile with
114
174
  // no modes (dama/ersimha), which `modeId` could never express. An incoherent pair (a mode that
115
175
  // doesn't belong to the game) is rejected with `bad_mode` rather than silently resolved.
176
+ // ── Sebaq slice (gameId 'sebaq', the RACE tile) ──
177
+ // ⚠️ THE SERVER JUDGES AND THE SERVER TIMES. The client sends which option was tapped and nothing
178
+ // else — no «correct», no elapsed ms. A client-reported time makes the best connection win, and a
179
+ // client-reported verdict makes the race unwinnable honestly.
180
+ z.object({ t: z.literal("sebaqAnswer"), optionIndex: z.number().int().min(0).max(5) }),
181
+ // Rule 3: after ONE wrong answer the player chooses. `sebaqRetry` re-opens the SAME question for
182
+ // half points; `sebaqSkip` takes 0 and moves on. Two tiers only (rule 4) — a second wrong answer
183
+ // needs no message, it just ends the question.
184
+ z.object({ t: z.literal("sebaqRetry") }),
185
+ z.object({ t: z.literal("sebaqSkip") }),
116
186
  z.object({ t: z.literal("setMode"), game: z.enum(GAME_IDS).optional(), modeId: z.enum(MODE_IDS).optional() }),
117
187
  // Host signals it's busy configuring the game (categories/rounds/mode) so waiting players can show
118
188
  // a "host is setting up…" state instead of a frozen-looking lobby. 'idle' when the settings close.
119
- z.object({ t: z.literal("hostActivity"), activity: z.enum(["configuring", "idle"]) }),
189
+ // ⚠️ THREE VALUES SINCE 2026-08-26 (Options page, geem-coordination#24). The host's pre-game
190
+ // journey is Lobby → Options → Categories, and joiners route on this:
191
+ // idle → the lobby
192
+ // options → «how we play» — the per-game settings page, read-only for joiners
193
+ // configuring → «… يختار الفئات» — the category picker
194
+ //
195
+ // ⚠️ THE RELAY MUST ACCEPT `options` BEFORE ANY CLIENT SENDS IT, which is why this ships ahead of
196
+ // the page: a new client sending it to an old relay is a `bad_message`, the activity stays whatever
197
+ // it was, and every joiner shows the wrong banner with nothing erroring.
198
+ z.object({ t: z.literal("hostActivity"), activity: z.enum(["options", "configuring", "idle"]) }),
199
+ // ⚠️ AND IT IS ADAPTED ON THE WAY OUT (D-025), which is what makes it shippable without a
200
+ // coordinated release. `hostActivityForPv` below down-maps `options` → `configuring` for any client
201
+ // below pv6 — see there for the failure it prevents.
120
202
  // host game actions
121
203
  z.object({
122
204
  t: z.literal("startGame"),
@@ -234,6 +316,8 @@ var SUBMISSION_KINDS = ["new", "edit"];
234
316
  var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate", "too_narrow", "other"];
235
317
  export {
236
318
  CHARACTER_IDS,
319
+ COMEBACK_BONUS_MIN_PV,
320
+ COMEBACK_EFFECTS,
237
321
  ClientMsg,
238
322
  EMOTE_IDS,
239
323
  GAMES,
@@ -247,8 +331,10 @@ export {
247
331
  ROOM_ALPHABET,
248
332
  SUBMISSION_KINDS,
249
333
  SUBMISSION_STATUSES,
334
+ effectsForPv,
250
335
  gameIdForPv,
251
336
  gamesForPv,
337
+ hostActivityForPv,
252
338
  modesForGame,
253
339
  normalizeWesternDigits,
254
340
  sanitizeName
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegeem/protocol",
3
- "version": "0.1.38",
3
+ "version": "0.1.41",
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",
@@ -14,7 +14,8 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "copy-rules.json"
18
19
  ],
19
20
  "publishConfig": {
20
21
  "access": "public"