@thegeem/protocol 0.1.40 → 0.1.42

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
@@ -22,7 +22,9 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CHARACTER_IDS: () => CHARACTER_IDS,
24
24
  COMEBACK_BONUS_MIN_PV: () => COMEBACK_BONUS_MIN_PV,
25
+ COMEBACK_EFFECTS: () => COMEBACK_EFFECTS,
25
26
  ClientMsg: () => ClientMsg,
27
+ EFFECT_MIN_PV: () => EFFECT_MIN_PV,
26
28
  EMOTE_IDS: () => EMOTE_IDS,
27
29
  GAMES: () => GAMES,
28
30
  GAME_IDS: () => GAME_IDS,
@@ -33,15 +35,21 @@ __export(index_exports, {
33
35
  PROTOCOL_VERSION: () => PROTOCOL_VERSION,
34
36
  REJECT_REASONS: () => REJECT_REASONS,
35
37
  ROOM_ALPHABET: () => ROOM_ALPHABET,
38
+ SEEDHA_MODE_IDS: () => SEEDHA_MODE_IDS,
39
+ SNAG_MODES_MIN_PV: () => SNAG_MODES_MIN_PV,
40
+ SNAG_MODE_IDS: () => SNAG_MODE_IDS,
36
41
  SUBMISSION_KINDS: () => SUBMISSION_KINDS,
37
42
  SUBMISSION_STATUSES: () => SUBMISSION_STATUSES,
38
43
  effectsForPv: () => effectsForPv,
39
44
  gameIdForPv: () => gameIdForPv,
40
45
  gamesForPv: () => gamesForPv,
41
46
  hostActivityForPv: () => hostActivityForPv,
47
+ internalGameOf: () => internalGameOf,
42
48
  modesForGame: () => modesForGame,
49
+ modesOf: () => modesOf,
43
50
  normalizeWesternDigits: () => normalizeWesternDigits,
44
- sanitizeName: () => sanitizeName
51
+ sanitizeName: () => sanitizeName,
52
+ wireShapeOf: () => wireShapeOf
45
53
  });
46
54
  module.exports = __toCommonJS(index_exports);
47
55
  var import_zod = require("zod");
@@ -54,7 +62,7 @@ var LIMITS = {
54
62
  * Clients batch pen points to stay under this — ~150ms batching lands at ~7/sec. */
55
63
  STROKE_BATCH_PER_SEC: 20
56
64
  };
57
- var PROTOCOL_VERSION = 7;
65
+ var PROTOCOL_VERSION = 8;
58
66
  var MIN_SUPPORTED_PV = 1;
59
67
  var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"];
