@thegeem/protocol 0.1.19 → 0.1.21

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
@@ -39,16 +39,21 @@ var LIMITS = {
39
39
  MAX_NAME_LEN: 20,
40
40
  ROOM_CODE_LEN: 4,
41
41
  MAX_PLAYERS: 12,
42
- MAX_MSG_PER_SEC: 8
42
+ MAX_MSG_PER_SEC: 8,
43
+ /** Ersimha strokes ride a SEPARATE per-socket lane (general msgs keep MAX_MSG_PER_SEC).
44
+ * Clients batch pen points to stay under this — ~150ms batching lands at ~7/sec. */
45
+ STROKE_BATCH_PER_SEC: 20
43
46
  };
44
- var PROTOCOL_VERSION = 3;
47
+ var PROTOCOL_VERSION = 4;
45
48
  var MIN_SUPPORTED_PV = 1;
46
- var GAME_IDS = ["seedha", "snag", "trivia", "dama"];
49
+ var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha"];
47
50
  var GAMES = [
48
51
  { id: "seedha", label: "Seedha", minPv: 2 },
49
52
  { id: "snag", label: "Snag", minPv: 2 },
50
53
  { id: "dama", label: "Dama", minPv: 3 },
51
54
  // engine #2 (D-027) — offered only to pv≥3 clients
55
+ { id: "ersimha", label: "Ersimha", minPv: 4 },
56
+ // engine #3 (D-030) draw-and-guess — offered only to pv≥4 clients
52
57
  { id: "trivia", label: "Trivia", minPv: 1, deprecated: true }
53
58
  ];
54
59
  var gamesForPv = (pv) => GAMES.filter((g) => !g.deprecated && g.minPv <= pv);
@@ -120,6 +125,10 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
120
125
  totalRounds: import_zod.z.number().int().min(1).max(50),
121
126
  categoryIds: import_zod.z.array(import_zod.z.string().max(60)).min(0).max(12),
122
127
  // empty for games without categories (Dama); trivia rejects empty in-handler
128
+ // Ersimha (gs#45): include the HOST connection in the drawer rotation (host draws/buzzes like a
129
+ // player; first drawer, Dama-parity) — a pair plays with 2 devices, no separate TV connection.
130
+ // Omit/false = the host stays the room's TV/spectator (the D-030 MVP shape). Additive, no pv bump.
131
+ hostPlays: import_zod.z.boolean().optional(),
123
132
  // PREFERRED: pick a mode from the registry (see MODES). The server maps it to the engine
124
133
  // rules. Send this and omit buzzerMode/noHost. Falls back to the legacy flags below if omitted.
125
134
  modeId: import_zod.z.enum(MODE_IDS).optional(),
@@ -168,7 +177,28 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
168
177
  path: import_zod.z.array(import_zod.z.object({ r: import_zod.z.number().int().min(0).max(7), c: import_zod.z.number().int().min(0).max(7) })).max(12).optional()
169
178
  }),
170
179
  // Resign the current Dama game (concede) → opponent wins.
171
- import_zod.z.object({ t: import_zod.z.literal("damaResign") })
180
+ import_zod.z.object({ t: import_zod.z.literal("damaResign") }),
181
+ // ── Ersimha slice (gameId 'ersimha', engine #3 — D-030) ──
182
+ // Drawer streams stroke points as batches. Coordinates are NORMALIZED [0,1] floats (device-
183
+ // independent — every client and the TV scale to their own canvas). The relay is a pure
184
+ // PASSTHROUGH: strokes are validated, rate-limited, fanned out, and NEVER stored (ephemeral
185
+ // drawings, D-030). `seq` orders batches within one stroke; a new strokeId = pen-down.
186
+ import_zod.z.object({
187
+ t: import_zod.z.literal("strokeBatch"),
188
+ strokeId: import_zod.z.string().min(1).max(40),
189
+ seq: import_zod.z.number().int().min(0).max(1e4),
190
+ points: import_zod.z.array(import_zod.z.object({ x: import_zod.z.number().min(0).max(1), y: import_zod.z.number().min(0).max(1) })).min(1).max(128),
191
+ color: import_zod.z.string().regex(/^#[0-9a-fA-F]{6}$/),
192
+ width: import_zod.z.number().min(1).max(64)
193
+ }),
194
+ // Drawer wipes the canvas (relayed to everyone).
195
+ import_zod.z.object({ t: import_zod.z.literal("clearCanvas") }),
196
+ // Drawer removes one stroke — the given id, or their latest when omitted (relayed).
197
+ import_zod.z.object({ t: import_zod.z.literal("undoStroke"), strokeId: import_zod.z.string().min(1).max(40).optional() }),
198
+ // Drawer rules the CURRENT buzzer's spoken guess صح/خطأ (guessing is out loud — no text on the wire).
199
+ import_zod.z.object({ t: import_zod.z.literal("judgeGuess"), correct: import_zod.z.boolean() }),
200
+ // Drawer passes on the served word (capped per draw-turn; relay serves a fresh one).
201
+ import_zod.z.object({ t: import_zod.z.literal("skipWord") })
172
202
  ]);
