@thegeem/protocol 0.1.38 → 0.1.40

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.
@@ -0,0 +1,120 @@
1
+ {
2
+ "$meta": {
3
+ "note": "THE COPY RULES, AS DATA. Banned words and digit ranges that every player-facing string in Geem must obey — on every client, in every language. Read this file; never re-type a word list.",
4
+ "why": "«قعدة» reached the store as the Arabic NAME of the only item sellable for real money, past six gates, because all six inspected ART and no art check can see a name. The relay then wrote a TS scanner and offered to publish it — which would have served the relay and the site and left iOS (Swift), Android and TV (Kotlin) reimplementing. Four copies with a shared name on two of them is WORSE than four copies, because it looks unified.",
5
+ "rule": "When sharing across languages, ask what must not diverge. If it is a VALUE, ship the value. Shipping the implementation only works where the runtime is shared, and it silently excludes whoever is not on it — the failure mode where the excluded party does not notice they were excluded.",
6
+ "authority": "geem-coordination/DECISIONS.md — D-039 (banned words), D-043 (Western digits), D-073 (normalise at the WRITE gate, never at render)",
7
+ "scanners": "One per language, each small. The relay's is relay/src/copyRules.ts. ⚠️ A scanner must assert it MATCHED something: a scan whose success condition is an empty result set gets more reliable the more broken your tooling is — broken glob, moved path, changed extension, zero literals, passes forever, invisibly.",
8
+ "reportNeverRewrite": "These rules REPORT. They must not silently rewrite a string: a second normaliser makes two clients disagree about what a thing is CALLED, which is worse than the original problem (D-073). The one gate that transforms is sanitizeName, and it guards text a PLAYER typed — not text we wrote.",
9
+ "updated": "2026-08-16",
10
+ "honestLimit": "⚠️ THIS LIST IS PERMANENTLY ADVISORY. Arabic is inflected and an enumeration cannot be complete, so passing these checks is NOT evidence that copy is clean — it only means the forms someone has thought of are absent. THE REAL CONTROL IS A KUWAITI READING PLAYER-FACING ARABIC BEFORE IT SHIPS. Mistaking the list for coverage is exactly what let «قعدتك» through. Add forms when one is found; never treat the file as the reason you did not look.",
11
+ "factsFile": "protocol/copy-facts.json — CFO-owned, and it does not exist yet (gs#110). This file owns the MECHANICS and carries no numbers; facts live in a file that owns them, the same split as ECONOMY.md + earn-table.json. A scanner must treat its absence as «no facts published», never as «no facts apply»."
12
+ },
13
+ "bannedWords": [
14
+ {
15
+ "forms": [
16
+ "قعدة",
17
+ "القعدة",
18
+ "قعدتك",
19
+ "قعدتنا",
20
+ "قعدته",
21
+ "قعدتها",
22
+ "قعدتهم",
23
+ "قعدتي",
24
+ "قعدات",
25
+ "القعدات",
26
+ "قعدتكم",
27
+ "قعدتهن",
28
+ "قعدتين",
29
+ "القعدتين"
30
+ ],
31
+ "ruling": "D-039",
32
+ "why": "In Kuwait the word mostly reads as a sitting for alcohol and music — disqualifying for a family game. The ban is «قعدة» and its forms, and nothing else. Where a container noun is unavoidable, use «غرفة»; everywhere the player acts, use a VERB and no container noun at all.",
33
+ "instead": "«غرفة» is the single permitted noun, and only where forced — the code and hard failures. Buttons and flow text carry no container noun at all.",
34
+ "formsNote": "The ة→ت POSSESSIVE family is the gap that let «قعدتك» ship in a released App Store build while three desks each trusted a two-item list nobody had measured. Enumerated, never stem-matched: a «قعد» stem flags «المقعد» (a seat) and «يقعدون» (they sit), both present in our own repos today — that is the noisy-check failure pre-loaded into the guard, and it would have taught every desk to ignore the copy scan within a week. ⚠️ «قعدتكم» was NAMED by the iOS desk on 2026-08-16 as missing and was still missing four days later: the enumeration was extended once, by hand, from one desk's examples — and a hand-extended list is complete exactly up to the examples someone happened to give. The plural-you and dual forms were added 2026-08-20 for the same reason, before anyone reported them.",
35
+ "correction": "⚠️ THIS `why` USED TO CLAIM A BAN THIS FILE NEVER CARRIED, and the sentence is quoted here rather than left in place: it said the ruling «rejects the whole gathering-word category», naming ديوانية as men's and يمعة as women's. The DATA never carried it — one entry, fourteen forms, every one a form of «قعدة». Owner, verbatim 2026-08-25: «I didn't banned مجالس i only banned قعدة». The gendered-words note was always the reason no other gathering word was picked AS THE REPLACEMENT — none is gender-neutral, so none works as a container noun — and «rejected as a replacement» is not «banned from all copy». D-077 carries the owner's companion rulings from the same week: «الديوانية» STAYS (it names the place drawn in the art) and «مجلس» is RENAMED. A category ban cannot survive its own author keeping one member of the category and merely re-labelling another. Corrected here 2026-08-26; the ledger row is D-093. ⚠️ IT IS A SEPARATE FIELD FROM `why` ON PURPOSE — `why` is what findings PRINT and what desks quote, so it must be the rule as it stands and nothing else. History in that string is how a retracted claim keeps circulating.",
36
+ "whyThisMattersBeyondOneWord": "⚠️ PROSE INSIDE A RULES FILE IS THE MOST DANGEROUS PROSE IN THE PROJECT — it is the only prose that inherits a checker's credibility. The file was correct exactly where it is enforceable and wrong exactly where it is narration, which is the worst split available. It produced a real wrong ruling: the CFO ruled «مجالس» banned in the 1.6.2 App Store listing, reading D-039's row CORRECTLY, and the owner overturned them personally. They did not misread — they read it right and it was wrong. The shape: a rules file has a reader and cannot overreach, a form is banned or it is not. A ledger row has no reader and can say anything. When they disagree, the row is what one desk quotes at another. IF `why` AND `forms` EVER DISAGREE AGAIN, FORMS WIN — they are what runs."
37
+ }
38
+ ],
39
+ "digits": {
40
+ "ruling": "D-043",
41
+ "why": "A digit is a glyph of a number, not identity. «سالم ١٧» and «سالم 17» are the same name for the same person, so normalising changes how a name is DRAWN, never who it refers to. Letters are never touched.",
42
+ "bannedRanges": [
43
+ {
44
+ "name": "Arabic-Indic",
45
+ "from": "٠",
46
+ "to": "٩"
47
+ },
48
+ {
49
+ "name": "Extended Arabic-Indic (Persian)",
50
+ "from": "۰",
51
+ "to": "۹"
52
+ }
53
+ ],
54
+ "use": "Western 0-9, every surface, every language."
55
+ },
56
+ "concepts": [
57
+ {
58
+ "id": "room",
59
+ "ruling": "D-039",
60
+ "requiredTerm": "غرفة",
61
+ "bannedForms": [
62
+ "قعدة",
63
+ "القعدة",
64
+ "قعدتك",
65
+ "قعدتنا",
66
+ "قعدته",
67
+ "قعدتها",
68
+ "قعدتهم",
69
+ "قعدتي",
70
+ "قعدات",
71
+ "القعدات",
72
+ "قعدتكم",
73
+ "قعدتهن",
74
+ "قعدتين",
75
+ "القعدتين"
76
+ ],
77
+ "scope": "only where a container noun is unavoidable — the code and hard failures. Buttons and flow text carry no container noun at all.",
78
+ "why": "A ban can only say NO. This says what to write instead, as DATA rather than prose, so a Swift or Kotlin scanner can act on it — `bannedWords[].instead` was already correct and already travelled with findings in the TS scanner, but it is a sentence, and a sentence is not a field.",
79
+ "whatItStillCannotDo": "⚠️ A required term only helps for a concept somebody has NAMED. It cannot notice that a new concept needs one. `$meta.honestLimit` still governs: this file is permanently advisory about words nobody has thought of.",
80
+ "⚠️ WHY «ديوانية» AND «يمعة» ARE NOT IN bannedForms": "The CFO's spec for this field (gs#110) listed «الليوان», «ليوان», «ديوانية» and «يمعة» as banned forms. **Adding them would have re-committed the exact error the owner corrected the day before.** Owner, verbatim 2026-08-25: «I didn't banned مجالس i only banned قعدة», and D-077 keeps «الديوانية» by name because it names the place drawn in the art. The CFO wrote their spec before that correction reached them — see `bannedWords[0].correction`. **This is the mechanism they asked for, doing the job they wanted, without the ban they did not have the authority to add.** «الليوان» is not banned either: it is wrong because it is not «غرفة», which is precisely what requiredTerm is for. A required term does the work a wider ban list was reaching for, and does it without banning words the owner permits."
81
+ }
82
+ ],
83
+ "noAbsolutePrices": {
84
+ "ruling": "CFO 2026-08-25 (gs#110)",
85
+ "why": "Apple renders the real localized price. A number written into copy is wrong in most territories and drifts from ASC the moment either changes. Measured from ASC: yearly Pro is $99.99 in the USA and 149.99 in Australia — one sentence naming an amount is wrong for most of the region the day it is written.",
86
+ "whyItIsAPROHIBITIONandNotAFACT": "⚠️ THE SHAPE IS THE POINT. iOS said «if the listing says $9.99 a month, I cannot evaluate that — only you know what is really sold.» Handing them the number would have been the obvious answer and the wrong one: a money fact copied into a second place drifts. A prohibition dissolves the problem instead — **any desk can check it with no CFO knowledge at all**, because it needs to know nothing except that a currency sits next to a numeral.",
87
+ "appliesTo": [
88
+ "App Store description",
89
+ "release notes",
90
+ "screenshots",
91
+ "in-app marketing copy"
92
+ ],
93
+ "currencyMarks": [
94
+ "$",
95
+ "€",
96
+ "£",
97
+ "KWD",
98
+ "USD",
99
+ "SAR",
100
+ "AED",
101
+ "QAR",
102
+ "BHD",
103
+ "OMR",
104
+ "EGP",
105
+ "د.ك",
106
+ "ر.س",
107
+ "د.إ",
108
+ "ر.ق",
109
+ "د.ب",
110
+ "ر.ع",
111
+ "ج.م",
112
+ "دينار",
113
+ "ريال",
114
+ "درهم",
115
+ "دولار"
116
+ ],
117
+ "instead": "name what it GRANTS, never what it costs. The store sheet carries the price, correctly, in the reader’s own currency.",
118
+ "notPlayerCoinAmounts": "⚠️ «600 دانات» IS NOT A PRICE UNDER THIS RULE and must never be flagged as one. دانات are an in-game currency this desk mints and can prove; the rule is about REAL money in a territory Apple prices. Flagging coin amounts would make the check red on the entire store and it would be switched off within a day."
119
+ }
120
+ }
package/dist/index.cjs CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CHARACTER_IDS: () => CHARACTER_IDS,
24
+ COMEBACK_BONUS_MIN_PV: () => COMEBACK_BONUS_MIN_PV,
24
25
  ClientMsg: () => ClientMsg,
