@thegeem/protocol 0.1.38 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/copy-rules.json +120 -0
- package/dist/index.cjs +96 -6
- package/dist/index.d.cts +213 -16
- package/dist/index.d.ts +213 -16
- package/dist/index.js +92 -6
- package/package.json +3 -2
package/copy-rules.json
ADDED
|
@@ -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,8 @@ 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,
|
|
25
|
+
COMEBACK_EFFECTS: () => COMEBACK_EFFECTS,
|
|
24
26
|
ClientMsg: () => ClientMsg,
|
|
25
27
|
EMOTE_IDS: () => EMOTE_IDS,
|
|
26
28
|
GAMES: () => GAMES,
|
|
@@ -34,8 +36,10 @@ __export(index_exports, {
|
|
|
34
36
|
ROOM_ALPHABET: () => ROOM_ALPHABET,
|
|
35
37
|
SUBMISSION_KINDS: () => SUBMISSION_KINDS,
|
|
36
38
|
SUBMISSION_STATUSES: () => SUBMISSION_STATUSES,
|
|
39
|
+
effectsForPv: () => effectsForPv,
|
|
37
40
|
gameIdForPv: () => gameIdForPv,
|
|
38
41
|
gamesForPv: () => gamesForPv,
|
|
42
|
+
hostActivityForPv: () => hostActivityForPv,
|
|
39
43
|
modesForGame: () => modesForGame,
|
|
40
44
|
normalizeWesternDigits: () => normalizeWesternDigits,
|
|
41
45
|
sanitizeName: () => sanitizeName
|
|
@@ -51,9 +55,9 @@ var LIMITS = {
|
|
|
51
55
|
* Clients batch pen points to stay under this — ~150ms batching lands at ~7/sec. */
|
|
52
56
|
STROKE_BATCH_PER_SEC: 20
|
|
53
57
|
};
|
|
54
|
-
var PROTOCOL_VERSION =
|
|
58
|
+
var PROTOCOL_VERSION = 7;
|
|
55
59
|
var MIN_SUPPORTED_PV = 1;
|
|
56
|
-
var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha"];
|
|
60
|
+
var GAME_IDS = ["seedha", "snag", "trivia", "dama", "ersimha", "sebaq"];
|
|
57
61
|
var GAMES = [
|
|
58
62
|
{ id: "seedha", label: "Seedha", minPv: 2 },
|
|
59
63
|
{ id: "snag", label: "Snag", minPv: 2 },
|
|
@@ -61,8 +65,31 @@ var GAMES = [
|
|
|
61
65
|
// engine #2 (D-027) — offered only to pv≥3 clients
|
|
62
66
|
{ id: "ersimha", label: "Ersimha", minPv: 4 },
|
|
63
67
|
// engine #3 (D-030) draw-and-guess — offered only to pv≥4 clients
|
|
68
|
+
// ⚠️ A NEW GAME, NOT A MODE OF SNAG, AND THE NAME COLLISION IS WHY IT HAD TO BE. `buzzerMode: 'race'`
|
|
69
|
+
// ALREADY MEANS ديوانية's buzz-race — everyone racing to buzz for ONE shared question. Sebaq is the
|
|
70
|
+
// opposite: no shared question, no buzzer, no turns. Every player runs the same set privately, at
|
|
71
|
+
// their own speed. Putting a mode called `race` beside a buzzerMode called `race`, meaning opposite
|
|
72
|
+
// things, one string apart on the wire, is the worst naming available here.
|
|
73
|
+
//
|
|
74
|
+
// It is also the wrong SHAPE for a mode: every MODES row is a way of playing seedha's turn loop —
|
|
75
|
+
// one question, one board, a shared clock. Sebaq uses none of it. A mode that shares no mechanic
|
|
76
|
+
// with its game is a game.
|
|
77
|
+
//
|
|
78
|
+
// ✅ NAME RULED BY THE OWNER, 2026-08-25: «سباق sebaq». Renamed from the provisional `sabag` before
|
|
79
|
+
// any client had shipped the string — which was the entire reason for saying it was provisional.
|
|
80
|
+
//
|
|
81
|
+
// ⚠️ `q` IS ق, AND IT IS NOT AN EXCEPTION TO THE BRAND RULE — I filed it as one and the owner
|
|
82
|
+
// corrected me. `CLAUDE.md`'s «Najdi ق = g» is about how ق SOUNDS in قييم; `q` is what the LETTER
|
|
83
|
+
// is. Two different questions, and `sebaq` answers the second. **Do not "fix" this to `sebag`.**
|
|
84
|
+
{ id: "sebaq", label: "Sebaq", minPv: 5 },
|
|
85
|
+
// سباق — the race tile, offered only to pv≥5 clients
|
|
64
86
|
{ id: "trivia", label: "Trivia", minPv: 1, deprecated: true }
|
|
65
87
|
];
|
|
88
|
+
var hostActivityForPv = (activity, pv) => activity === "options" && (pv ?? MIN_SUPPORTED_PV) < 6 ? "configuring" : activity;
|
|
89
|
+
var COMEBACK_BONUS_MIN_PV = 7;
|
|
90
|
+
var COMEBACK_EFFECTS = ["comebackOffer", "comebackBonus", "comebackDeclined"];
|
|
91
|
+
var COMEBACK_EFFECT_SET = new Set(COMEBACK_EFFECTS);
|
|
92
|
+
var effectsForPv = (effects, pv) => (pv ?? MIN_SUPPORTED_PV) >= COMEBACK_BONUS_MIN_PV ? effects : effects.filter((e) => !COMEBACK_EFFECT_SET.has(e.t));
|
|
66
93
|
var gamesForPv = (pv) => GAMES.filter((g) => !g.deprecated && g.minPv <= pv);
|
|
67
94
|
var gameIdForPv = (gameId, pv) => {
|
|
68
95
|
const g = GAMES.find((x) => x.id === gameId);
|
|
@@ -97,6 +124,7 @@ var HelpType = import_zod.z.enum([
|
|
|
97
124
|
var Pv = import_zod.z.number().int().min(1).max(1e6).optional();
|
|
98
125
|
var IdToken = import_zod.z.string().max(4096).optional();
|
|
99
126
|
var Platform = import_zod.z.enum(["web", "ios", "android", "tv"]).optional();
|
|
127
|
+
var Games = import_zod.z.array(import_zod.z.enum(GAME_IDS)).max(GAME_IDS.length).optional();
|
|
100
128
|
var AppVersion = import_zod.z.string().trim().max(24).regex(/^[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]+)?$/, "bad app version").optional();
|
|
101
129
|
var Avatar = import_zod.z.object({
|
|
102
130
|
emoji: import_zod.z.string().min(1).max(16).transform((s) => s.replace(UNSAFE_CHARS, "")).refine((s) => s.length >= 1, "empty emoji"),
|
|
@@ -105,7 +133,7 @@ var Avatar = import_zod.z.object({
|
|
|
105
133
|
var Character = import_zod.z.enum(CHARACTER_IDS);
|
|
106
134
|
var ClientMsg = import_zod.z.discriminatedUnion("t", [
|
|
107
135
|
// room / lobby
|
|
108
|
-
import_zod.z.object({ t: import_zod.z.literal("createRoom"), name: DisplayName.optional(), avatar: Avatar.optional(), game: import_zod.z.enum(GAME_IDS).optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
|
|
136
|
+
import_zod.z.object({ t: import_zod.z.literal("createRoom"), name: DisplayName.optional(), avatar: Avatar.optional(), game: import_zod.z.enum(GAME_IDS).optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
|
|
109
137
|
// Host sets/changes their display name AFTER createRoom (for anonymous one-tap
|
|
110
138
|
// hosts who started nameless). Host-only; the relay re-broadcasts the lobby.
|
|
111
139
|
// SUPERSEDED by setProfile (kept working for shipped builds).
|
|
@@ -119,8 +147,8 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
|
|
|
119
147
|
// view carries names+avatars so it updates live; dama/ersimha views are id-keyed (clients
|
|
120
148
|
// map ids→names from the lobby roster), so a change there lands on the next lobby.
|
|
121
149
|
import_zod.z.object({ t: import_zod.z.literal("setProfile"), name: DisplayName.optional(), avatar: Avatar.optional(), character: Character.optional() }),
|
|
122
|
-
import_zod.z.object({ t: import_zod.z.literal("joinRoom"), code: RoomCode, name: DisplayName, avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
|
|
123
|
-
import_zod.z.object({ t: import_zod.z.literal("resume"), code: RoomCode, token: import_zod.z.string().max(120), avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, appVersion: AppVersion }),
|
|
150
|
+
import_zod.z.object({ t: import_zod.z.literal("joinRoom"), code: RoomCode, name: DisplayName, avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
|
|
151
|
+
import_zod.z.object({ t: import_zod.z.literal("resume"), code: RoomCode, token: import_zod.z.string().max(120), avatar: Avatar.optional(), character: Character.optional(), pv: Pv, idToken: IdToken, platform: Platform, games: Games, appVersion: AppVersion }),
|
|
124
152
|
// `tally` (gs#46) = wins per PLAYER across every game this room has played tonight — «نتيجة الليلة».
|
|
125
153
|
// Keyed by playerId, NOT by team: team names are recycled each game, so a per-team count means nothing
|
|
126
154
|
// across a night. Survives playAgain. A One Device game credits nobody (its team slots hold no players).
|
|
@@ -139,6 +167,42 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
|
|
|
139
167
|
import_zod.z.object({ t: import_zod.z.literal("kick"), playerId: PlayerId }),
|
|
140
168
|
// Host's IN-PROGRESS category selection in the lobby — a live preview for waiting
|
|
141
169
|
// players. Distinct from startGame.categoryIds, which COMMITS the board. Empty = cleared.
|
|
170
|
+
// ⚠️ AN EMPTY `categoryIds` IS A MEANINGFUL MESSAGE — DO NOT ADD `.min(1)`.
|
|
171
|
+
//
|
|
172
|
+
// The host sends `setCategories([])` the moment they press «التالي», before picking anything.
|
|
173
|
+
//
|
|
174
|
+
// ⚠️ THE THREE-STATE READING RECORDED IN a33f305 IS NOT DELIVERABLE ON THIS WIRE, AND A CLIENT
|
|
175
|
+
// WRITTEN TO IT WILL BE «FIXED» IN THE WRONG DIRECTION. It said:
|
|
176
|
+
// absent → still in the lobby
|
|
177
|
+
// present but EMPTY → the host is choosing
|
|
178
|
+
// non-empty → the picks card
|
|
179
|
+
// The first state does not exist. `socket.ts` creates every room with `selectedCategoryIds: []`
|
|
180
|
+
// and `rooms.ts` serialises it on EVERY lobby frame, so a joiner's very first frame already carries
|
|
181
|
+
// `[]` — byte-identical to the host having pressed «التالي». QA measured it on prod (room J7UY,
|
|
182
|
+
// raw frames in geem-qa `findings/sebaq/race2.jsonl`): the joiner's lobby before and after the
|
|
183
|
+
// host's empty `setCategories` differ in nothing on this field. On iOS the visible result is that
|
|
184
|
+
// joiners read «يختار الفئات» the moment they land. (geem-server#118.)
|
|
185
|
+
//
|
|
186
|
+
// ⚠️ `hostActivity` IS THE CARRIER FOR «THE HOST IS CHOOSING» — it is the field built for this exact
|
|
187
|
+
// question and it distinguishes the state this one cannot: `'options'` (the host is on the settings
|
|
188
|
+
// surface) vs `'configuring'` vs `'idle'`. Read THAT for the waiting-screen line; read
|
|
189
|
+
// `selectedCategoryIds` only for WHICH categories, never for WHETHER the host has begun.
|
|
190
|
+
//
|
|
191
|
+
// What stays true from a33f305, and is still worth its test: an empty array must PARSE. Adding
|
|
192
|
+
// `.min(1)` would reject a message the client is right to send.
|
|
193
|
+
//
|
|
194
|
+
// Without it, joiners sat on «ناطرين … يبدأ اللعبة» for as long as the host browsed and only learned
|
|
195
|
+
// anything had happened when the FIRST pick landed (owner, 2026-08-26; fixed by the iOS desk).
|
|
196
|
+
//
|
|
197
|
+
// ⚠️ THE FAILURE IF SOMEONE TIGHTENS THIS IS SILENT AND LOOKS LIKE A CLIENT BUG. Nothing crashes:
|
|
198
|
+
// every joiner in every category game just goes back to «waiting to start» until the first pick.
|
|
199
|
+
// The relay would be rejecting a message the client is right to send, and the search would start on
|
|
200
|
+
// the client. Applies to صيدها, بالدور and سباق alike — they share one lobby.
|
|
201
|
+
// The host's verdict on a comeback offer (owner ruling 2026-08-27). `grant: false` is a real and
|
|
202
|
+
// expected outcome — «if he refuse then he will not get it» — and closes the offer with nothing.
|
|
203
|
+
// ⚠️ HOST-ONLY, and the relay enforces it. A trailing team that could grant its own bonus is not a
|
|
204
|
+
// bonus, it is a button that says «+100».
|
|
205
|
+
import_zod.z.object({ t: import_zod.z.literal("comebackDecide"), grant: import_zod.z.boolean() }),
|
|
142
206
|
import_zod.z.object({ t: import_zod.z.literal("setCategories"), categoryIds: import_zod.z.array(import_zod.z.string().max(60)).max(12) }),
|
|
143
207
|
// Host defines the lobby teams players can join (0–12; empty = disband back to the
|
|
144
208
|
// one-team-per-player default). Resets picks that fall out of range.
|
|
@@ -155,10 +219,32 @@ var ClientMsg = import_zod.z.discriminatedUnion("t", [
|
|
|
155
219
|
// `modeId` alone still works (pre-gs#49 clients); `game` is what lets a host announce a tile with
|
|
156
220
|
// no modes (dama/ersimha), which `modeId` could never express. An incoherent pair (a mode that
|
|
157
221
|
// doesn't belong to the game) is rejected with `bad_mode` rather than silently resolved.
|
|
222
|
+
// ── Sebaq slice (gameId 'sebaq', the RACE tile) ──
|
|
223
|
+
// ⚠️ THE SERVER JUDGES AND THE SERVER TIMES. The client sends which option was tapped and nothing
|
|
224
|
+
// else — no «correct», no elapsed ms. A client-reported time makes the best connection win, and a
|
|
225
|
+
// client-reported verdict makes the race unwinnable honestly.
|
|
226
|
+
import_zod.z.object({ t: import_zod.z.literal("sebaqAnswer"), optionIndex: import_zod.z.number().int().min(0).max(5) }),
|
|
227
|
+
// Rule 3: after ONE wrong answer the player chooses. `sebaqRetry` re-opens the SAME question for
|
|
228
|
+
// half points; `sebaqSkip` takes 0 and moves on. Two tiers only (rule 4) — a second wrong answer
|
|
229
|
+
// needs no message, it just ends the question.
|
|
230
|
+
import_zod.z.object({ t: import_zod.z.literal("sebaqRetry") }),
|
|
231
|
+
import_zod.z.object({ t: import_zod.z.literal("sebaqSkip") }),
|
|
158
232
|
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
233
|
// Host signals it's busy configuring the game (categories/rounds/mode) so waiting players can show
|
|
160
234
|
// a "host is setting up…" state instead of a frozen-looking lobby. 'idle' when the settings close.
|
|
161
|
-
|
|
235
|
+
// ⚠️ THREE VALUES SINCE 2026-08-26 (Options page, geem-coordination#24). The host's pre-game
|
|
236
|
+
// journey is Lobby → Options → Categories, and joiners route on this:
|
|
237
|
+
// idle → the lobby
|
|
238
|
+
// options → «how we play» — the per-game settings page, read-only for joiners
|
|
239
|
+
// configuring → «… يختار الفئات» — the category picker
|
|
240
|
+
//
|
|
241
|
+
// ⚠️ THE RELAY MUST ACCEPT `options` BEFORE ANY CLIENT SENDS IT, which is why this ships ahead of
|
|
242
|
+
// the page: a new client sending it to an old relay is a `bad_message`, the activity stays whatever
|
|
243
|
+
// it was, and every joiner shows the wrong banner with nothing erroring.
|
|
244
|
+
import_zod.z.object({ t: import_zod.z.literal("hostActivity"), activity: import_zod.z.enum(["options", "configuring", "idle"]) }),
|
|
245
|
+
// ⚠️ AND IT IS ADAPTED ON THE WAY OUT (D-025), which is what makes it shippable without a
|
|
246
|
+
// coordinated release. `hostActivityForPv` below down-maps `options` → `configuring` for any client
|
|
247
|
+
// below pv6 — see there for the failure it prevents.
|
|
162
248
|
// host game actions
|
|
163
249
|
import_zod.z.object({
|
|
164
250
|
t: import_zod.z.literal("startGame"),
|
|
@@ -277,6 +363,8 @@ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate",
|
|
|
277
363
|
// Annotate the CommonJS export names for ESM import in node:
|
|
278
364
|
0 && (module.exports = {
|
|
279
365
|
CHARACTER_IDS,
|
|
366
|
+
COMEBACK_BONUS_MIN_PV,
|
|
367
|
+
COMEBACK_EFFECTS,
|
|
280
368
|
ClientMsg,
|
|
281
369
|
EMOTE_IDS,
|
|
282
370
|
GAMES,
|
|
@@ -290,8 +378,10 @@ var REJECT_REASONS = ["duplicate", "unclear_answer", "not_family", "inaccurate",
|
|
|
290
378
|
ROOM_ALPHABET,
|
|
291
379
|
SUBMISSION_KINDS,
|
|
292
380
|
SUBMISSION_STATUSES,
|
|
381
|
+
effectsForPv,
|
|
293
382
|
gameIdForPv,
|
|
294
383
|
gamesForPv,
|
|
384
|
+
hostActivityForPv,
|
|
295
385
|
modesForGame,
|
|
296
386
|
normalizeWesternDigits,
|
|
297
387
|
sanitizeName
|