173
203
  // Annotate the CommonJS export names for ESM import in node:
174
204
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -14,6 +14,9 @@ declare const LIMITS: {
14
14
  readonly ROOM_CODE_LEN: 4;
15
15
  readonly MAX_PLAYERS: 12;
16
16
  readonly MAX_MSG_PER_SEC: 8;
17
+ /** Ersimha strokes ride a SEPARATE per-socket lane (general msgs keep MAX_MSG_PER_SEC).
18
+ * Clients batch pen points to stay under this — ~150ms batching lands at ~7/sec. */
19
+ readonly STROKE_BATCH_PER_SEC: 20;
17
20
  };
18
21
  /**
19
22
  * Wire-format generation. **Bump ONLY on a breaking change** to ClientMsg /
@@ -33,7 +36,7 @@ declare const LIMITS: {
33
36
  * `seedha`/`snag`). Raise the floor SLOWLY to retire ancient builds. Unversioned clients (no `pv`,
34
37
  * e.g. the legacy web harness) are treated as the floor and never rejected.
35
38
  */
36
- declare const PROTOCOL_VERSION = 3;
39
+ declare const PROTOCOL_VERSION = 4;
37
40
  declare const MIN_SUPPORTED_PV = 1;
38
41
  /**
39
42
  * Geem is a PLATFORM of GAMES (jackbox-style). The trivia board split (D-023) into TWO player-facing
@@ -42,7 +45,7 @@ declare const MIN_SUPPORTED_PV = 1;
42
45
  * pre-pv2 clients (they never saw seedha/snag), and what old clients send on startGame. Every room
43
46
  * runs one game, identified by `gameId`.
44
47
  */
45
- declare const GAME_IDS: readonly ["seedha", "snag", "trivia", "dama"];
48
+ declare const GAME_IDS: readonly ["seedha", "snag", "trivia", "dama", "ersimha"];
46
49
  type GameId = (typeof GAME_IDS)[number];