60
68
  var GAMES = [
@@ -86,20 +94,55 @@ var GAMES = [
86
94
  ];
87
95
  var hostActivityForPv = (activity, pv) => activity === "options" && (pv ?? MIN_SUPPORTED_PV) < 6 ? "configuring" : activity;
88
96
  var COMEBACK_BONUS_MIN_PV = 7;
89
- var COMEBACK_EFFECTS = /* @__PURE__ */ new Set(["comebackBonus", "comebackOffer", "comebackDeclined"]);
90
- var effectsForPv = (effects, pv) => (pv ?? MIN_SUPPORTED_PV) >= COMEBACK_BONUS_MIN_PV ? effects : effects.filter((e) => !COMEBACK_EFFECTS.has(e.t));
97
+ var EFFECT_MIN_PV = {
98
+ comebackOffer: COMEBACK_BONUS_MIN_PV,
99
+ comebackBonus: COMEBACK_BONUS_MIN_PV,
100
+ comebackDeclined: COMEBACK_BONUS_MIN_PV
101
+ };
102
+ var COMEBACK_EFFECTS = ["comebackOffer", "comebackBonus", "comebackDeclined"];
103
+ var effectsForPv = (effects, pv) => {
104
+ const have = pv ?? MIN_SUPPORTED_PV;
105
+ return effects.filter((e) => (EFFECT_MIN_PV[e.t] ?? MIN_SUPPORTED_PV) <= have);
106
+ };
91
107
  var gamesForPv = (pv) => GAMES.filter((g) => !g.deprecated && g.minPv <= pv);
92
108
  var gameIdForPv = (gameId, pv) => {
93
109
  const g = GAMES.find((x) => x.id === gameId);
94
110
  return g && !g.deprecated && (pv ?? MIN_SUPPORTED_PV) >= g.minPv ? gameId : "trivia";
95
111
  };
96
- var MODE_IDS = ["solo", "oneDevice", "snag", "multiplayer"];
112
+ var SEEDHA_MODE_IDS = ["solo", "oneDevice", "multiplayer"];
113
+ var SNAG_MODE_IDS = ["normal", "sebaq"];
114
+ var MODE_IDS = [...SEEDHA_MODE_IDS, ...SNAG_MODE_IDS, "snag"];
115
+ var SNAG_MODES_MIN_PV = 8;
116
+ function modesOf(gameId) {
117
+ return MODES.filter((m) => m.gameId === gameId && !m.legacyAlias);
118
+ }
97
119
  var MODES = [
98
120
  { id: "solo", gameId: "seedha", label: "Training", onlineEligible: false },
99
121
  { id: "oneDevice", gameId: "seedha", label: "One Device", onlineEligible: false },
100
122
  { id: "multiplayer", gameId: "seedha", label: "Multiplayer", onlineEligible: true },
101
- { id: "snag", gameId: "snag", label: "Snag", onlineEligible: true }
123
+ // D-102: today's buzz Snag, by its real name. OFF the boards (owner 2026-08-29): the host awards 1-answer
124
+ // questions and the Comeback +100, so its score is not the relay's alone.
125
+ { id: "normal", gameId: "snag", label: "Normal", onlineEligible: false },
126
+ { id: "sebaq", gameId: "snag", label: "Sebaq", onlineEligible: true },
127
+ // D-102: the race — relay-scored end to end; THE Snag board
128
+ // NOT a mode — the legacy spelling of «snag / normal» (see MODE_IDS). Kept so old clients' `MODES.find` still maps it.
129
+ { id: "snag", gameId: "snag", label: "Snag", onlineEligible: false, legacyAlias: true }
102
130
  ];
131
+ function internalGameOf(game, modeId) {
132
+ if (modeId === "sebaq" || game === "sebaq") return { gameId: "sebaq", modeId: void 0 };
133
+ if (modeId === "snag") return { gameId: "snag", modeId: "normal" };
134
+ if (modeId === "normal") return { gameId: "snag", modeId: "normal" };
135
+ return { gameId: game, modeId };
136
+ }
137
+ function wireShapeOf(gameId, modeId, pv) {
138
+ const v = pv ?? MIN_SUPPORTED_PV;
139
+ if (gameId === "sebaq") return v >= SNAG_MODES_MIN_PV ? { gameId: "snag", modeId: "sebaq" } : { gameId: gameIdForPv("sebaq", pv), modeId: null };
140
+ if (gameId === "snag") {
141
+ const m = modeId === "normal" || modeId === "snag" ? v >= SNAG_MODES_MIN_PV ? "normal" : "snag" : modeId;
142
+ return { gameId: gameIdForPv("snag", pv), modeId: m };
143
+ }
144
+ return { gameId: gameIdForPv(gameId, pv), modeId };
145
+ }
103
146
  var modesForGame = (gameId) => MODES.filter((m) => m.gameId === gameId);
104
147
  var EMOTE_IDS = ["laugh", "wow", "think", "gg"];
105
148
  var CHARACTER_IDS = ["m", "f"];
@@ -122,6 +165,7 @@ var HelpType = import_zod.z.enum([
122
165
  var Pv = import_zod.z.number().int().min(1).max(1e6).optional();
123
166
  var IdToken = import_zod.z.string().max(4096).optional();
124
167
  var Platform = import_zod.z.enum(["web", "ios", "android", "tv"]).optional();
168
+ var Games = import_zod.z.array(import_zod.z.enum(GAME_IDS)).max(GAME_IDS.length).optional();
125
169
  var AppVersion = import_zod.z.string().trim().max(24).regex(/^[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]+)?$/, "bad app version").optional();
126
170
  var Avatar = import_zod.z.object({
127
171
  emoji: import_zod.z.string().min(1).max(16).transform((s) => s.replace(UNSAFE_CHARS, "")).refine((s) => s.length >= 1, "empty emoji"),
@@ -130,7 +174,7 @@ var Avatar = import_zod.z.object({
130
174
  var Character = import_zod.z.enum(CHARACTER_IDS);
131
175
  var ClientMsg = import_zod.z.discriminatedUnion("t", [
132
176
  // room / lobby
133
- import_zod.z.object({ t: import_zod.z.literal("createRoom"), name: DisplayName.optional(), avatar: Avatar.optional(), game: import_zod.z.enum(GAME_IDS).optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
177
+ import_zod.z.object({ t: import_zod.z.literal("createRoom"), name: DisplayName.optional(), avatar: Avatar.optional(), game: import_zod.z.enum(GAME_IDS).optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
134
178
  // Host sets/changes their display name AFTER createRoom (for anonymous one-tap
135
179
  // hosts who started nameless). Host-only; the relay re-broadcasts the lobby.
136
180
  // SUPERSEDED by setProfile (kept working for shipped builds).
@@ -144,8 +188,8 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
144
188
  // view carries names+avatars so it updates live; dama/ersimha views are id-keyed (clients
145
189
  // map ids→names from the lobby roster), so a change there lands on the next lobby.
146
190
  import_zod.z.object({ t: import_zod.z.literal("setProfile"), name: DisplayName.optional(), avatar: Avatar.optional(), character: Character.optional() }),
147
- import_zod.z.object({ t: import_zod.z.literal("joinRoom"), code: RoomCode, name: DisplayName, avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
148
- import_zod.z.object({ t: import_zod.z.literal("resume"), code: RoomCode, token: import_zod.z.string().max(120), avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
191
+ import_zod.z.object({ t: import_zod.z.literal("joinRoom"), code: RoomCode, name: DisplayName, avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
192
+ import_zod.z.object({ t: import_zod.z.literal("resume"), code: RoomCode, token: import_zod.z.string().max(120), avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
149
193
  // `tally` (gs#46) = wins per PLAYER across every game this room has played tonight — «نتيجة الليلة».
150
194
  // Keyed by playerId, NOT by team: team names are recycled each game, so a per-team count means nothing
151
195
  // across a night. Survives playAgain. A One Device game credits nobody (its team slots hold no players).
@@ -361,7 +405,9 @@ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate",
361
405
  0 && (module.exports = {
362
406
  CHARACTER_IDS,
363
407
  COMEBACK_BONUS_MIN_PV,
408
+ COMEBACK_EFFECTS,
364
409
  ClientMsg,
410
+ EFFECT_MIN_PV,
365
411
  EMOTE_IDS,
366
412
  GAMES,
367
413
  GAME_IDS,
@@ -372,13 +418,19 @@ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate",
372
418
  PROTOCOL_VERSION,
373
419
  REJECT_REASONS,
374
420
  ROOM_ALPHABET,
421
+ SEEDHA_MODE_IDS,
422
+ SNAG_MODES_MIN_PV,
423
+ SNAG_MODE_IDS,
375
424
  SUBMISSION_KINDS,
376
425
  SUBMISSION_STATUSES,
377
426
  effectsForPv,
378
427
  gameIdForPv,
379
428
  gamesForPv,
380
429
  hostActivityForPv,
430
+ internalGameOf,
381
431
  modesForGame,
432
+ modesOf,
382
433
  normalizeWesternDigits,
383
- sanitizeName
434
+ sanitizeName,
435
+ wireShapeOf
384
436
  });
package/dist/index.d.cts 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 = 7;
39
+ declare const PROTOCOL_VERSION = 8;
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
@@ -96,6 +96,33 @@ declare const hostActivityForPv: (activity: "options" | "configuring" | "idle",
96
96
  * the honest degradation: the game stays correct, the animation is missing.
97
97
  */
98
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
+ /**
109
+ * The pv each VERSION-GATED effect first became decodable at. Absent = decodable since the floor.
110
+ *
111
+ * ⚠️ ONE TABLE, THE SHAPE `GAMES` ALREADY USES (`minPv` per entry) — not one constant, one set and one
112
+ * `||` per feature. The comeback bonus was the first gated effect and arrived as three of each; the
113
+ * next would have added three more. ⚠️ The relay's `CLIENT_FX` spreads EVERY key of this table, whatever
114
+ * its pv — the gate withholds, the allow-list must never — so a gated effect cannot be forgotten in one
115
+ * of the two places. I re-typed that list once, listed one of three, and shipped a feature whose refusal
116
+ * rendered as silence on prod. A hand-list fails by OMISSION.
117
+ */
118
+ declare const EFFECT_MIN_PV: Partial<Record<Effect['t'], number>>;
119
+ /** The comeback FAMILY, explicit. ⚠️ Not «every effect whose minPv is 7» — that derivation would silently
120
+ * drop a fourth comeback effect gated at pv8, and the test that checks this list would stay green because
121
+ * it derives from the same list (code-review C7). Family membership is a fact about the feature; the pv is
122
+ * a fact about the wire. Two tables, on purpose. */
123
+ declare const COMEBACK_EFFECTS: readonly ["comebackOffer", "comebackBonus", "comebackDeclined"];
124
+ /** The effects a client of version `pv` can decode. Withholding is the only option for an effect — an
125
+ * unknown variant has no older equivalent, and a strict Swift enum drops the WHOLE frame on one. */
99
126
  declare const effectsForPv: (effects: Effect[], pv: number | undefined) => Effect[];
100
127
  declare const gamesForPv: (pv: number) => GameInfo[];
101
128
  /** The gameId to SEND a client of version `pv`: new games need pv >= their minPv; older clients see 'trivia'. */
@@ -109,15 +136,53 @@ declare const gameIdForPv: (gameId: GameId, pv: number | undefined) => GameId;
109
136
  * (only server-arbitrated, multi-device modes; single-device/solo are local-only — the host controls
110
137
  * input, so the server can't trust the score).
111
138
  */
112
- declare const MODE_IDS: readonly ["solo", "oneDevice", "snag", "multiplayer"];
139
+ /** صيدها's modes one name each, the ONLY name anything outside the engine uses (guide §9 item 1, owner «go» 2026-08-29). */
140
+ declare const SEEDHA_MODE_IDS: readonly ["solo", "oneDevice", "multiplayer"];
141
+ /** سناق (من سبق لبق)'s modes — D-102, owner 2026-08-29: `normal` = buzz-to-claim on ONE shared question (what
142
+ * every shipped build calls «Snag»), `sebaq` = everyone races the SAME set on their own phone (D-099's rules).
143
+ * ⚠️ The word ديوانية is RESERVED for a future «Group Diwaniya» — it names nothing today. */
144
+ declare const SNAG_MODE_IDS: readonly ["normal", "sebaq"];
145
+ /** The wire union. `'snag'` is NOT a mode: it is the LEGACY SPELLING of «game snag, mode normal» that every
146
+ * shipped iOS and web build sends, and a frozen build must keep working — see internalGameOf(). The new ids
147
+ * reach only pv ≥ SNAG_MODES_MIN_PV clients (`lobby.modeId` is a STRICT Swift enum: an unknown value drops
148
+ * the whole frame) — wireShapeOf() down-maps for everyone else. */
149
+ declare const MODE_IDS: readonly ["solo", "oneDevice", "multiplayer", "normal", "sebaq", "snag"];
150
+ declare const SNAG_MODES_MIN_PV = 8;
151
+ type SnagModeId = (typeof SNAG_MODE_IDS)[number];
113
152
  type ModeId = (typeof MODE_IDS)[number];
153
+ type SeedhaModeId = (typeof SEEDHA_MODE_IDS)[number];
154
+ /** The modes a game offers, from the registry. Games with no modes (دامة, ارسمها, سباق) → []. */
155
+ declare function modesOf(gameId: GameId): ModeInfo[];
114
156
  interface ModeInfo {
115
157
  id: ModeId;
116
158
  gameId: GameId;
117
159
  label: string;
118
160
  onlineEligible: boolean;
161
+ /** A row kept only so `MODES.find(id)` keeps resolving for builds that send a game as a modeId. Not a mode:
162
+ * never render it in a mode picker — `modesOf()` filters it out. */
163
+ legacyAlias?: true;
119
164
  }
120
165
  declare const MODES: readonly ModeInfo[];
166
+ /**
167
+ * D-102 — the two directions between the PRODUCT shape (game Snag → mode normal | sebaq) and the ENGINE
168
+ * shape the relay runs (`gameId 'snag'` = the trivia engine in race flow; `gameId 'sebaq'` = its own
169
+ * engine/driver, D-026 tile-vs-engine). Both spellings stay on the wire forever: `game: 'sebaq'` and
170
+ * `modeId: 'snag'` are what every shipped build sends.
171
+ *
172
+ * INBOUND — `internalGameOf(game, modeId)`: what the client SAID → what the relay RUNS.
173
+ */
174
+ declare function internalGameOf(game: GameId | undefined, modeId: ModeId | undefined): {
175
+ gameId: GameId | undefined;
176
+ modeId: ModeId | undefined;
177
+ };
178
+ /**
179
+ * OUTBOUND — `wireShapeOf(internalGameId, modeId, pv)`: what the relay RUNS → what THIS client can DECODE.
180
+ * pv ≥ 8 sees the product shape; below it, the pv7 shape it shipped with (`sebaq` as a game, `snag` as a mode).
181
+ */
182
+ declare function wireShapeOf(gameId: GameId, modeId: ModeId | null, pv: number | undefined): {
183
+ gameId: GameId;
184
+ modeId: ModeId | null;
185
+ };
121
186
  /** The modes available for a given game (for a client's mode picker). */
122
187
  declare const modesForGame: (gameId: GameId) => ModeInfo[];
123
188
  /**
@@ -176,6 +241,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
176
241
  pv: z.ZodOptional<z.ZodNumber>;
177
242
  idToken: z.ZodOptional<z.ZodString>;
178
243
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
244
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
179
245
  appVersion: z.ZodOptional<z.ZodString>;
180
246
  }, "strip", z.ZodTypeAny, {
181
247
  t: "createRoom";
@@ -189,6 +255,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
189
255
  pv?: number | undefined;
190
256
  idToken?: string | undefined;
191
257
  platform?: "web" | "ios" | "android" | "tv" | undefined;
258
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
192
259
  appVersion?: string | undefined;
193
260
  }, {
194
261
  t: "createRoom";
@@ -202,6 +269,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
202
269
  pv?: number | undefined;
203
270
  idToken?: string | undefined;
204
271
  platform?: "web" | "ios" | "android" | "tv" | undefined;
272
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
205
273
  appVersion?: string | undefined;
206
274
  }>, z.ZodObject<{
207
275
  t: z.ZodLiteral<"renameHost">;
@@ -260,10 +328,11 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
260
328
  pv: z.ZodOptional<z.ZodNumber>;
261
329
  idToken: z.ZodOptional<z.ZodString>;
262
330
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
331
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
263
332
  appVersion: z.ZodOptional<z.ZodString>;
264
333
  }, "strip", z.ZodTypeAny, {
265
- code: string;
266
334
  t: "joinRoom";
335
+ code: string;
267
336
  name: string;
268
337
  avatar?: {
269
338
  emoji: string;
@@ -273,10 +342,11 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
273
342
  pv?: number | undefined;
274
343
  idToken?: string | undefined;
275
344
  platform?: "web" | "ios" | "android" | "tv" | undefined;
345
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
276
346
  appVersion?: string | undefined;
277
347
  }, {
278
- code: string;
279
348
  t: "joinRoom";
349
+ code: string;
280
350
  name: string;
281
351
  avatar?: {
282
352
  emoji: string;
@@ -286,6 +356,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
286
356
  pv?: number | undefined;
287
357
  idToken?: string | undefined;
288
358
  platform?: "web" | "ios" | "android" | "tv" | undefined;
359
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
289
360
  appVersion?: string | undefined;
290
361
  }>, z.ZodObject<{
291
362
  t: z.ZodLiteral<"resume">;
@@ -305,10 +376,11 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
305
376
  pv: z.ZodOptional<z.ZodNumber>;
306
377
  idToken: z.ZodOptional<z.ZodString>;
307
378
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
379
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
308
380
  appVersion: z.ZodOptional<z.ZodString>;
309
381
  }, "strip", z.ZodTypeAny, {
310
- code: string;
311
382
  t: "resume";
383
+ code: string;
312
384
  token: string;
313
385
  avatar?: {
314
386
  emoji: string;
@@ -318,10 +390,11 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
318
390
  pv?: number | undefined;
319
391
  idToken?: string | undefined;
320
392
  platform?: "web" | "ios" | "android" | "tv" | undefined;
393
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
321
394
  appVersion?: string | undefined;
322
395
  }, {
323
- code: string;
324
396
  t: "resume";
397
+ code: string;
325
398
  token: string;
326
399
  avatar?: {
327
400
  emoji: string;
@@ -331,6 +404,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
331
404
  pv?: number | undefined;
332
405
  idToken?: string | undefined;
333
406
  platform?: "web" | "ios" | "android" | "tv" | undefined;
407
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
334
408
  appVersion?: string | undefined;
335
409
  }>, z.ZodObject<{
336
410
  t: z.ZodLiteral<"setPlaying">;
@@ -437,15 +511,15 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
437
511
  }>, z.ZodObject<{
438
512
  t: z.ZodLiteral<"setMode">;
439
513
  game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
440
- modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
514
+ modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "multiplayer", "normal", "sebaq", "snag"]>>;
441
515
  }, "strip", z.ZodTypeAny, {
442
516
  t: "setMode";
443
517
  game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
444
- modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
518
+ modeId?: "snag" | "sebaq" | "solo" | "oneDevice" | "multiplayer" | "normal" | undefined;
445
519
  }, {
446
520
  t: "setMode";
447
521
  game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
448
- modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
522
+ modeId?: "snag" | "sebaq" | "solo" | "oneDevice" | "multiplayer" | "normal" | undefined;
449
523
  }>, z.ZodObject<{
450
524
  t: z.ZodLiteral<"hostActivity">;
451
525
  activity: z.ZodEnum<["options", "configuring", "idle"]>;
@@ -469,7 +543,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
469
543
  }, {
470
544
  difficulty?: "easy" | "medium" | "hard" | undefined;
471
545
  }>>;
472
- modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
546
+ modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "multiplayer", "normal", "sebaq", "snag"]>>;
473
547
  buzzerMode: z.ZodOptional<z.ZodEnum<["off", "solo", "race", "controller"]>>;
474
548
  noHost: z.ZodOptional<z.ZodBoolean>;
475
549
  }, "strip", z.ZodTypeAny, {
@@ -477,7 +551,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
477
551
  categoryIds: string[];
478
552
  totalRounds: number;
479
553
  game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
480
- modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
554
+ modeId?: "snag" | "sebaq" | "solo" | "oneDevice" | "multiplayer" | "normal" | undefined;
481
555
  hostPlays?: boolean | undefined;
482
556
  players?: string[] | undefined;
483
557
  bot?: {
@@ -490,7 +564,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
490
564
  categoryIds: string[];
491
565
  totalRounds: number;
492
566
  game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
493
- modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
567
+ modeId?: "snag" | "sebaq" | "solo" | "oneDevice" | "multiplayer" | "normal" | undefined;
494
568
  hostPlays?: boolean | undefined;
495
569
  players?: string[] | undefined;
496
570
  bot?: {
@@ -704,8 +778,8 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
704
778
  color: z.ZodString;
705
779
  width: z.ZodNumber;
706
780
  }, "strip", z.ZodTypeAny, {
707
- color: string;
708
781
  t: "strokeBatch";
782
+ color: string;
709
783
  strokeId: string;
710
784
  seq: number;
711
785
  points: {
@@ -714,8 +788,8 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
714
788
  }[];
715
789
  width: number;
716
790
  }, {
717
- color: string;
718
791
  t: "strokeBatch";
792
+ color: string;
719
793
  strokeId: string;
720
794
  seq: number;
721
795
  points: {
@@ -874,7 +948,7 @@ interface PackView {
874
948
  hasImage?: boolean;
875
949
  }
876
950
  type GameStatus = 'setup' | 'categorySelection' | 'inProgress' | 'completed';
877
- type GameplayFlow = 'classic' | 'openJudged' | 'noHost' | 'buzzer';
951
+ type GameplayFlow = 'classic' | 'noHost' | 'buzzer';
878
952
  type BuzzerGameMode = 'off' | 'solo' | 'race' | 'controller';
879
953
  /** The help/lifeline ids (same set as the inbound `HelpType` zod enum above). */
880
954
  type HelpName = z.infer<typeof HelpType>;
@@ -890,6 +964,7 @@ interface TeamView {
890
964
  helps: {
891
965
  type: HelpName;
892
966
  isUsed: boolean;
967
+ unusable?: true;
893
968
  }[];
894
969
  /** The team's players in rotation order (`connected` = that player's socket is online right now).
895
970
  * Empty for host-run single-device teams. */
@@ -1229,6 +1304,7 @@ type ServerMsg = {
1229
1304
  hostFrame?: string;
1230
1305
  hostPlaying?: boolean;
1231
1306
  hostPinned?: boolean;
1307
+ hostTeamIndex?: number | null;
1232
1308
  players: LobbyPlayer[];
1233
1309
  categories: CategoryView[];
1234
1310
  packs?: PackView[];
@@ -1340,4 +1416,4 @@ interface MyQuestionRow {
1340
1416
  };
1341
1417
  }
1342
1418
 
1343
- export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, COMEBACK_BONUS_MIN_PV, 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 };
1419
+ export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, COMEBACK_BONUS_MIN_PV, COMEBACK_EFFECTS, type CategoryView, type CharacterId, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, EFFECT_MIN_PV, 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, SEEDHA_MODE_IDS, SNAG_MODES_MIN_PV, SNAG_MODE_IDS, SUBMISSION_KINDS, SUBMISSION_STATUSES, type SebaqStandingRow, type SebaqStateView, type SeedhaModeId, type ServerMsg, type SnagModeId, type SoundName, type StrokePointView, type SubmissionKind, type SubmissionStatus, type TeamSlotView, type TeamView, type YouContext, effectsForPv, gameIdForPv, gamesForPv, hostActivityForPv, internalGameOf, modesForGame, modesOf, normalizeWesternDigits, sanitizeName, wireShapeOf };
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 = 7;
39
+ declare const PROTOCOL_VERSION = 8;
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
@@ -96,6 +96,33 @@ declare const hostActivityForPv: (activity: "options" | "configuring" | "idle",
96
96
  * the honest degradation: the game stays correct, the animation is missing.
97
97
  */
98
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
+ /**
109
+ * The pv each VERSION-GATED effect first became decodable at. Absent = decodable since the floor.
110
+ *
111
+ * ⚠️ ONE TABLE, THE SHAPE `GAMES` ALREADY USES (`minPv` per entry) — not one constant, one set and one
112
+ * `||` per feature. The comeback bonus was the first gated effect and arrived as three of each; the
113
+ * next would have added three more. ⚠️ The relay's `CLIENT_FX` spreads EVERY key of this table, whatever
114
+ * its pv — the gate withholds, the allow-list must never — so a gated effect cannot be forgotten in one
115
+ * of the two places. I re-typed that list once, listed one of three, and shipped a feature whose refusal
116
+ * rendered as silence on prod. A hand-list fails by OMISSION.
117
+ */
118
+ declare const EFFECT_MIN_PV: Partial<Record<Effect['t'], number>>;
119
+ /** The comeback FAMILY, explicit. ⚠️ Not «every effect whose minPv is 7» — that derivation would silently
120
+ * drop a fourth comeback effect gated at pv8, and the test that checks this list would stay green because
121
+ * it derives from the same list (code-review C7). Family membership is a fact about the feature; the pv is
122
+ * a fact about the wire. Two tables, on purpose. */
123
+ declare const COMEBACK_EFFECTS: readonly ["comebackOffer", "comebackBonus", "comebackDeclined"];
124
+ /** The effects a client of version `pv` can decode. Withholding is the only option for an effect — an
125
+ * unknown variant has no older equivalent, and a strict Swift enum drops the WHOLE frame on one. */
99
126
  declare const effectsForPv: (effects: Effect[], pv: number | undefined) => Effect[];
100
127
  declare const gamesForPv: (pv: number) => GameInfo[];
101
128
  /** The gameId to SEND a client of version `pv`: new games need pv >= their minPv; older clients see 'trivia'. */
@@ -109,15 +136,53 @@ declare const gameIdForPv: (gameId: GameId, pv: number | undefined) => GameId;
109
136
  * (only server-arbitrated, multi-device modes; single-device/solo are local-only — the host controls
110
137
  * input, so the server can't trust the score).
111
138
  */
112
- declare const MODE_IDS: readonly ["solo", "oneDevice", "snag", "multiplayer"];
139
+ /** صيدها's modes one name each, the ONLY name anything outside the engine uses (guide §9 item 1, owner «go» 2026-08-29). */
140
+ declare const SEEDHA_MODE_IDS: readonly ["solo", "oneDevice", "multiplayer"];
141
+ /** سناق (من سبق لبق)'s modes — D-102, owner 2026-08-29: `normal` = buzz-to-claim on ONE shared question (what
142
+ * every shipped build calls «Snag»), `sebaq` = everyone races the SAME set on their own phone (D-099's rules).
143
+ * ⚠️ The word ديوانية is RESERVED for a future «Group Diwaniya» — it names nothing today. */
144
+ declare const SNAG_MODE_IDS: readonly ["normal", "sebaq"];
145
+ /** The wire union. `'snag'` is NOT a mode: it is the LEGACY SPELLING of «game snag, mode normal» that every
146
+ * shipped iOS and web build sends, and a frozen build must keep working — see internalGameOf(). The new ids
147
+ * reach only pv ≥ SNAG_MODES_MIN_PV clients (`lobby.modeId` is a STRICT Swift enum: an unknown value drops
148
+ * the whole frame) — wireShapeOf() down-maps for everyone else. */
149
+ declare const MODE_IDS: readonly ["solo", "oneDevice", "multiplayer", "normal", "sebaq", "snag"];
150
+ declare const SNAG_MODES_MIN_PV = 8;
151
+ type SnagModeId = (typeof SNAG_MODE_IDS)[number];
113
152
  type ModeId = (typeof MODE_IDS)[number];
153
+ type SeedhaModeId = (typeof SEEDHA_MODE_IDS)[number];
154
+ /** The modes a game offers, from the registry. Games with no modes (دامة, ارسمها, سباق) → []. */
155
+ declare function modesOf(gameId: GameId): ModeInfo[];
114
156
  interface ModeInfo {
115
157
  id: ModeId;
116
158
  gameId: GameId;
117
159
  label: string;
118
160
  onlineEligible: boolean;
161
+ /** A row kept only so `MODES.find(id)` keeps resolving for builds that send a game as a modeId. Not a mode:
162
+ * never render it in a mode picker — `modesOf()` filters it out. */
163
+ legacyAlias?: true;
119
164
  }
120
165
  declare const MODES: readonly ModeInfo[];
166
+ /**
167
+ * D-102 — the two directions between the PRODUCT shape (game Snag → mode normal | sebaq) and the ENGINE
168
+ * shape the relay runs (`gameId 'snag'` = the trivia engine in race flow; `gameId 'sebaq'` = its own
169
+ * engine/driver, D-026 tile-vs-engine). Both spellings stay on the wire forever: `game: 'sebaq'` and
170
+ * `modeId: 'snag'` are what every shipped build sends.
171
+ *
172
+ * INBOUND — `internalGameOf(game, modeId)`: what the client SAID → what the relay RUNS.
173
+ */
174
+ declare function internalGameOf(game: GameId | undefined, modeId: ModeId | undefined): {
175
+ gameId: GameId | undefined;
176
+ modeId: ModeId | undefined;
177
+ };
178
+ /**
179
+ * OUTBOUND — `wireShapeOf(internalGameId, modeId, pv)`: what the relay RUNS → what THIS client can DECODE.
180
+ * pv ≥ 8 sees the product shape; below it, the pv7 shape it shipped with (`sebaq` as a game, `snag` as a mode).
181
+ */
182
+ declare function wireShapeOf(gameId: GameId, modeId: ModeId | null, pv: number | undefined): {
183
+ gameId: GameId;
184
+ modeId: ModeId | null;
185
+ };
121
186
  /** The modes available for a given game (for a client's mode picker). */
122
187
  declare const modesForGame: (gameId: GameId) => ModeInfo[];
123
188
  /**
@@ -176,6 +241,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
176
241
  pv: z.ZodOptional<z.ZodNumber>;
177
242
  idToken: z.ZodOptional<z.ZodString>;
178
243
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
244
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
179
245
  appVersion: z.ZodOptional<z.ZodString>;
180
246
  }, "strip", z.ZodTypeAny, {
181
247
  t: "createRoom";
@@ -189,6 +255,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
189
255
  pv?: number | undefined;
190
256
  idToken?: string | undefined;
191
257
  platform?: "web" | "ios" | "android" | "tv" | undefined;
258
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
192
259
  appVersion?: string | undefined;
193
260
  }, {
194
261
  t: "createRoom";
@@ -202,6 +269,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
202
269
  pv?: number | undefined;
203
270
  idToken?: string | undefined;
204
271
  platform?: "web" | "ios" | "android" | "tv" | undefined;
272
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
205
273
  appVersion?: string | undefined;
206
274
  }>, z.ZodObject<{
207
275
  t: z.ZodLiteral<"renameHost">;
@@ -260,10 +328,11 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
260
328
  pv: z.ZodOptional<z.ZodNumber>;
261
329
  idToken: z.ZodOptional<z.ZodString>;
262
330
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
331
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
263
332
  appVersion: z.ZodOptional<z.ZodString>;
264
333
  }, "strip", z.ZodTypeAny, {
265
- code: string;
266
334
  t: "joinRoom";
335
+ code: string;
267
336
  name: string;
268
337
  avatar?: {
269
338
  emoji: string;
@@ -273,10 +342,11 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
273
342
  pv?: number | undefined;
274
343
  idToken?: string | undefined;
275
344
  platform?: "web" | "ios" | "android" | "tv" | undefined;
345
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
276
346
  appVersion?: string | undefined;
277
347
  }, {
278
- code: string;
279
348
  t: "joinRoom";
349
+ code: string;
280
350
  name: string;
281
351
  avatar?: {
282
352
  emoji: string;
@@ -286,6 +356,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
286
356
  pv?: number | undefined;
287
357
  idToken?: string | undefined;
288
358
  platform?: "web" | "ios" | "android" | "tv" | undefined;
359
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
289
360
  appVersion?: string | undefined;
290
361
  }>, z.ZodObject<{
291
362
  t: z.ZodLiteral<"resume">;
@@ -305,10 +376,11 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
305
376
  pv: z.ZodOptional<z.ZodNumber>;
306
377
  idToken: z.ZodOptional<z.ZodString>;
307
378
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android", "tv"]>>;
379
+ games: z.ZodOptional<z.ZodArray<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>, "many">>;
308
380
  appVersion: z.ZodOptional<z.ZodString>;
309
381
  }, "strip", z.ZodTypeAny, {
310
- code: string;
311
382
  t: "resume";
383
+ code: string;
312
384
  token: string;
313
385
  avatar?: {
314
386
  emoji: string;
@@ -318,10 +390,11 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
318
390
  pv?: number | undefined;
319
391
  idToken?: string | undefined;
320
392
  platform?: "web" | "ios" | "android" | "tv" | undefined;
393
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
321
394
  appVersion?: string | undefined;
322
395
  }, {
323
- code: string;
324
396
  t: "resume";
397
+ code: string;
325
398
  token: string;
326
399
  avatar?: {
327
400
  emoji: string;
@@ -331,6 +404,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
331
404
  pv?: number | undefined;
332
405
  idToken?: string | undefined;
333
406
  platform?: "web" | "ios" | "android" | "tv" | undefined;
407
+ games?: ("seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq")[] | undefined;
334
408
  appVersion?: string | undefined;
335
409
  }>, z.ZodObject<{
336
410
  t: z.ZodLiteral<"setPlaying">;
@@ -437,15 +511,15 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
437
511
  }>, z.ZodObject<{
438
512
  t: z.ZodLiteral<"setMode">;
439
513
  game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
440
- modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
514
+ modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "multiplayer", "normal", "sebaq", "snag"]>>;
441
515
  }, "strip", z.ZodTypeAny, {
442
516
  t: "setMode";
443
517
  game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
444
- modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
518
+ modeId?: "snag" | "sebaq" | "solo" | "oneDevice" | "multiplayer" | "normal" | undefined;
445
519
  }, {
446
520
  t: "setMode";
447
521
  game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
448
- modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
522
+ modeId?: "snag" | "sebaq" | "solo" | "oneDevice" | "multiplayer" | "normal" | undefined;
449
523
  }>, z.ZodObject<{
450
524
  t: z.ZodLiteral<"hostActivity">;
451
525
  activity: z.ZodEnum<["options", "configuring", "idle"]>;
@@ -469,7 +543,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
469
543
  }, {
470
544
  difficulty?: "easy" | "medium" | "hard" | undefined;
471
545
  }>>;
472
- modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
546
+ modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "multiplayer", "normal", "sebaq", "snag"]>>;
473
547
  buzzerMode: z.ZodOptional<z.ZodEnum<["off", "solo", "race", "controller"]>>;
474
548
  noHost: z.ZodOptional<z.ZodBoolean>;
475
549
  }, "strip", z.ZodTypeAny, {
@@ -477,7 +551,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
477
551
  categoryIds: string[];
478
552
  totalRounds: number;
479
553
  game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
480
- modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
554
+ modeId?: "snag" | "sebaq" | "solo" | "oneDevice" | "multiplayer" | "normal" | undefined;
481
555
  hostPlays?: boolean | undefined;
482
556
  players?: string[] | undefined;
483
557
  bot?: {
@@ -490,7 +564,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
490
564
  categoryIds: string[];
491
565
  totalRounds: number;
492
566
  game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
493
- modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
567
+ modeId?: "snag" | "sebaq" | "solo" | "oneDevice" | "multiplayer" | "normal" | undefined;
494
568
  hostPlays?: boolean | undefined;
495
569
  players?: string[] | undefined;
496
570
  bot?: {
@@ -704,8 +778,8 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
704
778
  color: z.ZodString;
705
779
  width: z.ZodNumber;
706
780
  }, "strip", z.ZodTypeAny, {
707
- color: string;
708
781
  t: "strokeBatch";
782
+ color: string;
709
783
  strokeId: string;
710
784
  seq: number;
711
785
  points: {
@@ -714,8 +788,8 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
714
788
  }[];
715
789
  width: number;
716
790
  }, {
717
- color: string;
718
791
  t: "strokeBatch";
792
+ color: string;
719
793
  strokeId: string;
720
794
  seq: number;
721
795
  points: {
@@ -874,7 +948,7 @@ interface PackView {
874
948
  hasImage?: boolean;
875
949
  }
876
950
  type GameStatus = 'setup' | 'categorySelection' | 'inProgress' | 'completed';
877
- type GameplayFlow = 'classic' | 'openJudged' | 'noHost' | 'buzzer';
951
+ type GameplayFlow = 'classic' | 'noHost' | 'buzzer';
878
952
  type BuzzerGameMode = 'off' | 'solo' | 'race' | 'controller';
879
953
  /** The help/lifeline ids (same set as the inbound `HelpType` zod enum above). */
880
954
  type HelpName = z.infer<typeof HelpType>;
@@ -890,6 +964,7 @@ interface TeamView {
890
964
  helps: {
891
965
  type: HelpName;
892
966
  isUsed: boolean;
967
+ unusable?: true;
893
968
  }[];
894
969
  /** The team's players in rotation order (`connected` = that player's socket is online right now).
895
970
  * Empty for host-run single-device teams. */
@@ -1229,6 +1304,7 @@ type ServerMsg = {
1229
1304
  hostFrame?: string;
1230
1305
  hostPlaying?: boolean;
1231
1306
  hostPinned?: boolean;
1307
+ hostTeamIndex?: number | null;
1232
1308
  players: LobbyPlayer[];
1233
1309
  categories: CategoryView[];
1234
1310
  packs?: PackView[];
@@ -1340,4 +1416,4 @@ interface MyQuestionRow {
1340
1416
  };
1341
1417
  }
1342
1418
 
1343
- export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, COMEBACK_BONUS_MIN_PV, 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 };
1419
+ export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, COMEBACK_BONUS_MIN_PV, COMEBACK_EFFECTS, type CategoryView, type CharacterId, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, EFFECT_MIN_PV, 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, SEEDHA_MODE_IDS, SNAG_MODES_MIN_PV, SNAG_MODE_IDS, SUBMISSION_KINDS, SUBMISSION_STATUSES, type SebaqStandingRow, type SebaqStateView, type SeedhaModeId, type ServerMsg, type SnagModeId, type SoundName, type StrokePointView, type SubmissionKind, type SubmissionStatus, type TeamSlotView, type TeamView, type YouContext, effectsForPv, gameIdForPv, gamesForPv, hostActivityForPv, internalGameOf, modesForGame, modesOf, normalizeWesternDigits, sanitizeName, wireShapeOf };
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ 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 = 7;
12
+ var PROTOCOL_VERSION = 8;
13
13
  var MIN_SUPPORTED_PV = 1;
14
14
  var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"];
15
15
  var GAMES = [
@@ -41,20 +41,55 @@ var GAMES = [
41
41
  ];
42
42
  var hostActivityForPv = (activity, pv) => activity === "options" && (pv ?? MIN_SUPPORTED_PV) < 6 ? "configuring" : activity;
43
43
  var COMEBACK_BONUS_MIN_PV = 7;
44
- var COMEBACK_EFFECTS = /* @__PURE__ */ new Set(["comebackBonus", "comebackOffer", "comebackDeclined"]);
45
- var effectsForPv = (effects, pv) => (pv ?? MIN_SUPPORTED_PV) >= COMEBACK_BONUS_MIN_PV ? effects : effects.filter((e) => !COMEBACK_EFFECTS.has(e.t));
44
+ var EFFECT_MIN_PV = {
45
+ comebackOffer: COMEBACK_BONUS_MIN_PV,
46
+ comebackBonus: COMEBACK_BONUS_MIN_PV,
47
+ comebackDeclined: COMEBACK_BONUS_MIN_PV
48
+ };
49
+ var COMEBACK_EFFECTS = ["comebackOffer", "comebackBonus", "comebackDeclined"];
50
+ var effectsForPv = (effects, pv) => {
51
+ const have = pv ?? MIN_SUPPORTED_PV;
52
+ return effects.filter((e) => (EFFECT_MIN_PV[e.t] ?? MIN_SUPPORTED_PV) <= have);
53
+ };
46
54
  var gamesForPv = (pv) => GAMES.filter((g) => !g.deprecated && g.minPv <= pv);
47
55
  var gameIdForPv = (gameId, pv) => {
48
56
  const g = GAMES.find((x) => x.id === gameId);
49
57
  return g && !g.deprecated && (pv ?? MIN_SUPPORTED_PV) >= g.minPv ? gameId : "trivia";
50
58
  };
51
- var MODE_IDS = ["solo", "oneDevice", "snag", "multiplayer"];
59
+ var SEEDHA_MODE_IDS = ["solo", "oneDevice", "multiplayer"];
60
+ var SNAG_MODE_IDS = ["normal", "sebaq"];
61
+ var MODE_IDS = [...SEEDHA_MODE_IDS, ...SNAG_MODE_IDS, "snag"];
62
+ var SNAG_MODES_MIN_PV = 8;
63
+ function modesOf(gameId) {
64
+ return MODES.filter((m) => m.gameId === gameId && !m.legacyAlias);
65
+ }
52
66
  var MODES = [
53
67
  { id: "solo", gameId: "seedha", label: "Training", onlineEligible: false },
54
68
  { id: "oneDevice", gameId: "seedha", label: "One Device", onlineEligible: false },
55
69
  { id: "multiplayer", gameId: "seedha", label: "Multiplayer", onlineEligible: true },
56
- { id: "snag", gameId: "snag", label: "Snag", onlineEligible: true }
70
+ // D-102: today's buzz Snag, by its real name. OFF the boards (owner 2026-08-29): the host awards 1-answer
71
+ // questions and the Comeback +100, so its score is not the relay's alone.
72
+ { id: "normal", gameId: "snag", label: "Normal", onlineEligible: false },
73
+ { id: "sebaq", gameId: "snag", label: "Sebaq", onlineEligible: true },
74
+ // D-102: the race — relay-scored end to end; THE Snag board
75
+ // NOT a mode — the legacy spelling of «snag / normal» (see MODE_IDS). Kept so old clients' `MODES.find` still maps it.
76
+ { id: "snag", gameId: "snag", label: "Snag", onlineEligible: false, legacyAlias: true }
57
77
  ];
78
+ function internalGameOf(game, modeId) {
79
+ if (modeId === "sebaq" || game === "sebaq") return { gameId: "sebaq", modeId: void 0 };
80
+ if (modeId === "snag") return { gameId: "snag", modeId: "normal" };
81
+ if (modeId === "normal") return { gameId: "snag", modeId: "normal" };
82
+ return { gameId: game, modeId };
83
+ }
84
+ function wireShapeOf(gameId, modeId, pv) {
85
+ const v = pv ?? MIN_SUPPORTED_PV;
86
+ if (gameId === "sebaq") return v >= SNAG_MODES_MIN_PV ? { gameId: "snag", modeId: "sebaq" } : { gameId: gameIdForPv("sebaq", pv), modeId: null };
87
+ if (gameId === "snag") {
88
+ const m = modeId === "normal" || modeId === "snag" ? v >= SNAG_MODES_MIN_PV ? "normal" : "snag" : modeId;
89
+ return { gameId: gameIdForPv("snag", pv), modeId: m };
90
+ }
91
+ return { gameId: gameIdForPv(gameId, pv), modeId };
92
+ }
58
93
  var modesForGame = (gameId) => MODES.filter((m) => m.gameId === gameId);
59
94
  var EMOTE_IDS = ["laugh", "wow", "think", "gg"];
60
95
  var CHARACTER_IDS = ["m", "f"];
@@ -77,6 +112,7 @@ var HelpType = z.enum([
77
112
  var Pv = z.number().int().min(1).max(1e6).optional();
78
113
  var IdToken = z.string().max(4096).optional();
79
114
  var Platform = z.enum(["web", "ios", "android", "tv"]).optional();
115
+ var Games = z.array(z.enum(GAME_IDS)).max(GAME_IDS.length).optional();
80
116
  var AppVersion = z.string().trim().max(24).regex(/^[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]+)?$/, "bad app version").optional();
81
117
  var Avatar = z.object({
82
118
  emoji: z.string().min(1).max(16).transform((s) => s.replace(UNSAFE_CHARS, "")).refine((s) => s.length >= 1, "empty emoji"),
@@ -85,7 +121,7 @@ var Avatar = z.object({
85
121
  var Character = z.enum(CHARACTER_IDS);
86
122
  var ClientMsg = z.discriminatedUnion("t", [
87
123
  // room / lobby
88
- 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 }),
124
+ 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 }),
89
125
  // Host sets/changes their display name AFTER createRoom (for anonymous one-tap
90
126
  // hosts who started nameless). Host-only; the relay re-broadcasts the lobby.
91
127
  // SUPERSEDED by setProfile (kept working for shipped builds).
@@ -99,8 +135,8 @@ var ClientMsg = z.discriminatedUnion("t", [
99
135
  // view carries names+avatars so it updates live; dama/ersimha views are id-keyed (clients
100
136
  // map ids→names from the lobby roster), so a change there lands on the next lobby.
101
137
  z.object({ t: z.literal("setProfile"), name: DisplayName.optional(), avatar: Avatar.optional(), character: Character.optional() }),
102
- z.object({ t: z.literal("joinRoom"), code: RoomCode, name: DisplayName, avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
103
- 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 }),
138
+ 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 }),
139
+ 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 }),
104
140
  // `tally` (gs#46) = wins per PLAYER across every game this room has played tonight — «نتيجة الليلة».
105
141
  // Keyed by playerId, NOT by team: team names are recycled each game, so a per-team count means nothing
106
142
  // across a night. Survives playAgain. A One Device game credits nobody (its team slots hold no players).
@@ -315,7 +351,9 @@ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate",
315
351
  export {
316
352
  CHARACTER_IDS,
317
353
  COMEBACK_BONUS_MIN_PV,
354
+ COMEBACK_EFFECTS,
318
355
  ClientMsg,
356
+ EFFECT_MIN_PV,
319
357
  EMOTE_IDS,
320
358
  GAMES,
321
359
  GAME_IDS,
@@ -326,13 +364,19 @@ export {
326
364
  PROTOCOL_VERSION,
327
365
  REJECT_REASONS,
328
366
  ROOM_ALPHABET,
367
+ SEEDHA_MODE_IDS,
368
+ SNAG_MODES_MIN_PV,
369
+ SNAG_MODE_IDS,
329
370
  SUBMISSION_KINDS,
330
371
  SUBMISSION_STATUSES,
331
372
  effectsForPv,
332
373
  gameIdForPv,
333
374
  gamesForPv,
334
375
  hostActivityForPv,
376
+ internalGameOf,
335
377
  modesForGame,
378
+ modesOf,
336
379
  normalizeWesternDigits,
337
- sanitizeName
380
+ sanitizeName,
381
+ wireShapeOf
338
382
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegeem/protocol",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
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",