25
26
  EMOTE_IDS: () => EMOTE_IDS,
26
27
  GAMES: () => GAMES,
@@ -34,8 +35,10 @@ __export(index_exports, {
34
35
  ROOM_ALPHABET: () => ROOM_ALPHABET,
35
36
  SUBMISSION_KINDS: () => SUBMISSION_KINDS,
36
37
  SUBMISSION_STATUSES: () => SUBMISSION_STATUSES,
38
+ effectsForPv: () => effectsForPv,
37
39
  gameIdForPv: () => gameIdForPv,
38
40
  gamesForPv: () => gamesForPv,
41
+ hostActivityForPv: () => hostActivityForPv,
39
42
  modesForGame: () => modesForGame,
40
43
  normalizeWesternDigits: () => normalizeWesternDigits,
41
44
  sanitizeName: () => sanitizeName
@@ -51,9 +54,9 @@ var LIMITS = {
51
54
  * Clients batch pen points to stay under this — ~150ms batching lands at ~7/sec. */
52
55
  STROKE_BATCH_PER_SEC: 20
53
56
  };
54
- var PROTOCOL_VERSION = 4;
57
+ var PROTOCOL_VERSION = 7;
55
58
  var MIN_SUPPORTED_PV = 1;
56
- var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha"];
59
+ var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"];
57
60
  var GAMES = [
58
61
  { id: "seedha", label: "Seedha", minPv: 2 },
59
62
  { id: "snag", label: "Snag", minPv: 2 },
@@ -61,8 +64,30 @@ var GAMES = [
61
64
  // engine #2 (D-027) — offered only to pv≥3 clients
62
65
  { id: "ersimha", label: "Ersimha", minPv: 4 },
63
66
  // engine #3 (D-030) draw-and-guess — offered only to pv≥4 clients
67
+ // ⚠️ A NEW GAME, NOT A MODE OF SNAG, AND THE NAME COLLISION IS WHY IT HAD TO BE. `buzzerMode: 'race'`
68
+ // ALREADY MEANS ديوانية's buzz-race — everyone racing to buzz for ONE shared question. Sebaq is the
69
+ // opposite: no shared question, no buzzer, no turns. Every player runs the same set privately, at
70
+ // their own speed. Putting a mode called `race` beside a buzzerMode called `race`, meaning opposite
71
+ // things, one string apart on the wire, is the worst naming available here.
72
+ //
73
+ // It is also the wrong SHAPE for a mode: every MODES row is a way of playing seedha's turn loop —
74
+ // one question, one board, a shared clock. Sebaq uses none of it. A mode that shares no mechanic
75
+ // with its game is a game.
76
+ //
77
+ // ✅ NAME RULED BY THE OWNER, 2026-08-25: «سباق sebaq». Renamed from the provisional `sabag` before
78
+ // any client had shipped the string — which was the entire reason for saying it was provisional.
79
+ //
80
+ // ⚠️ `q` IS ق, AND IT IS NOT AN EXCEPTION TO THE BRAND RULE — I filed it as one and the owner
81
+ // corrected me. `CLAUDE.md`'s «Najdi ق = g» is about how ق SOUNDS in قييم; `q` is what the LETTER
82
+ // is. Two different questions, and `sebaq` answers the second. **Do not "fix" this to `sebag`.**
83
+ { id: "sebaq", label: "Sebaq", minPv: 5 },
84
+ // سباق — the race tile, offered only to pv≥5 clients
64
85
  { id: "trivia", label: "Trivia", minPv: 1, deprecated: true }
65
86
  ];
87
+ var hostActivityForPv = (activity, pv) => activity === "options" && (pv ?? MIN_SUPPORTED_PV) < 6 ? "configuring" : activity;
88
+ 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));
66
91
  var gamesForPv = (pv) => GAMES.filter((g) => !g.deprecated && g.minPv <= pv);
67
92
  var gameIdForPv = (gameId, pv) => {
68
93
  const g = GAMES.find((x) => x.id === gameId);
@@ -139,6 +164,42 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
139
164
  import_zod.z.object({ t: import_zod.z.literal("kick"), playerId: PlayerId }),
140
165
  // Host's IN-PROGRESS category selection in the lobby — a live preview for waiting
141
166
  // players. Distinct from startGame.categoryIds, which COMMITS the board. Empty = cleared.
167
+ // ⚠️ AN EMPTY `categoryIds` IS A MEANINGFUL MESSAGE — DO NOT ADD `.min(1)`.
168
+ //
169
+ // The host sends `setCategories([])` the moment they press «التالي», before picking anything.
170
+ //
171
+ // ⚠️ THE THREE-STATE READING RECORDED IN a33f305 IS NOT DELIVERABLE ON THIS WIRE, AND A CLIENT
172
+ // WRITTEN TO IT WILL BE «FIXED» IN THE WRONG DIRECTION. It said:
173
+ // absent → still in the lobby
174
+ // present but EMPTY → the host is choosing
175
+ // non-empty → the picks card
176
+ // The first state does not exist. `socket.ts` creates every room with `selectedCategoryIds: []`
177
+ // and `rooms.ts` serialises it on EVERY lobby frame, so a joiner's very first frame already carries
178
+ // `[]` — byte-identical to the host having pressed «التالي». QA measured it on prod (room J7UY,
179
+ // raw frames in geem-qa `findings/sebaq/race2.jsonl`): the joiner's lobby before and after the
180
+ // host's empty `setCategories` differ in nothing on this field. On iOS the visible result is that
181
+ // joiners read «يختار الفئات» the moment they land. (geem-server#118.)
182
+ //
183
+ // ⚠️ `hostActivity` IS THE CARRIER FOR «THE HOST IS CHOOSING» — it is the field built for this exact
184
+ // question and it distinguishes the state this one cannot: `'options'` (the host is on the settings
185
+ // surface) vs `'configuring'` vs `'idle'`. Read THAT for the waiting-screen line; read
186
+ // `selectedCategoryIds` only for WHICH categories, never for WHETHER the host has begun.
187
+ //
188
+ // What stays true from a33f305, and is still worth its test: an empty array must PARSE. Adding
189
+ // `.min(1)` would reject a message the client is right to send.
190
+ //
191
+ // Without it, joiners sat on «ناطرين … يبدأ اللعبة» for as long as the host browsed and only learned
192
+ // anything had happened when the FIRST pick landed (owner, 2026-08-26; fixed by the iOS desk).
193
+ //
194
+ // ⚠️ THE FAILURE IF SOMEONE TIGHTENS THIS IS SILENT AND LOOKS LIKE A CLIENT BUG. Nothing crashes:
195
+ // every joiner in every category game just goes back to «waiting to start» until the first pick.
196
+ // The relay would be rejecting a message the client is right to send, and the search would start on
197
+ // the client. Applies to صيدها, بالدور and سباق alike — they share one lobby.
198
+ // The host's verdict on a comeback offer (owner ruling 2026-08-27). `grant: false` is a real and
199
+ // expected outcome — «if he refuse then he will not get it» — and closes the offer with nothing.
200
+ // ⚠️ HOST-ONLY, and the relay enforces it. A trailing team that could grant its own bonus is not a
201
+ // bonus, it is a button that says «+100».
202
+ import_zod.z.object({ t: import_zod.z.literal("comebackDecide"), grant: import_zod.z.boolean() }),
142
203
  import_zod.z.object({ t: import_zod.z.literal("setCategories"), categoryIds: import_zod.z.array(import_zod.z.string().max(60)).max(12) }),
143
204
  // Host defines the lobby teams players can join (0–12; empty = disband back to the
144
205
  // one-team-per-player default). Resets picks that fall out of range.
@@ -155,10 +216,32 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
155
216
  // `modeId` alone still works (pre-gs#49 clients); `game` is what lets a host announce a tile with
156
217
  // no modes (dama/ersimha), which `modeId` could never express. An incoherent pair (a mode that
157
218
  // doesn't belong to the game) is rejected with `bad_mode` rather than silently resolved.
219
+ // ── Sebaq slice (gameId 'sebaq', the RACE tile) ──
220
+ // ⚠️ THE SERVER JUDGES AND THE SERVER TIMES. The client sends which option was tapped and nothing
221
+ // else — no «correct», no elapsed ms. A client-reported time makes the best connection win, and a
222
+ // client-reported verdict makes the race unwinnable honestly.
223
+ import_zod.z.object({ t: import_zod.z.literal("sebaqAnswer"), optionIndex: import_zod.z.number().int().min(0).max(5) }),
224
+ // Rule 3: after ONE wrong answer the player chooses. `sebaqRetry` re-opens the SAME question for
225
+ // half points; `sebaqSkip` takes 0 and moves on. Two tiers only (rule 4) — a second wrong answer
226
+ // needs no message, it just ends the question.
227
+ import_zod.z.object({ t: import_zod.z.literal("sebaqRetry") }),
228
+ import_zod.z.object({ t: import_zod.z.literal("sebaqSkip") }),
158
229
  import_zod.z.object({ t: import_zod.z.literal("setMode"), game: import_zod.z.enum(GAME_IDS).optional(), modeId: import_zod.z.enum(MODE_IDS).optional() }),
159
230
  // Host signals it's busy configuring the game (categories/rounds/mode) so waiting players can show
160
231
  // a "host is setting up…" state instead of a frozen-looking lobby. 'idle' when the settings close.
161
- import_zod.z.object({ t: import_zod.z.literal("hostActivity"), activity: import_zod.z.enum(["configuring", "idle"]) }),
232
+ // ⚠️ THREE VALUES SINCE 2026-08-26 (Options page, geem-coordination#24). The host's pre-game
233
+ // journey is Lobby → Options → Categories, and joiners route on this:
234
+ // idle → the lobby
235
+ // options → «how we play» — the per-game settings page, read-only for joiners
236
+ // configuring → «… يختار الفئات» — the category picker
237
+ //
238
+ // ⚠️ THE RELAY MUST ACCEPT `options` BEFORE ANY CLIENT SENDS IT, which is why this ships ahead of
239
+ // the page: a new client sending it to an old relay is a `bad_message`, the activity stays whatever
240
+ // it was, and every joiner shows the wrong banner with nothing erroring.
241
+ import_zod.z.object({ t: import_zod.z.literal("hostActivity"), activity: import_zod.z.enum(["options", "configuring", "idle"]) }),
242
+ // ⚠️ AND IT IS ADAPTED ON THE WAY OUT (D-025), which is what makes it shippable without a
243
+ // coordinated release. `hostActivityForPv` below down-maps `options` → `configuring` for any client
244
+ // below pv6 — see there for the failure it prevents.
162
245
  // host game actions
163
246
  import_zod.z.object({
164
247
  t: import_zod.z.literal("startGame"),
@@ -277,6 +360,7 @@ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate",
277
360
  // Annotate the CommonJS export names for ESM import in node:
278
361
  0 && (module.exports = {
279
362
  CHARACTER_IDS,
363
+ COMEBACK_BONUS_MIN_PV,
280
364
  ClientMsg,
281
365
  EMOTE_IDS,
282
366
  GAMES,
@@ -290,8 +374,10 @@ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate",
290
374
  ROOM_ALPHABET,
291
375
  SUBMISSION_KINDS,
292
376
  SUBMISSION_STATUSES,
377
+ effectsForPv,
293
378
  gameIdForPv,
294
379
  gamesForPv,
380
+ hostActivityForPv,
295
381
  modesForGame,
296
382
  normalizeWesternDigits,
297
383
  sanitizeName
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 = 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,43 @@ 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
+ declare const effectsForPv: (effects: Effect[], pv: number | undefined) => Effect[];
63
100
  declare const gamesForPv: (pv: number) => GameInfo[];
64
101
  /** The gameId to SEND a client of version `pv`: new games need pv >= their minPv; older clients see 'trivia'. */
65
102
  declare const gameIdForPv: (gameId: GameId, pv: number | undefined) => GameId;
@@ -134,7 +171,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
134
171
  emoji: string;
135
172
  color: string;
136
173
  }>>;
137
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
174
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
138
175
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
139
176
  pv: z.ZodOptional<z.ZodNumber>;
140
177
  idToken: z.ZodOptional<z.ZodString>;
@@ -147,7 +184,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
147
184
  emoji: string;
148
185
  color: string;
149
186
  } | undefined;
150
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
187
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
151
188
  character?: "m" | "f" | undefined;
152
189
  pv?: number | undefined;
153
190
  idToken?: string | undefined;
@@ -160,7 +197,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
160
197
  emoji: string;
161
198
  color: string;
162
199
  } | undefined;
163
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
200
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
164
201
  character?: "m" | "f" | undefined;
165
202
  pv?: number | undefined;
166
203
  idToken?: string | undefined;
@@ -340,6 +377,15 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
340
377
  }, {
341
378
  t: "kick";
342
379
  playerId: string;
380
+ }>, z.ZodObject<{
381
+ t: z.ZodLiteral<"comebackDecide">;
382
+ grant: z.ZodBoolean;
383
+ }, "strip", z.ZodTypeAny, {
384
+ t: "comebackDecide";
385
+ grant: boolean;
386
+ }, {
387
+ t: "comebackDecide";
388
+ grant: boolean;
343
389
  }>, z.ZodObject<{
344
390
  t: z.ZodLiteral<"setCategories">;
345
391
  categoryIds: z.ZodArray<z.ZodString, "many">;
@@ -367,30 +413,51 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
367
413
  }, {
368
414
  t: "pickTeam";
369
415
  teamIndex: number;
416
+ }>, z.ZodObject<{
417
+ t: z.ZodLiteral<"sebaqAnswer">;
418
+ optionIndex: z.ZodNumber;
419
+ }, "strip", z.ZodTypeAny, {
420
+ t: "sebaqAnswer";
421
+ optionIndex: number;
422
+ }, {
423
+ t: "sebaqAnswer";
424
+ optionIndex: number;
425
+ }>, z.ZodObject<{
426
+ t: z.ZodLiteral<"sebaqRetry">;
427
+ }, "strip", z.ZodTypeAny, {
428
+ t: "sebaqRetry";
429
+ }, {
430
+ t: "sebaqRetry";
431
+ }>, z.ZodObject<{
432
+ t: z.ZodLiteral<"sebaqSkip">;
433
+ }, "strip", z.ZodTypeAny, {
434
+ t: "sebaqSkip";
435
+ }, {
436
+ t: "sebaqSkip";
370
437
  }>, z.ZodObject<{
371
438
  t: z.ZodLiteral<"setMode">;
372
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
439
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
373
440
  modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
374
441
  }, "strip", z.ZodTypeAny, {
375
442
  t: "setMode";
376
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
443
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
377
444
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
378
445
  }, {
379
446
  t: "setMode";
380
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
447
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
381
448
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
382
449
  }>, z.ZodObject<{
383
450
  t: z.ZodLiteral<"hostActivity">;
384
- activity: z.ZodEnum<["configuring", "idle"]>;
451
+ activity: z.ZodEnum<["options", "configuring", "idle"]>;
385
452
  }, "strip", z.ZodTypeAny, {
386
453
  t: "hostActivity";
387
- activity: "configuring" | "idle";
454
+ activity: "options" | "configuring" | "idle";
388
455
  }, {
389
456
  t: "hostActivity";
390
- activity: "configuring" | "idle";
457
+ activity: "options" | "configuring" | "idle";
391
458
  }>, z.ZodObject<{
392
459
  t: z.ZodLiteral<"startGame">;
393
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
460
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
394
461
  totalRounds: z.ZodNumber;
395
462
  categoryIds: z.ZodArray<z.ZodString, "many">;
396
463
  hostPlays: z.ZodOptional<z.ZodBoolean>;
@@ -409,7 +476,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
409
476
  t: "startGame";
410
477
  categoryIds: string[];
411
478
  totalRounds: number;
412
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
479
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
413
480
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
414
481
  hostPlays?: boolean | undefined;
415
482
  players?: string[] | undefined;
@@ -422,7 +489,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
422
489
  t: "startGame";
423
490
  categoryIds: string[];
424
491
  totalRounds: number;
425
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
492
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
426
493
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
427
494
  hostPlays?: boolean | undefined;
428
495
  players?: string[] | undefined;
@@ -901,6 +968,26 @@ interface GameStateView {
901
968
  * paused (e.g. steal intro), or after time-up. Present ⇒ animate a countdown from
902
969
  * it; the server remains the authority on actual time-up. */
903
970
  timerRemainingMs?: number;
971
+ /**
972
+ * A comeback bonus awaiting the host's verdict (gs#123), or ABSENT when there is none.
973
+ *
974
+ * ⚠️ IT IS STATE, NOT ONLY AN EFFECT, AND THE DIFFERENCE IS A WEDGE. `comebackOffer` arrives as an
975
+ * `fx` — and `fx` is transient and never replayed. So a host who reconnects between the offer and
976
+ * their verdict would come back to a state with no mention of it: no card, no buttons, and no way
977
+ * to resolve. The server would still hold the pending offer, and a pending offer BLOCKS the next
978
+ * one, so the feature would be permanently dead for the rest of that game with nothing in any log.
979
+ *
980
+ * Found by the iOS desk asking «a resume snapshot after the accept carries the new score only,
981
+ * yes?» — a question about the happy path, whose answer exposed the unhappy one.
982
+ *
983
+ * ABSENT rather than null when there is no offer: the card is either on screen or it is not, and an
984
+ * optional key is the shape a client already has to handle for `timerRemainingMs`.
985
+ */
986
+ comebackOffer?: {
987
+ teamId: string;
988
+ questionText: string;
989
+ scoreGapBeforeBonus: number;
990
+ };
904
991
  }
905
992
  /** Transient presentation effects the relay emits; clients play them once. */
906
993
  type Effect = {
@@ -931,6 +1018,21 @@ type Effect = {
931
1018
  t: 'scorePop';
932
1019
  amount: number;
933
1020
  streakBonus: number;
1021
+ } | {
1022
+ t: 'comebackBonus';
1023
+ teamId: string;
1024
+ points: number;
1025
+ previousScore: number;
1026
+ newScore: number;
1027
+ scoreGapBeforeBonus: number;
1028
+ } | {
1029
+ t: 'comebackOffer';
1030
+ teamId: string;
1031
+ questionText: string;
1032
+ scoreGapBeforeBonus: number;
1033
+ } | {
1034
+ t: 'comebackDeclined';
1035
+ teamId: string;
934
1036
  } | {
935
1037
  t: 'chargeCredit';
936
1038
  } | {
@@ -989,6 +1091,73 @@ interface DamaStateView {
989
1091
  moveCount: number;
990
1092
  black?: string;
991
1093
  white?: string;
1094
+ /**
1095
+ * When the side to move runs out of time, as absolute epoch ms — render it as a countdown.
1096
+ *
1097
+ * دامة is strictly 1v1, so ONE absent player stops the board completely: every other engine bounded
1098
+ * its equivalent and this one did not, which left a stalled game sitting until the 30-minute room
1099
+ * reaper. On expiry the game ends with NO winner and NO rating change — the same ruling as a
1100
+ * mid-game kick (2026-08-08): counting an abandonment as a win would let someone farm ELO by
1101
+ * walking away from a losing board.
1102
+ *
1103
+ * ADDITIVE and optional: absent while a bot is to move, after the game is over, and from any older
1104
+ * relay. A client that ignores it is correct, just less kind — it simply sees the game end.
1105
+ */
1106
+ moveDeadlineMs?: number;
1107
+ }
1108
+ /**
1109
+ * سباق — the race. Everyone runs the SAME questions, in the SAME order, privately, at their own speed.
1110
+ *
1111
+ * ⚠️ WHY THIS IS A RELAY GAME AND NOT A LOCAL ONE, in one sentence from the iOS desk: **timing cannot
1112
+ * be client-reported or the player with the best connection wins.** Every advance is stamped by the
1113
+ * server, the question set is dealt by the server, and a client never asks for a question — that last
1114
+ * one is how two players would otherwise end up racing different sets.
1115
+ *
1116
+ * The rules, all the owner's:
1117
+ * 1. same questions, same order, for every player — it is what makes «who finished first» mean anything
1118
+ * 2. right on the first tap → FULL points
1119
+ * 3. wrong → the player CHOOSES: try again for HALF, or move on. The cost of a retry is TIME, which
1120
+ * is why it needs no extra penalty and why the choice changes depending on whether you are ahead
1121
+ * 4. wrong twice → 0 for that question, next question. ⚠️ TWO TIERS ONLY — full → half → gone is
1122
+ * something a child can hold; a third tier turns it into arithmetic. Do not add one.
1123
+ * 5. finishing faster → BONUS points
1124
+ * 6. 🔴 HIGHEST SCORE WINS. SPEED ADDS, IT NEVER OVERRIDES. A player who mashes through in 30
1125
+ * seconds finishes first and still loses — that is the intended outcome and it is load-bearing.
1126
+ * 7. live standings visible during play
1127
+ * 8. the host races too
1128
+ *
1129
+ * ⚠️ QUESTION COUNT IS NOT FIXED: it is the selected categories × 6 levels (owner, 2026-08-25 —
1130
+ * «Questions are as players selected categories»). «3 categories» was his example, not the rule.
1131
+ */
1132
+ interface SebaqStandingRow {
1133
+ playerId: string;
1134
+ /** How many questions this player has FINISHED (not the one they are on). */
1135
+ done: number;
1136
+ score: number;
1137
+ /** Set only when they have finished the whole set — the bonus is not knowable before then. */
1138
+ speedBonus?: number;
1139
+ finished: boolean;
1140
+ }
1141
+ interface SebaqStateView {
1142
+ /** Total questions in this race = categories × 6. Every player runs all of them. */
1143
+ total: number;
1144
+ /** Live standings for everyone, always visible (rule 7). Ordered by the server, best first. */
1145
+ standings: SebaqStandingRow[];
1146
+ /** Set when every racer has finished. Ties on total score break on base score, then on time. */
1147
+ winnerId?: string | null;
1148
+ /** ⚠️ YOUR question only, and only the one you are ON. Never another player's, never the next one —
1149
+ * a client that could see ahead could pre-read while others cannot, which is the whole race. */
1150
+ you?: {
1151
+ index: number;
1152
+ /** ⚠️ The SAME anti-cheat boundary as trivia's: `correctAnswerIndex` is null while the question is
1153
+ * live and set only on reveal. In a race the temptation is stronger — every player holds a live
1154
+ * question at the same moment, so a leaked answer index is a leak to everyone at once. */
1155
+ question: QuestionView;
1156
+ /** true once you have answered wrong ONCE on this question: the half-or-skip choice is live. */
1157
+ retryOffered: boolean;
1158
+ score: number;
1159
+ finished: boolean;
1160
+ };
992
1161
  }
993
1162
  /**
994
1163
  * The authoritative Ersimha round state. Anti-cheat boundary (D-030): `word` is set ONLY in the
@@ -1065,7 +1234,7 @@ type ServerMsg = {
1065
1234
  packs?: PackView[];
1066
1235
  selectedCategoryIds: string[];
1067
1236
  teams: TeamSlotView[];
1068
- hostActivity: 'configuring' | 'idle';
1237
+ hostActivity: 'options' | 'configuring' | 'idle';
1069
1238
  tally?: {
1070
1239
  [playerId: string]: number;
1071
1240
  };
@@ -1087,6 +1256,12 @@ type ServerMsg = {
1087
1256
  view: DamaStateView;
1088
1257
  you: YouContext;
1089
1258
  roster?: RosterEntry[];
1259
+ } | {
1260
+ t: 'sebaqState';
1261
+ gameId: 'sebaq';
1262
+ view: SebaqStateView;
1263
+ you: YouContext;
1264
+ roster?: RosterEntry[];
1090
1265
  } | {
1091
1266
  t: 'ersimhaState';
1092
1267
  gameId: 'ersimha';
@@ -1165,4 +1340,4 @@ interface MyQuestionRow {
1165
1340
  };
1166
1341
  }
1167
1342
 
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 };
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 };
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,43 @@ 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
+ declare const effectsForPv: (effects: Effect[], pv: number | undefined) => Effect[];
63
100
  declare const gamesForPv: (pv: number) => GameInfo[];
64
101
  /** The gameId to SEND a client of version `pv`: new games need pv >= their minPv; older clients see 'trivia'. */
65
102
  declare const gameIdForPv: (gameId: GameId, pv: number | undefined) => GameId;
@@ -134,7 +171,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
134
171
  emoji: string;
135
172
  color: string;
136
173
  }>>;
137
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
174
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
138
175
  character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
139
176
  pv: z.ZodOptional<z.ZodNumber>;
140
177
  idToken: z.ZodOptional<z.ZodString>;
@@ -147,7 +184,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
147
184
  emoji: string;
148
185
  color: string;
149
186
  } | undefined;
150
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
187
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
151
188
  character?: "m" | "f" | undefined;
152
189
  pv?: number | undefined;
153
190
  idToken?: string | undefined;
@@ -160,7 +197,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
160
197
  emoji: string;
161
198
  color: string;
162
199
  } | undefined;
163
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
200
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
164
201
  character?: "m" | "f" | undefined;
165
202
  pv?: number | undefined;
166
203
  idToken?: string | undefined;
@@ -340,6 +377,15 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
340
377
  }, {
341
378
  t: "kick";
342
379
  playerId: string;
380
+ }>, z.ZodObject<{
381
+ t: z.ZodLiteral<"comebackDecide">;
382
+ grant: z.ZodBoolean;
383
+ }, "strip", z.ZodTypeAny, {
384
+ t: "comebackDecide";
385
+ grant: boolean;
386
+ }, {
387
+ t: "comebackDecide";
388
+ grant: boolean;
343
389
  }>, z.ZodObject<{
344
390
  t: z.ZodLiteral<"setCategories">;
345
391
  categoryIds: z.ZodArray<z.ZodString, "many">;
@@ -367,30 +413,51 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
367
413
  }, {
368
414
  t: "pickTeam";
369
415
  teamIndex: number;
416
+ }>, z.ZodObject<{
417
+ t: z.ZodLiteral<"sebaqAnswer">;
418
+ optionIndex: z.ZodNumber;
419
+ }, "strip", z.ZodTypeAny, {
420
+ t: "sebaqAnswer";
421
+ optionIndex: number;
422
+ }, {
423
+ t: "sebaqAnswer";
424
+ optionIndex: number;
425
+ }>, z.ZodObject<{
426
+ t: z.ZodLiteral<"sebaqRetry">;
427
+ }, "strip", z.ZodTypeAny, {
428
+ t: "sebaqRetry";
429
+ }, {
430
+ t: "sebaqRetry";
431
+ }>, z.ZodObject<{
432
+ t: z.ZodLiteral<"sebaqSkip">;
433
+ }, "strip", z.ZodTypeAny, {
434
+ t: "sebaqSkip";
435
+ }, {
436
+ t: "sebaqSkip";
370
437
  }>, z.ZodObject<{
371
438
  t: z.ZodLiteral<"setMode">;
372
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
439
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
373
440
  modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
374
441
  }, "strip", z.ZodTypeAny, {
375
442
  t: "setMode";
376
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
443
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
377
444
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
378
445
  }, {
379
446
  t: "setMode";
380
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
447
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
381
448
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
382
449
  }>, z.ZodObject<{
383
450
  t: z.ZodLiteral<"hostActivity">;
384
- activity: z.ZodEnum<["configuring", "idle"]>;
451
+ activity: z.ZodEnum<["options", "configuring", "idle"]>;
385
452
  }, "strip", z.ZodTypeAny, {
386
453
  t: "hostActivity";
387
- activity: "configuring" | "idle";
454
+ activity: "options" | "configuring" | "idle";
388
455
  }, {
389
456
  t: "hostActivity";
390
- activity: "configuring" | "idle";
457
+ activity: "options" | "configuring" | "idle";
391
458
  }>, z.ZodObject<{
392
459
  t: z.ZodLiteral<"startGame">;
393
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
460
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
394
461
  totalRounds: z.ZodNumber;
395
462
  categoryIds: z.ZodArray<z.ZodString, "many">;
396
463
  hostPlays: z.ZodOptional<z.ZodBoolean>;
@@ -409,7 +476,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
409
476
  t: "startGame";
410
477
  categoryIds: string[];
411
478
  totalRounds: number;
412
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
479
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
413
480
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
414
481
  hostPlays?: boolean | undefined;
415
482
  players?: string[] | undefined;
@@ -422,7 +489,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
422
489
  t: "startGame";
423
490
  categoryIds: string[];
424
491
  totalRounds: number;
425
- game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
492
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
426
493
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
427
494
  hostPlays?: boolean | undefined;
428
495
  players?: string[] | undefined;
@@ -901,6 +968,26 @@ interface GameStateView {
901
968
  * paused (e.g. steal intro), or after time-up. Present ⇒ animate a countdown from
902
969
  * it; the server remains the authority on actual time-up. */
903
970
  timerRemainingMs?: number;
971
+ /**
972
+ * A comeback bonus awaiting the host's verdict (gs#123), or ABSENT when there is none.
973
+ *
974
+ * ⚠️ IT IS STATE, NOT ONLY AN EFFECT, AND THE DIFFERENCE IS A WEDGE. `comebackOffer` arrives as an
975
+ * `fx` — and `fx` is transient and never replayed. So a host who reconnects between the offer and
976
+ * their verdict would come back to a state with no mention of it: no card, no buttons, and no way
977
+ * to resolve. The server would still hold the pending offer, and a pending offer BLOCKS the next
978
+ * one, so the feature would be permanently dead for the rest of that game with nothing in any log.
979
+ *
980
+ * Found by the iOS desk asking «a resume snapshot after the accept carries the new score only,
981
+ * yes?» — a question about the happy path, whose answer exposed the unhappy one.
982
+ *
983
+ * ABSENT rather than null when there is no offer: the card is either on screen or it is not, and an
984
+ * optional key is the shape a client already has to handle for `timerRemainingMs`.
985
+ */
986
+ comebackOffer?: {
987
+ teamId: string;
988
+ questionText: string;
989
+ scoreGapBeforeBonus: number;
990
+ };
904
991
  }
905
992
  /** Transient presentation effects the relay emits; clients play them once. */
906
993
  type Effect = {
@@ -931,6 +1018,21 @@ type Effect = {
931
1018
  t: 'scorePop';
932
1019
  amount: number;
933
1020
  streakBonus: number;
1021
+ } | {
1022
+ t: 'comebackBonus';
1023
+ teamId: string;
1024
+ points: number;
1025
+ previousScore: number;
1026
+ newScore: number;
1027
+ scoreGapBeforeBonus: number;
1028
+ } | {
1029
+ t: 'comebackOffer';
1030
+ teamId: string;
1031
+ questionText: string;
1032
+ scoreGapBeforeBonus: number;
1033
+ } | {
1034
+ t: 'comebackDeclined';
1035
+ teamId: string;
934
1036
  } | {
935
1037
  t: 'chargeCredit';
936
1038
  } | {
@@ -989,6 +1091,73 @@ interface DamaStateView {
989
1091
  moveCount: number;
990
1092
  black?: string;
991
1093
  white?: string;
1094
+ /**
1095
+ * When the side to move runs out of time, as absolute epoch ms — render it as a countdown.
1096
+ *
1097
+ * دامة is strictly 1v1, so ONE absent player stops the board completely: every other engine bounded
1098
+ * its equivalent and this one did not, which left a stalled game sitting until the 30-minute room
1099
+ * reaper. On expiry the game ends with NO winner and NO rating change — the same ruling as a
1100
+ * mid-game kick (2026-08-08): counting an abandonment as a win would let someone farm ELO by
1101
+ * walking away from a losing board.
1102
+ *
1103
+ * ADDITIVE and optional: absent while a bot is to move, after the game is over, and from any older
1104
+ * relay. A client that ignores it is correct, just less kind — it simply sees the game end.
1105
+ */
1106
+ moveDeadlineMs?: number;
1107
+ }
1108
+ /**
1109
+ * سباق — the race. Everyone runs the SAME questions, in the SAME order, privately, at their own speed.
1110
+ *
1111
+ * ⚠️ WHY THIS IS A RELAY GAME AND NOT A LOCAL ONE, in one sentence from the iOS desk: **timing cannot
1112
+ * be client-reported or the player with the best connection wins.** Every advance is stamped by the
1113
+ * server, the question set is dealt by the server, and a client never asks for a question — that last
1114
+ * one is how two players would otherwise end up racing different sets.
1115
+ *
1116
+ * The rules, all the owner's:
1117
+ * 1. same questions, same order, for every player — it is what makes «who finished first» mean anything
1118
+ * 2. right on the first tap → FULL points
1119
+ * 3. wrong → the player CHOOSES: try again for HALF, or move on. The cost of a retry is TIME, which
1120
+ * is why it needs no extra penalty and why the choice changes depending on whether you are ahead
1121
+ * 4. wrong twice → 0 for that question, next question. ⚠️ TWO TIERS ONLY — full → half → gone is
1122
+ * something a child can hold; a third tier turns it into arithmetic. Do not add one.
1123
+ * 5. finishing faster → BONUS points
1124
+ * 6. 🔴 HIGHEST SCORE WINS. SPEED ADDS, IT NEVER OVERRIDES. A player who mashes through in 30
1125
+ * seconds finishes first and still loses — that is the intended outcome and it is load-bearing.
1126
+ * 7. live standings visible during play
1127
+ * 8. the host races too
1128
+ *
1129
+ * ⚠️ QUESTION COUNT IS NOT FIXED: it is the selected categories × 6 levels (owner, 2026-08-25 —
1130
+ * «Questions are as players selected categories»). «3 categories» was his example, not the rule.
1131
+ */
1132
+ interface SebaqStandingRow {
1133
+ playerId: string;
1134
+ /** How many questions this player has FINISHED (not the one they are on). */
1135
+ done: number;
1136
+ score: number;
1137
+ /** Set only when they have finished the whole set — the bonus is not knowable before then. */
1138
+ speedBonus?: number;
1139
+ finished: boolean;
1140
+ }
1141
+ interface SebaqStateView {
1142
+ /** Total questions in this race = categories × 6. Every player runs all of them. */
1143
+ total: number;
1144
+ /** Live standings for everyone, always visible (rule 7). Ordered by the server, best first. */
1145
+ standings: SebaqStandingRow[];
1146
+ /** Set when every racer has finished. Ties on total score break on base score, then on time. */
1147
+ winnerId?: string | null;
1148
+ /** ⚠️ YOUR question only, and only the one you are ON. Never another player's, never the next one —
1149
+ * a client that could see ahead could pre-read while others cannot, which is the whole race. */
1150
+ you?: {
1151
+ index: number;
1152
+ /** ⚠️ The SAME anti-cheat boundary as trivia's: `correctAnswerIndex` is null while the question is
1153
+ * live and set only on reveal. In a race the temptation is stronger — every player holds a live
1154
+ * question at the same moment, so a leaked answer index is a leak to everyone at once. */
1155
+ question: QuestionView;
1156
+ /** true once you have answered wrong ONCE on this question: the half-or-skip choice is live. */
1157
+ retryOffered: boolean;
1158
+ score: number;
1159
+ finished: boolean;
1160
+ };
992
1161
  }
993
1162
  /**
994
1163
  * The authoritative Ersimha round state. Anti-cheat boundary (D-030): `word` is set ONLY in the
@@ -1065,7 +1234,7 @@ type ServerMsg = {
1065
1234
  packs?: PackView[];
1066
1235
  selectedCategoryIds: string[];
1067
1236
  teams: TeamSlotView[];
1068
- hostActivity: 'configuring' | 'idle';
1237
+ hostActivity: 'options' | 'configuring' | 'idle';
1069
1238
  tally?: {
1070
1239
  [playerId: string]: number;
1071
1240
  };
@@ -1087,6 +1256,12 @@ type ServerMsg = {
1087
1256
  view: DamaStateView;
1088
1257
  you: YouContext;
1089
1258
  roster?: RosterEntry[];
1259
+ } | {
1260
+ t: 'sebaqState';
1261
+ gameId: 'sebaq';
1262
+ view: SebaqStateView;
1263
+ you: YouContext;
1264
+ roster?: RosterEntry[];
1090
1265
  } | {
1091
1266
  t: 'ersimhaState';
1092
1267
  gameId: 'ersimha';
@@ -1165,4 +1340,4 @@ interface MyQuestionRow {
1165
1340
  };
1166
1341
  }
1167
1342
 
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 };
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 };
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,30 @@ 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 = /* @__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));
24
46
  var gamesForPv = (pv) => GAMES.filter((g) => !g.deprecated && g.minPv <= pv);
25
47
  var gameIdForPv = (gameId, pv) => {
26
48
  const g = GAMES.find((x) => x.id === gameId);
@@ -97,6 +119,42 @@ var ClientMsg = z.discriminatedUnion("t", [
97
119
  z.object({ t: z.literal("kick"), playerId: PlayerId }),
98
120
  // Host's IN-PROGRESS category selection in the lobby — a live preview for waiting
99
121
  // players. Distinct from startGame.categoryIds, which COMMITS the board. Empty = cleared.
122
+ // ⚠️ AN EMPTY `categoryIds` IS A MEANINGFUL MESSAGE — DO NOT ADD `.min(1)`.
123
+ //
124
+ // The host sends `setCategories([])` the moment they press «التالي», before picking anything.
125
+ //
126
+ // ⚠️ THE THREE-STATE READING RECORDED IN a33f305 IS NOT DELIVERABLE ON THIS WIRE, AND A CLIENT
127
+ // WRITTEN TO IT WILL BE «FIXED» IN THE WRONG DIRECTION. It said:
128
+ // absent → still in the lobby
129
+ // present but EMPTY → the host is choosing
130
+ // non-empty → the picks card
131
+ // The first state does not exist. `socket.ts` creates every room with `selectedCategoryIds: []`
132
+ // and `rooms.ts` serialises it on EVERY lobby frame, so a joiner's very first frame already carries
133
+ // `[]` — byte-identical to the host having pressed «التالي». QA measured it on prod (room J7UY,
134
+ // raw frames in geem-qa `findings/sebaq/race2.jsonl`): the joiner's lobby before and after the
135
+ // host's empty `setCategories` differ in nothing on this field. On iOS the visible result is that
136
+ // joiners read «يختار الفئات» the moment they land. (geem-server#118.)
137
+ //
138
+ // ⚠️ `hostActivity` IS THE CARRIER FOR «THE HOST IS CHOOSING» — it is the field built for this exact
139
+ // question and it distinguishes the state this one cannot: `'options'` (the host is on the settings
140
+ // surface) vs `'configuring'` vs `'idle'`. Read THAT for the waiting-screen line; read
141
+ // `selectedCategoryIds` only for WHICH categories, never for WHETHER the host has begun.
142
+ //
143
+ // What stays true from a33f305, and is still worth its test: an empty array must PARSE. Adding
144
+ // `.min(1)` would reject a message the client is right to send.
145
+ //
146
+ // Without it, joiners sat on «ناطرين … يبدأ اللعبة» for as long as the host browsed and only learned
147
+ // anything had happened when the FIRST pick landed (owner, 2026-08-26; fixed by the iOS desk).
148
+ //
149
+ // ⚠️ THE FAILURE IF SOMEONE TIGHTENS THIS IS SILENT AND LOOKS LIKE A CLIENT BUG. Nothing crashes:
150
+ // every joiner in every category game just goes back to «waiting to start» until the first pick.
151
+ // The relay would be rejecting a message the client is right to send, and the search would start on
152
+ // the client. Applies to صيدها, بالدور and سباق alike — they share one lobby.
153
+ // The host's verdict on a comeback offer (owner ruling 2026-08-27). `grant: false` is a real and
154
+ // expected outcome — «if he refuse then he will not get it» — and closes the offer with nothing.
155
+ // ⚠️ HOST-ONLY, and the relay enforces it. A trailing team that could grant its own bonus is not a
156
+ // bonus, it is a button that says «+100».
157
+ z.object({ t: z.literal("comebackDecide"), grant: z.boolean() }),
100
158
  z.object({ t: z.literal("setCategories"), categoryIds: z.array(z.string().max(60)).max(12) }),
101
159
  // Host defines the lobby teams players can join (0–12; empty = disband back to the
102
160
  // one-team-per-player default). Resets picks that fall out of range.
@@ -113,10 +171,32 @@ var ClientMsg = z.discriminatedUnion("t", [
113
171
  // `modeId` alone still works (pre-gs#49 clients); `game` is what lets a host announce a tile with
114
172
  // no modes (dama/ersimha), which `modeId` could never express. An incoherent pair (a mode that
115
173
  // doesn't belong to the game) is rejected with `bad_mode` rather than silently resolved.
174
+ // ── Sebaq slice (gameId 'sebaq', the RACE tile) ──
175
+ // ⚠️ THE SERVER JUDGES AND THE SERVER TIMES. The client sends which option was tapped and nothing
176
+ // else — no «correct», no elapsed ms. A client-reported time makes the best connection win, and a
177
+ // client-reported verdict makes the race unwinnable honestly.
178
+ z.object({ t: z.literal("sebaqAnswer"), optionIndex: z.number().int().min(0).max(5) }),
179
+ // Rule 3: after ONE wrong answer the player chooses. `sebaqRetry` re-opens the SAME question for
180
+ // half points; `sebaqSkip` takes 0 and moves on. Two tiers only (rule 4) — a second wrong answer
181
+ // needs no message, it just ends the question.
182
+ z.object({ t: z.literal("sebaqRetry") }),
183
+ z.object({ t: z.literal("sebaqSkip") }),
116
184
  z.object({ t: z.literal("setMode"), game: z.enum(GAME_IDS).optional(), modeId: z.enum(MODE_IDS).optional() }),
117
185
  // Host signals it's busy configuring the game (categories/rounds/mode) so waiting players can show
118
186
  // 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"]) }),
187
+ // ⚠️ THREE VALUES SINCE 2026-08-26 (Options page, geem-coordination#24). The host's pre-game
188
+ // journey is Lobby → Options → Categories, and joiners route on this:
189
+ // idle → the lobby
190
+ // options → «how we play» — the per-game settings page, read-only for joiners
191
+ // configuring → «… يختار الفئات» — the category picker
192
+ //
193
+ // ⚠️ THE RELAY MUST ACCEPT `options` BEFORE ANY CLIENT SENDS IT, which is why this ships ahead of
194
+ // the page: a new client sending it to an old relay is a `bad_message`, the activity stays whatever
195
+ // it was, and every joiner shows the wrong banner with nothing erroring.
196
+ z.object({ t: z.literal("hostActivity"), activity: z.enum(["options", "configuring", "idle"]) }),
197
+ // ⚠️ AND IT IS ADAPTED ON THE WAY OUT (D-025), which is what makes it shippable without a
198
+ // coordinated release. `hostActivityForPv` below down-maps `options` → `configuring` for any client
199
+ // below pv6 — see there for the failure it prevents.
120
200
  // host game actions
121
201
  z.object({
122
202
  t: z.literal("startGame"),
@@ -234,6 +314,7 @@ var SUBMISSION_KINDS = ["new", "edit"];
234
314
  var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate", "too_narrow", "other"];
235
315
  export {
236
316
  CHARACTER_IDS,
317
+ COMEBACK_BONUS_MIN_PV,
237
318
  ClientMsg,
238
319
  EMOTE_IDS,
239
320
  GAMES,
@@ -247,8 +328,10 @@ export {
247
328
  ROOM_ALPHABET,
248
329
  SUBMISSION_KINDS,
249
330
  SUBMISSION_STATUSES,
331
+ effectsForPv,
250
332
  gameIdForPv,
251
333
  gamesForPv,
334
+ hostActivityForPv,
252
335
  modesForGame,
253
336
  normalizeWesternDigits,
254
337
  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.40",
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"