47
50
  /**
48
51
  * The GAME REGISTRY — player-facing games + the version gating (D-024/D-025). A client renders a tile
@@ -96,7 +99,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
96
99
  emoji: string;
97
100
  color: string;
98
101
  }>>;
99
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama"]>>;
102
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
100
103
  pv: z.ZodOptional<z.ZodNumber>;
101
104
  idToken: z.ZodOptional<z.ZodString>;
102
105
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
@@ -107,7 +110,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
107
110
  emoji: string;
108
111
  color: string;
109
112
  } | undefined;
110
- game?: "seedha" | "snag" | "trivia" | "dama" | undefined;
113
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
111
114
  pv?: number | undefined;
112
115
  idToken?: string | undefined;
113
116
  platform?: "web" | "ios" | "android" | undefined;
@@ -118,7 +121,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
118
121
  emoji: string;
119
122
  color: string;
120
123
  } | undefined;
121
- game?: "seedha" | "snag" | "trivia" | "dama" | undefined;
124
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
122
125
  pv?: number | undefined;
123
126
  idToken?: string | undefined;
124
127
  platform?: "web" | "ios" | "android" | undefined;
@@ -289,9 +292,10 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
289
292
  activity: "configuring" | "idle";
290
293
  }>, z.ZodObject<{
291
294
  t: z.ZodLiteral<"startGame">;
292
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama"]>>;
295
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
293
296
  totalRounds: z.ZodNumber;
294
297
  categoryIds: z.ZodArray<z.ZodString, "many">;
298
+ hostPlays: z.ZodOptional<z.ZodBoolean>;
295
299
  modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
296
300
  buzzerMode: z.ZodOptional<z.ZodEnum<["off", "solo", "race", "controller"]>>;
297
301
  noHost: z.ZodOptional<z.ZodBoolean>;
@@ -299,16 +303,18 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
299
303
  t: "startGame";
300
304
  categoryIds: string[];
301
305
  totalRounds: number;
302
- game?: "seedha" | "snag" | "trivia" | "dama" | undefined;
306
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
303
307
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
308
+ hostPlays?: boolean | undefined;
304
309
  buzzerMode?: "solo" | "off" | "race" | "controller" | undefined;
305
310
  noHost?: boolean | undefined;
306
311
  }, {
307
312
  t: "startGame";
308
313
  categoryIds: string[];
309
314
  totalRounds: number;
310
- game?: "seedha" | "snag" | "trivia" | "dama" | undefined;
315
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
311
316
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
317
+ hostPlays?: boolean | undefined;
312
318
  buzzerMode?: "solo" | "off" | "race" | "controller" | undefined;
313
319
  noHost?: boolean | undefined;
314
320
  }>, z.ZodObject<{
@@ -488,6 +494,72 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
488
494
  t: "damaResign";
489
495
  }, {
490
496
  t: "damaResign";
497
+ }>, z.ZodObject<{
498
+ t: z.ZodLiteral<"strokeBatch">;
499
+ strokeId: z.ZodString;
500
+ seq: z.ZodNumber;
501
+ points: z.ZodArray<z.ZodObject<{
502
+ x: z.ZodNumber;
503
+ y: z.ZodNumber;
504
+ }, "strip", z.ZodTypeAny, {
505
+ x: number;
506
+ y: number;
507
+ }, {
508
+ x: number;
509
+ y: number;
510
+ }>, "many">;
511
+ color: z.ZodString;
512
+ width: z.ZodNumber;
513
+ }, "strip", z.ZodTypeAny, {
514
+ color: string;
515
+ t: "strokeBatch";
516
+ strokeId: string;
517
+ seq: number;
518
+ points: {
519
+ x: number;
520
+ y: number;
521
+ }[];
522
+ width: number;
523
+ }, {
524
+ color: string;
525
+ t: "strokeBatch";
526
+ strokeId: string;
527
+ seq: number;
528
+ points: {
529
+ x: number;
530
+ y: number;
531
+ }[];
532
+ width: number;
533
+ }>, z.ZodObject<{
534
+ t: z.ZodLiteral<"clearCanvas">;
535
+ }, "strip", z.ZodTypeAny, {
536
+ t: "clearCanvas";
537
+ }, {
538
+ t: "clearCanvas";
539
+ }>, z.ZodObject<{
540
+ t: z.ZodLiteral<"undoStroke">;
541
+ strokeId: z.ZodOptional<z.ZodString>;
542
+ }, "strip", z.ZodTypeAny, {
543
+ t: "undoStroke";
544
+ strokeId?: string | undefined;
545
+ }, {
546
+ t: "undoStroke";
547
+ strokeId?: string | undefined;
548
+ }>, z.ZodObject<{
549
+ t: z.ZodLiteral<"judgeGuess">;
550
+ correct: z.ZodBoolean;
551
+ }, "strip", z.ZodTypeAny, {
552
+ t: "judgeGuess";
553
+ correct: boolean;
554
+ }, {
555
+ t: "judgeGuess";
556
+ correct: boolean;
557
+ }>, z.ZodObject<{
558
+ t: z.ZodLiteral<"skipWord">;
559
+ }, "strip", z.ZodTypeAny, {
560
+ t: "skipWord";
561
+ }, {
562
+ t: "skipWord";
491
563
  }>]>;
492
564
  type ClientMsg = z.infer<typeof ClientMsg>;
493
565
  /** A player's privacy-safe avatar (emoji + circle color) for in-room display. */
@@ -673,6 +745,33 @@ interface DamaStateView {
673
745
  legalMoves: DamaMoveView[];
674
746
  moveCount: number;
675
747
  }
