@thegeem/protocol 0.1.37 → 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.
- package/copy-rules.json +120 -0
- package/dist/index.cjs +104 -6
- package/dist/index.d.cts +252 -16
- package/dist/index.d.ts +252 -16
- package/dist/index.js +96 -5
- package/package.json +3 -2
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 =
|
|
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;
|
|
@@ -112,6 +149,14 @@ declare const ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
|
|
|
112
149
|
* Letters are never touched; this maps exactly twenty codepoints.
|
|
113
150
|
*/
|
|
114
151
|
declare const normalizeWesternDigits: (s: string) => string;
|
|
152
|
+
/**
|
|
153
|
+
* THE definition of "a safe player-supplied string": strip the unsafe class, normalise digits.
|
|
154
|
+
* Exported because it was being hand-copied — the relay's HTTP gates (account PATCH, question
|
|
155
|
+
* submissions) each grew their own copy of this exact body, and one of those copies stored the
|
|
156
|
+
* bidi characters LITERALLY in source, where any formatter or copy-paste could silently change
|
|
157
|
+
* what it matches. A rule that can be edited by accident is not a rule.
|
|
158
|
+
*/
|
|
159
|
+
declare const sanitizeName: (s: string) => string;
|
|
115
160
|
declare const HelpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
|
|
116
161
|
declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
|
|
117
162
|
t: z.ZodLiteral<"createRoom">;
|
|
@@ -126,7 +171,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
|
|
|
126
171
|
emoji: string;
|
|
127
172
|
color: string;
|
|
128
173
|
}>>;
|
|
129
|
-
game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
|
|
174
|
+
game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
|
|
130
175
|
character: z.ZodOptional<z.ZodEnum<["m", "f"]>>;
|
|
131
176
|
pv: z.ZodOptional<z.ZodNumber>;
|
|
132
177
|
idToken: z.ZodOptional<z.ZodString>;
|
|
@@ -139,7 +184,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
|
|
|
139
184
|
emoji: string;
|
|
140
185
|
color: string;
|
|
141
186
|
} | undefined;
|
|
142
|
-
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
|
|
187
|
+
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
|
|
143
188
|
character?: "m" | "f" | undefined;
|
|
144
189
|
pv?: number | undefined;
|
|
145
190
|
idToken?: string | undefined;
|
|
@@ -152,7 +197,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
|
|
|
152
197
|
emoji: string;
|
|
153
198
|
color: string;
|
|
154
199
|
} | undefined;
|
|
155
|
-
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
|
|
200
|
+
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
|
|
156
201
|
character?: "m" | "f" | undefined;
|
|
157
202
|
pv?: number | undefined;
|
|
158
203
|
idToken?: string | undefined;
|
|
@@ -332,6 +377,15 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
|
|
|
332
377
|
}, {
|
|
333
378
|
t: "kick";
|
|
334
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;
|
|
335
389
|
}>, z.ZodObject<{
|
|
336
390
|
t: z.ZodLiteral<"setCategories">;
|
|
337
391
|
categoryIds: z.ZodArray<z.ZodString, "many">;
|
|
@@ -359,30 +413,51 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
|
|
|
359
413
|
}, {
|
|
360
414
|
t: "pickTeam";
|
|
361
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";
|
|
362
437
|
}>, z.ZodObject<{
|
|
363
438
|
t: z.ZodLiteral<"setMode">;
|
|
364
|
-
game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
|
|
439
|
+
game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
|
|
365
440
|
modeId: z.ZodOptional<z.ZodEnum<["solo", "oneDevice", "snag", "multiplayer"]>>;
|
|
366
441
|
}, "strip", z.ZodTypeAny, {
|
|
367
442
|
t: "setMode";
|
|
368
|
-
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
|
|
443
|
+
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
|
|
369
444
|
modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
|
|
370
445
|
}, {
|
|
371
446
|
t: "setMode";
|
|
372
|
-
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
|
|
447
|
+
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
|
|
373
448
|
modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
|
|
374
449
|
}>, z.ZodObject<{
|
|
375
450
|
t: z.ZodLiteral<"hostActivity">;
|
|
376
|
-
activity: z.ZodEnum<["configuring", "idle"]>;
|
|
451
|
+
activity: z.ZodEnum<["options", "configuring", "idle"]>;
|
|
377
452
|
}, "strip", z.ZodTypeAny, {
|
|
378
453
|
t: "hostActivity";
|
|
379
|
-
activity: "configuring" | "idle";
|
|
454
|
+
activity: "options" | "configuring" | "idle";
|
|
380
455
|
}, {
|
|
381
456
|
t: "hostActivity";
|
|
382
|
-
activity: "configuring" | "idle";
|
|
457
|
+
activity: "options" | "configuring" | "idle";
|
|
383
458
|
}>, z.ZodObject<{
|
|
384
459
|
t: z.ZodLiteral<"startGame">;
|
|
385
|
-
game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha"]>>;
|
|
460
|
+
game: z.ZodOptional<z.ZodEnum<["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"]>>;
|
|
386
461
|
totalRounds: z.ZodNumber;
|
|
387
462
|
categoryIds: z.ZodArray<z.ZodString, "many">;
|
|
388
463
|
hostPlays: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -401,7 +476,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
|
|
|
401
476
|
t: "startGame";
|
|
402
477
|
categoryIds: string[];
|
|
403
478
|
totalRounds: number;
|
|
404
|
-
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
|
|
479
|
+
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
|
|
405
480
|
modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
|
|
406
481
|
hostPlays?: boolean | undefined;
|
|
407
482
|
players?: string[] | undefined;
|
|
@@ -414,7 +489,7 @@ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
|
|
|
414
489
|
t: "startGame";
|
|
415
490
|
categoryIds: string[];
|
|
416
491
|
totalRounds: number;
|
|
417
|
-
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | undefined;
|
|
492
|
+
game?: "seedha" | "snag" | "trivia" | "dama" | "ersimha" | "sebaq" | undefined;
|
|
418
493
|
modeId?: "snag" | "solo" | "oneDevice" | "multiplayer" | undefined;
|
|
419
494
|
hostPlays?: boolean | undefined;
|
|
420
495
|
players?: string[] | undefined;
|
|
@@ -833,6 +908,20 @@ interface TeamView {
|
|
|
833
908
|
avatar?: AvatarView;
|
|
834
909
|
}
|
|
835
910
|
interface QuestionView {
|
|
911
|
+
/** The category this question came from (gs#94 follow-up, geem-site#54). NOT reveal-gated: the
|
|
912
|
+
* category is already on the board tile the player picked, so it gives nothing away — unlike the
|
|
913
|
+
* id, which is a handle into the bank. It exists so «اقترح سؤال مشابه» can preset the category
|
|
914
|
+
* instead of asking the player to re-pick it; without it a client either asks again or INFERS one,
|
|
915
|
+
* and inferring files somebody's question under the wrong category. Optional: pre-gs#94 servers
|
|
916
|
+
* omit it and clients must cope. */
|
|
917
|
+
categoryId?: string;
|
|
918
|
+
/** The question's id — present ONLY once revealed (gs#90). It exists so a player can say «عدّل
|
|
919
|
+
* السؤال» about a specific question: text is neither unique (template-shaped questions collide on
|
|
920
|
+
* wording) nor stable, so an edit identified only by its text is not reliably applicable. Kept off
|
|
921
|
+
* the wire while the question is LIVE for the same reason `correctAnswerIndex` is — the bank is
|
|
922
|
+
* server-side and an id is a handle into it. Optional on the wire: shipped clients ignore it, and
|
|
923
|
+
* a client that wants it must tolerate its absence pre-reveal. */
|
|
924
|
+
id?: string;
|
|
836
925
|
text: string;
|
|
837
926
|
options: string[];
|
|
838
927
|
hiddenOptionIndices: number[];
|
|
@@ -840,6 +929,10 @@ interface QuestionView {
|
|
|
840
929
|
selectedAnswerIndex: number | null;
|
|
841
930
|
/** Only present once revealed (anti-cheat). Null while the question is live. */
|
|
842
931
|
correctAnswerIndex: number | null;
|
|
932
|
+
/** «سؤال من: …» — community submitter credit (owner ruling 2026-08-14). Present ONLY on
|
|
933
|
+
* questions promoted from community submissions; official questions never carry it. NOT
|
|
934
|
+
* reveal-gated — a name leaks nothing — so place it wherever it reads best. */
|
|
935
|
+
submittedBy?: string;
|
|
843
936
|
/** «معلومة» — optional explanation shown AFTER the reveal. Null while the question is live (it
|
|
844
937
|
* usually names the answer — same anti-cheat gate as correctAnswerIndex) and null when the
|
|
845
938
|
* question simply has none, which is most of the bank: render the card only when non-null. */
|
|
@@ -875,6 +968,26 @@ interface GameStateView {
|
|
|
875
968
|
* paused (e.g. steal intro), or after time-up. Present ⇒ animate a countdown from
|
|
876
969
|
* it; the server remains the authority on actual time-up. */
|
|
877
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
|
+
};
|
|
878
991
|
}
|
|
879
992
|
/** Transient presentation effects the relay emits; clients play them once. */
|
|
880
993
|
type Effect = {
|
|
@@ -905,6 +1018,21 @@ type Effect = {
|
|
|
905
1018
|
t: 'scorePop';
|
|
906
1019
|
amount: number;
|
|
907
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;
|
|
908
1036
|
} | {
|
|
909
1037
|
t: 'chargeCredit';
|
|
910
1038
|
} | {
|
|
@@ -963,6 +1091,73 @@ interface DamaStateView {
|
|
|
963
1091
|
moveCount: number;
|
|
964
1092
|
black?: string;
|
|
965
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
|
+
};
|
|
966
1161
|
}
|
|
967
1162
|
/**
|
|
968
1163
|
* The authoritative Ersimha round state. Anti-cheat boundary (D-030): `word` is set ONLY in the
|
|
@@ -1039,7 +1234,7 @@ type ServerMsg = {
|
|
|
1039
1234
|
packs?: PackView[];
|
|
1040
1235
|
selectedCategoryIds: string[];
|
|
1041
1236
|
teams: TeamSlotView[];
|
|
1042
|
-
hostActivity: 'configuring' | 'idle';
|
|
1237
|
+
hostActivity: 'options' | 'configuring' | 'idle';
|
|
1043
1238
|
tally?: {
|
|
1044
1239
|
[playerId: string]: number;
|
|
1045
1240
|
};
|
|
@@ -1061,6 +1256,12 @@ type ServerMsg = {
|
|
|
1061
1256
|
view: DamaStateView;
|
|
1062
1257
|
you: YouContext;
|
|
1063
1258
|
roster?: RosterEntry[];
|
|
1259
|
+
} | {
|
|
1260
|
+
t: 'sebaqState';
|
|
1261
|
+
gameId: 'sebaq';
|
|
1262
|
+
view: SebaqStateView;
|
|
1263
|
+
you: YouContext;
|
|
1264
|
+
roster?: RosterEntry[];
|
|
1064
1265
|
} | {
|
|
1065
1266
|
t: 'ersimhaState';
|
|
1066
1267
|
gameId: 'ersimha';
|
|
@@ -1103,5 +1304,40 @@ type ServerMsg = {
|
|
|
1103
1304
|
message: string;
|
|
1104
1305
|
reason?: string;
|
|
1105
1306
|
};
|
|
1307
|
+
/** Review states for a community submission. ⚠️ `approved` is NOT here: it is a DEPRECATED alias
|
|
1308
|
+
* written by an older geem-admin, mapped to `accepted` by the relay and permitted by 0031's CHECK
|
|
1309
|
+
* only until that write is switched. It must never become a fourth state. */
|
|
1310
|
+
declare const SUBMISSION_STATUSES: readonly ["pending", "accepted", "rejected"];
|
|
1311
|
+
type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
|
|
1312
|
+
/** A brand-new question vs a correction to one already in the bank. */
|
|
1313
|
+
declare const SUBMISSION_KINDS: readonly ["new", "edit"];
|
|
1314
|
+
type SubmissionKind = (typeof SUBMISSION_KINDS)[number];
|
|
1315
|
+
/** Why a submission was turned down. Blame-free by construction — the closest to "you did badly" is
|
|
1316
|
+
* `duplicate`, which is about the BANK, not the writer. Clients translate these and must fall back
|
|
1317
|
+
* to `other` on anything unrecognised, which is exactly what makes adding a seventh survivable for a
|
|
1318
|
+
* build that shipped last month and will never be updated. */
|
|
1319
|
+
declare const REJECT_REASONS: readonly ["duplicate", "unclear_answer", "not_family", "inaccurate", "too_narrow", "other"];
|
|
1320
|
+
type RejectReason = (typeof REJECT_REASONS)[number];
|
|
1321
|
+
/** One row of «أسئلتي». Exists as a named type so the native generator EMITS the three unions above
|
|
1322
|
+
* — quicktype only reaches what a root references, and hand-typed constants are the whole bug. */
|
|
1323
|
+
interface MyQuestionRow {
|
|
1324
|
+
id: string;
|
|
1325
|
+
status: SubmissionStatus;
|
|
1326
|
+
kind: SubmissionKind;
|
|
1327
|
+
text: string;
|
|
1328
|
+
options: string[];
|
|
1329
|
+
correctIndex: number;
|
|
1330
|
+
explain: string | null;
|
|
1331
|
+
createdAt: string;
|
|
1332
|
+
accepted?: {
|
|
1333
|
+
plays: number;
|
|
1334
|
+
firstPlayedAt: string | null;
|
|
1335
|
+
coins: number;
|
|
1336
|
+
};
|
|
1337
|
+
rejected?: {
|
|
1338
|
+
reason: RejectReason;
|
|
1339
|
+
canResubmit: boolean;
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1106
1342
|
|
|
1107
|
-
export { type AvatarView, type BuzzerGameMode, CHARACTER_IDS, type CategoryView, type CharacterId, ClientMsg, type DamaMoveView, type DamaPieceView, type DamaStateView, EMOTE_IDS, type Effect, type EmoteId, type ErsimhaStateView, GAMES, GAME_IDS, type GameId, type GameInfo, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, MIN_SUPPORTED_PV, MODES, MODE_IDS, type ModeId, type ModeInfo, PROTOCOL_VERSION, type PackView, type QuestionView, ROOM_ALPHABET, type RosterEntry, type ServerMsg, type SoundName, type StrokePointView, type TeamSlotView, type TeamView, type YouContext, gameIdForPv, gamesForPv, modesForGame, normalizeWesternDigits };
|
|
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 =
|
|
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);
|
|
@@ -39,7 +61,8 @@ var CHARACTER_IDS = ["m", "f"];
|
|
|
39
61
|
var ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
|
|
40
62
|
var UNSAFE_CHARS = /[\p{Cc}\u202A-\u202E\u2066-\u2069<>]/gu;
|
|
41
63
|
var normalizeWesternDigits = (s) => s.replace(/[٠-٩۰-۹]/g, (d) => String("\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669".indexOf(d) >= 0 ? "\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669".indexOf(d) : "\u06F0\u06F1\u06F2\u06F3\u06F4\u06F5\u06F6\u06F7\u06F8\u06F9".indexOf(d)));
|
|
42
|
-
var
|
|
64
|
+
var sanitizeName = (s) => normalizeWesternDigits(s.replace(UNSAFE_CHARS, "")).trim();
|
|
65
|
+
var DisplayName = z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform(sanitizeName).refine((s) => s.length >= 1, "name is empty after sanitizing");
|
|
43
66
|
var RoomCode = z.string().trim().toUpperCase().regex(new RegExp(`^[${ROOM_ALPHABET}]{${LIMITS.ROOM_CODE_LEN}}$`), "bad room code");
|
|
44
67
|
var PlayerId = z.string().max(40);
|
|
45
68
|
var HelpType = z.enum([
|
|
@@ -96,6 +119,42 @@ var ClientMsg = z.discriminatedUnion("t", [
|
|
|
96
119
|
z.object({ t: z.literal("kick"), playerId: PlayerId }),
|
|
97
120
|
// Host's IN-PROGRESS category selection in the lobby — a live preview for waiting
|
|
98
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() }),
|
|
99
158
|
z.object({ t: z.literal("setCategories"), categoryIds: z.array(z.string().max(60)).max(12) }),
|
|
100
159
|
// Host defines the lobby teams players can join (0–12; empty = disband back to the
|
|
101
160
|
// one-team-per-player default). Resets picks that fall out of range.
|
|
@@ -112,10 +171,32 @@ var ClientMsg = z.discriminatedUnion("t", [
|
|
|
112
171
|
// `modeId` alone still works (pre-gs#49 clients); `game` is what lets a host announce a tile with
|
|
113
172
|
// no modes (dama/ersimha), which `modeId` could never express. An incoherent pair (a mode that
|
|
114
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") }),
|
|
115
184
|
z.object({ t: z.literal("setMode"), game: z.enum(GAME_IDS).optional(), modeId: z.enum(MODE_IDS).optional() }),
|
|
116
185
|
// Host signals it's busy configuring the game (categories/rounds/mode) so waiting players can show
|
|
117
186
|
// a "host is setting up…" state instead of a frozen-looking lobby. 'idle' when the settings close.
|
|
118
|
-
|
|
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.
|
|
119
200
|
// host game actions
|
|
120
201
|
z.object({
|
|
121
202
|
t: z.literal("startGame"),
|
|
@@ -228,8 +309,12 @@ var ClientMsg = z.discriminatedUnion("t", [
|
|
|
228
309
|
// Drawer passes on the served word (capped per draw-turn; relay serves a fresh one).
|
|
229
310
|
z.object({ t: z.literal("skipWord") })
|
|
230
311
|
]);
|
|
312
|
+
var SUBMISSION_STATUSES = ["pending", "accepted", "rejected"];
|
|
313
|
+
var SUBMISSION_KINDS = ["new", "edit"];
|
|
314
|
+
var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate", "too_narrow", "other"];
|
|
231
315
|
export {
|
|
232
316
|
CHARACTER_IDS,
|
|
317
|
+
COMEBACK_BONUS_MIN_PV,
|
|
233
318
|
ClientMsg,
|
|
234
319
|
EMOTE_IDS,
|
|
235
320
|
GAMES,
|
|
@@ -239,9 +324,15 @@ export {
|
|
|
239
324
|
MODES,
|
|
240
325
|
MODE_IDS,
|
|
241
326
|
PROTOCOL_VERSION,
|
|
327
|
+
REJECT_REASONS,
|
|
242
328
|
ROOM_ALPHABET,
|
|
329
|
+
SUBMISSION_KINDS,
|
|
330
|
+
SUBMISSION_STATUSES,
|
|
331
|
+
effectsForPv,
|
|
243
332
|
gameIdForPv,
|
|
244
333
|
gamesForPv,
|
|
334
|
+
hostActivityForPv,
|
|
245
335
|
modesForGame,
|
|
246
|
-
normalizeWesternDigits
|
|
336
|
+
normalizeWesternDigits,
|
|
337
|
+
sanitizeName
|
|
247
338
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegeem/protocol",
|
|
3
|
-
"version": "0.1.
|
|
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"
|