748
+ /**
749
+ * The authoritative Ersimha round state. Anti-cheat boundary (D-030): `word` is set ONLY in the
750
+ * payload sent to the DRAWER's connection — guessers and the TV get `maskedWord` (per-segment
751
+ * letter counts, «سيارة إسعاف» → [5,5]). Names/avatars come from the lobby roster (map by id),
752
+ * same as Dama. Strokes are NOT in the state — they stream as `strokeBatch` passthroughs and a
753
+ * late joiner simply starts from a blank canvas (ephemeral by design).
754
+ */
755
+ interface ErsimhaStateView {
756
+ phase: 'choosing' | 'drawing' | 'judging' | 'roundEnd' | 'completed';
757
+ drawerId: string;
758
+ judgedId: string | null;
759
+ turn: number;
760
+ totalTurns: number;
761
+ scores: {
762
+ id: string;
763
+ score: number;
764
+ }[];
765
+ maskedWord: number[];
766
+ word: string | null;
767
+ buzzQueue: string[];
768
+ lockedOut: string[];
769
+ canBuzz: boolean;
770
+ skipsLeft: number;
771
+ lastWord: string | null;
772
+ lastWinnerId: string | null;
773
+ timerRemainingMs: number | null;
774
+ }
676
775
  type ServerMsg = {
677
776
  t: 'roomCreated';
678
777
  code: string;
@@ -722,6 +821,29 @@ type ServerMsg = {
722
821
  gameId: 'dama';
723
822
  view: DamaStateView;
724
823
  you: YouContext;
824
+ } | {
825
+ t: 'ersimhaState';
826
+ gameId: 'ersimha';
827
+ view: ErsimhaStateView;
828
+ you: YouContext;
829
+ } | {
830
+ t: 'strokeBatch';
831
+ from: string;
832
+ strokeId: string;
833
+ seq: number;
834
+ points: {
835
+ x: number;
836
+ y: number;
837
+ }[];
838
+ color: string;
839
+ width: number;
840
+ } | {
841
+ t: 'clearCanvas';
842
+ from: string;
843
+ } | {
844
+ t: 'undoStroke';
845
+ from: string;
846
+ strokeId: string | null;
725
847
  } | {
726
848
  t: 'fx';
727
849
  effects: Effect[];
@@ -738,4 +860,4 @@ type ServerMsg = {
738
860
  message: string;
739
861
  };
740
862
 
741
- export { type AvatarView, type BuzzerGameMode, type CategoryView, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, type Effect, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type QuestionView, ROOM_ALPHABET, type ServerMsg, type SoundName, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame };
863
+ export { type AvatarView, type BuzzerGameMode, type CategoryView, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, type Effect, type ErsimhaStateView, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type QuestionView, ROOM_ALPHABET, type ServerMsg, type SoundName, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame };
package/dist/index.d.ts CHANGED
@@ -14,6 +14,9 @@ declare const LIMITS: {
14
14
  readonly ROOM_CODE_LEN: 4;
15
15
  readonly MAX_PLAYERS: 12;
16
16
  readonly MAX_MSG_PER_SEC: 8;
17
+ /** Ersimha strokes ride a SEPARATE per-socket lane (general msgs keep MAX_MSG_PER_SEC).
18
+ * Clients batch pen points to stay under this — ~150ms batching lands at ~7/sec. */
19
+ readonly STROKE_BATCH_PER_SEC: 20;
17
20
  };
18
21
  /**
19
22
  * Wire-format generation. **Bump ONLY on a breaking change** to ClientMsg /
@@ -33,7 +36,7 @@ declare const LIMITS: {
33
36
  * `seedha`/`snag`). Raise the floor SLOWLY to retire ancient builds. Unversioned clients (no `pv`,
34
37
  * e.g. the legacy web harness) are treated as the floor and never rejected.
35
38
  */
36
- declare const PROTOCOL_VERSION = 3;
39
+ declare const PROTOCOL_VERSION = 4;
37
40
  declare const MIN_SUPPORTED_PV = 1;
38
41
  /**
39
42
  * Geem is a PLATFORM of GAMES (jackbox-style). The trivia board split (D-023) into TWO player-facing
@@ -42,7 +45,7 @@ declare const MIN_SUPPORTED_PV = 1;
42
45
  * pre-pv2 clients (they never saw seedha/snag), and what old clients send on startGame. Every room
43
46
  * runs one game, identified by `gameId`.
44
47
  */
45
- declare const GAME_IDS: readonly ["seedha", "snag", "trivia", "dama"];
48
+ declare const GAME_IDS: readonly ["seedha", "snag", "trivia", "dama", "ersimha"];
46
49
  type GameId = (typeof GAME_IDS)[number];
47
50
  /**
48
51
  * The GAME REGISTRY — player-facing games + the version gating (D-024/D-025). A client renders a tile
@@ -96,7 +99,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
96
99
  emoji: string;
97
100
  color: string;
98
101
  }>>;
99
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama"]>>;
102
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
100
103
  pv: z.ZodOptional<z.ZodNumber>;
101
104
  idToken: z.ZodOptional<z.ZodString>;
102
105
  platform: z.ZodOptional<z.ZodEnum<["web", "ios", "android"]>>;
@@ -107,7 +110,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
107
110
  emoji: string;
108
111
  color: string;
109
112
  } | undefined;
110
- game?: "seedha" | "snag" | "trivia" | "dama" | undefined;
113
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
111
114
  pv?: number | undefined;
112
115
  idToken?: string | undefined;
113
116
  platform?: "web" | "ios" | "android" | undefined;
@@ -118,7 +121,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
118
121
  emoji: string;
119
122
  color: string;
120
123
  } | undefined;
121
- game?: "seedha" | "snag" | "trivia" | "dama" | undefined;
124
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
122
125
  pv?: number | undefined;
123
126
  idToken?: string | undefined;
124
127
  platform?: "web" | "ios" | "android" | undefined;
@@ -289,9 +292,10 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
289
292
  activity: "configuring" | "idle";
290
293
  }>, z.ZodObject<{
291
294
  t: z.ZodLiteral<"startGame">;
292
- game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama"]>>;
295
+ game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
293
296
  totalRounds: z.ZodNumber;
294
297
  categoryIds: z.ZodArray<z.ZodString, "many">;
298
+ hostPlays: z.ZodOptional<z.ZodBoolean>;
295
299
  modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
296
300
  buzzerMode: z.ZodOptional<z.ZodEnum<["off", "solo", "race", "controller"]>>;
297
301
  noHost: z.ZodOptional<z.ZodBoolean>;
@@ -299,16 +303,18 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
299
303
  t: "startGame";
300
304
  categoryIds: string[];
301
305
  totalRounds: number;
302
- game?: "seedha" | "snag" | "trivia" | "dama" | undefined;
306
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
303
307
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
308
+ hostPlays?: boolean | undefined;
304
309
  buzzerMode?: "solo" | "off" | "race" | "controller" | undefined;
305
310
  noHost?: boolean | undefined;
306
311
  }, {
307
312
  t: "startGame";
308
313
  categoryIds: string[];
309
314
  totalRounds: number;
310
- game?: "seedha" | "snag" | "trivia" | "dama" | undefined;
315
+ game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
311
316
  modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
317
+ hostPlays?: boolean | undefined;
312
318
  buzzerMode?: "solo" | "off" | "race" | "controller" | undefined;
313
319
  noHost?: boolean | undefined;
314
320
  }>, z.ZodObject<{
@@ -488,6 +494,72 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
488
494
  t: "damaResign";
489
495
  }, {
490
496
  t: "damaResign";
497
+ }>, z.ZodObject<{
498
+ t: z.ZodLiteral<"strokeBatch">;
499
+ strokeId: z.ZodString;
500
+ seq: z.ZodNumber;
501
+ points: z.ZodArray<z.ZodObject<{
502
+ x: z.ZodNumber;
503
+ y: z.ZodNumber;
504
+ }, "strip", z.ZodTypeAny, {
505
+ x: number;
506
+ y: number;
507
+ }, {
508
+ x: number;
509
+ y: number;
510
+ }>, "many">;
511
+ color: z.ZodString;
512
+ width: z.ZodNumber;
513
+ }, "strip", z.ZodTypeAny, {
514
+ color: string;
515
+ t: "strokeBatch";
516
+ strokeId: string;
517
+ seq: number;
518
+ points: {
519
+ x: number;
520
+ y: number;
521
+ }[];
522
+ width: number;
523
+ }, {
524
+ color: string;
525
+ t: "strokeBatch";
526
+ strokeId: string;
527
+ seq: number;
528
+ points: {
529
+ x: number;
530
+ y: number;
531
+ }[];
532
+ width: number;
533
+ }>, z.ZodObject<{
534
+ t: z.ZodLiteral<"clearCanvas">;
535
+ }, "strip", z.ZodTypeAny, {
536
+ t: "clearCanvas";
537
+ }, {
538
+ t: "clearCanvas";
539
+ }>, z.ZodObject<{
540
+ t: z.ZodLiteral<"undoStroke">;
541
+ strokeId: z.ZodOptional<z.ZodString>;
542
+ }, "strip", z.ZodTypeAny, {
543
+ t: "undoStroke";
544
+ strokeId?: string | undefined;
545
+ }, {
546
+ t: "undoStroke";
547
+ strokeId?: string | undefined;
548
+ }>, z.ZodObject<{
549
+ t: z.ZodLiteral<"judgeGuess">;
550
+ correct: z.ZodBoolean;
551
+ }, "strip", z.ZodTypeAny, {
552
+ t: "judgeGuess";
553
+ correct: boolean;
554
+ }, {
555
+ t: "judgeGuess";
556
+ correct: boolean;
557
+ }>, z.ZodObject<{
558
+ t: z.ZodLiteral<"skipWord">;
559
+ }, "strip", z.ZodTypeAny, {
560
+ t: "skipWord";
561
+ }, {
562
+ t: "skipWord";
491
563
  }>]>;
492
564
  type ClientMsg = z.infer<typeof ClientMsg>;
493
565
  /** A player's privacy-safe avatar (emoji + circle color) for in-room display. */
@@ -673,6 +745,33 @@ interface DamaStateView {
673
745
  legalMoves: DamaMoveView[];
674
746
  moveCount: number;
675
747
  }
748
+ /**
749
+ * The authoritative Ersimha round state. Anti-cheat boundary (D-030): `word` is set ONLY in the
750
+ * payload sent to the DRAWER's connection — guessers and the TV get `maskedWord` (per-segment
751
+ * letter counts, «سيارة إسعاف» → [5,5]). Names/avatars come from the lobby roster (map by id),
752
+ * same as Dama. Strokes are NOT in the state — they stream as `strokeBatch` passthroughs and a
753
+ * late joiner simply starts from a blank canvas (ephemeral by design).
754
+ */
755
+ interface ErsimhaStateView {
756
+ phase: 'choosing' | 'drawing' | 'judging' | 'roundEnd' | 'completed';
757
+ drawerId: string;
758
+ judgedId: string | null;
759
+ turn: number;
760
+ totalTurns: number;
761
+ scores: {
762
+ id: string;
763
+ score: number;
764
+ }[];
765
+ maskedWord: number[];
766
+ word: string | null;
767
+ buzzQueue: string[];
768
+ lockedOut: string[];
769
+ canBuzz: boolean;
770
+ skipsLeft: number;
771
+ lastWord: string | null;
772
+ lastWinnerId: string | null;
773
+ timerRemainingMs: number | null;
774
+ }
676
775
  type ServerMsg = {
677
776
  t: 'roomCreated';
678
777
  code: string;
@@ -722,6 +821,29 @@ type ServerMsg = {
722
821
  gameId: 'dama';
723
822
  view: DamaStateView;
724
823
  you: YouContext;
824
+ } | {
825
+ t: 'ersimhaState';
826
+ gameId: 'ersimha';
827
+ view: ErsimhaStateView;
828
+ you: YouContext;
829
+ } | {
830
+ t: 'strokeBatch';
831
+ from: string;
832
+ strokeId: string;
833
+ seq: number;
834
+ points: {
835
+ x: number;
836
+ y: number;
837
+ }[];
838
+ color: string;
839
+ width: number;
840
+ } | {
841
+ t: 'clearCanvas';
842
+ from: string;
843
+ } | {
844
+ t: 'undoStroke';
845
+ from: string;
846
+ strokeId: string | null;
725
847
  } | {
726
848
  t: 'fx';
727
849
  effects: Effect[];
@@ -738,4 +860,4 @@ type ServerMsg = {
738
860
  message: string;
739
861
  };
740
862
 
741
- export { type AvatarView, type BuzzerGameMode, type CategoryView, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, type Effect, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type QuestionView, ROOM_ALPHABET, type ServerMsg, type SoundName, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame };
863
+ export { type AvatarView, type BuzzerGameMode, type CategoryView, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, type Effect, type ErsimhaStateView, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type QuestionView, ROOM_ALPHABET, type ServerMsg, type SoundName, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame };
package/dist/index.js CHANGED
@@ -4,16 +4,21 @@ var LIMITS = {
4
4
  MAX_NAME_LEN: 20,
5
5
  ROOM_CODE_LEN: 4,
6
6
  MAX_PLAYERS: 12,
7
- MAX_MSG_PER_SEC: 8
7
+ MAX_MSG_PER_SEC: 8,
8
+ /** Ersimha strokes ride a SEPARATE per-socket lane (general msgs keep MAX_MSG_PER_SEC).
9
+ * Clients batch pen points to stay under this — ~150ms batching lands at ~7/sec. */
10
+ STROKE_BATCH_PER_SEC: 20
8
11
  };
9
- var PROTOCOL_VERSION = 3;
12
+ var PROTOCOL_VERSION = 4;
10
13
  var MIN_SUPPORTED_PV = 1;
11
- var GAME_IDS = ["seedha", "snag", "trivia", "dama"];
14
+ var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha"];
12
15
  var GAMES = [
13
16
  { id: "seedha", label: "Seedha", minPv: 2 },
14
17
  { id: "snag", label: "Snag", minPv: 2 },
15
18
  { id: "dama", label: "Dama", minPv: 3 },
16
19
  // engine #2 (D-027) — offered only to pv≥3 clients
20
+ { id: "ersimha", label: "Ersimha", minPv: 4 },
21
+ // engine #3 (D-030) draw-and-guess — offered only to pv≥4 clients
17
22
  { id: "trivia", label: "Trivia", minPv: 1, deprecated: true }
18
23
  ];
19
24
  var gamesForPv = (pv) => GAMES.filter((g) => !g.deprecated && g.minPv <= pv);
@@ -85,6 +90,10 @@ var ClientMsg = z.discriminatedUnion("t", [
85
90
  totalRounds: z.number().int().min(1).max(50),
86
91
  categoryIds: z.array(z.string().max(60)).min(0).max(12),
87
92
  // empty for games without categories (Dama); trivia rejects empty in-handler
93
+ // Ersimha (gs#45): include the HOST connection in the drawer rotation (host draws/buzzes like a
94
+ // player; first drawer, Dama-parity) — a pair plays with 2 devices, no separate TV connection.
95
+ // Omit/false = the host stays the room's TV/spectator (the D-030 MVP shape). Additive, no pv bump.
96
+ hostPlays: z.boolean().optional(),
88
97
  // PREFERRED: pick a mode from the registry (see MODES). The server maps it to the engine
89
98
  // rules. Send this and omit buzzerMode/noHost. Falls back to the legacy flags below if omitted.
90
99
  modeId: z.enum(MODE_IDS).optional(),
@@ -133,7 +142,28 @@ var ClientMsg = z.discriminatedUnion("t", [
133
142
  path: z.array(z.object({ r: z.number().int().min(0).max(7), c: z.number().int().min(0).max(7) })).max(12).optional()
134
143
  }),
135
144
  // Resign the current Dama game (concede) → opponent wins.
136
- z.object({ t: z.literal("damaResign") })
145
+ z.object({ t: z.literal("damaResign") }),
146
+ // ── Ersimha slice (gameId 'ersimha', engine #3 — D-030) ──
147
+ // Drawer streams stroke points as batches. Coordinates are NORMALIZED [0,1] floats (device-
148
+ // independent — every client and the TV scale to their own canvas). The relay is a pure
149
+ // PASSTHROUGH: strokes are validated, rate-limited, fanned out, and NEVER stored (ephemeral
150
+ // drawings, D-030). `seq` orders batches within one stroke; a new strokeId = pen-down.
151
+ z.object({
152
+ t: z.literal("strokeBatch"),
153
+ strokeId: z.string().min(1).max(40),
154
+ seq: z.number().int().min(0).max(1e4),
155
+ points: z.array(z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1) })).min(1).max(128),
156
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
157
+ width: z.number().min(1).max(64)
158
+ }),
159
+ // Drawer wipes the canvas (relayed to everyone).
160
+ z.object({ t: z.literal("clearCanvas") }),
161
+ // Drawer removes one stroke — the given id, or their latest when omitted (relayed).
162
+ z.object({ t: z.literal("undoStroke"), strokeId: z.string().min(1).max(40).optional() }),
163
+ // Drawer rules the CURRENT buzzer's spoken guess صح/خطأ (guessing is out loud — no text on the wire).
164
+ z.object({ t: z.literal("judgeGuess"), correct: z.boolean() }),
165
+ // Drawer passes on the served word (capped per draw-turn; relay serves a fresh one).
166
+ z.object({ t: z.literal("skipWord") })
137
167
  ]);
138
168
  export {
139
169
  ClientMsg,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegeem/protocol",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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",