@indiegems/gem-web-sdk 7.3.1 → 7.4.1
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/README.md +54 -0
- package/dist/game.d.ts +156 -22
- package/dist/gem-web-sdk.bridge.js +1 -1
- package/dist/gem-web-sdk.esm.js +2 -2
- package/dist/gem-web-sdk.game.js +2 -2
- package/dist/gem-web-sdk.min.js +2 -2
- package/dist/gem-web-sdk.sdk.js +2 -2
- package/dist/gem-web-sdk.server.js +2 -2
- package/dist/index.d.ts +89 -6
- package/dist/sdk.d.ts +156 -22
- package/docs/achievements.md +232 -0
- package/docs/analytics.md +271 -0
- package/docs/api/api-reference-game.md +15308 -0
- package/docs/api/api-reference-server.md +4397 -0
- package/docs/api/reference.json +24130 -0
- package/docs/arcade-economy.md +263 -0
- package/docs/async-opponents.md +396 -0
- package/docs/brand-art.md +334 -0
- package/docs/cross-promotion.md +137 -0
- package/docs/daily-challenges.md +530 -0
- package/docs/dedicated-server-flow.md +879 -0
- package/docs/deprecation-policy.md +124 -0
- package/docs/friends.md +233 -0
- package/docs/game-economy-tutorial.md +326 -0
- package/docs/getting-started.md +552 -0
- package/docs/inventory-and-catalogs.md +1035 -0
- package/docs/leaderboards.md +277 -0
- package/docs/manifest.json +135 -0
- package/docs/match-session.md +288 -0
- package/docs/p2p-and-parties.md +783 -0
- package/docs/platform-rules.md +372 -0
- package/docs/player-sign-in.md +89 -0
- package/docs/room-shapes.md +195 -0
- package/docs/rooms-and-matches.md +779 -0
- package/docs/saving-player-progress.md +457 -0
- package/docs/server-bundles.md +1041 -0
- package/docs/serverless-functions.md +748 -0
- package/docs/trading-and-gifting.md +98 -0
- package/docs/what-a-game-must-do.md +1231 -0
- package/package.json +7 -2
- package/schema/bridge.v1.json +440 -7
- package/schema/capabilities.public.json +189 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indiegems/gem-web-sdk",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.4.1",
|
|
4
4
|
"description": "postMessage bridge contract between gem-web and browser-playable games",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
"!dist/*.map",
|
|
41
41
|
"!dist/gem-web-sdk.devtools.min.js",
|
|
42
42
|
"schema/bridge.v1.json",
|
|
43
|
-
"
|
|
43
|
+
"schema/capabilities.public.json",
|
|
44
|
+
"brand/*.png",
|
|
45
|
+
"docs/*.md",
|
|
46
|
+
"docs/manifest.json",
|
|
47
|
+
"docs/api/*.md",
|
|
48
|
+
"docs/api/reference.json"
|
|
44
49
|
]
|
|
45
50
|
}
|
package/schema/bridge.v1.json
CHANGED
|
@@ -794,6 +794,41 @@
|
|
|
794
794
|
"rateLimit": { "perMinute": 12 }
|
|
795
795
|
},
|
|
796
796
|
|
|
797
|
+
{
|
|
798
|
+
"type": "gem.earnAllowanceChanged",
|
|
799
|
+
"direction": "parentToFrame",
|
|
800
|
+
"transport": "port",
|
|
801
|
+
"$comment": [
|
|
802
|
+
"The player's earn allowance for this game MAY have changed, so the",
|
|
803
|
+
"frame should re-read it from gem-api",
|
|
804
|
+
"(`GET /v1/games/{game_id}/players/me/earn-allowance`) if it shows it.",
|
|
805
|
+
"Gated on `earnAllowance`: a parent sends it only when",
|
|
806
|
+
"`frameSupports('earnAllowance')`.",
|
|
807
|
+
"",
|
|
808
|
+
"NO FIELDS, AND IT SHOULD STAY THAT WAY. The frame re-reads from gem-api,",
|
|
809
|
+
"the authority, so the bridge never becomes a second source of truth for",
|
|
810
|
+
"the figure. This message never carries `limit`, `earned`, `remaining`, a",
|
|
811
|
+
"balance, a grant amount or any game's id: a figure here would be one a",
|
|
812
|
+
"parent could get wrong, and one the game could not tell from the real",
|
|
813
|
+
"answer. `gem.requestContext` and `gem.ready` are the precedent for a",
|
|
814
|
+
"message that carries only its own occurrence.",
|
|
815
|
+
"",
|
|
816
|
+
"WHEN A PARENT MAY SEND IT: after a grant by this game, or after a",
|
|
817
|
+
"platform-wide change the parent knows moved this game's consolidated",
|
|
818
|
+
"figure. NOT after another game's grant that left this game's figure",
|
|
819
|
+
"where it was: that send would tell this game, to the second, when",
|
|
820
|
+
"another game paid the player -- a timing oracle for payouts this game",
|
|
821
|
+
"is otherwise never shown. It should coalesce a burst -- one trailing",
|
|
822
|
+
"send per second or so -- so it stays well inside the limit.",
|
|
823
|
+
"",
|
|
824
|
+
"A HINT, NOT A GUARANTEE. A game reads the allowance on its own schedule",
|
|
825
|
+
"and never depends on this message to read it; an embedder that never",
|
|
826
|
+
"sends it is not defective. The SDK makes no request because of it."
|
|
827
|
+
],
|
|
828
|
+
"fields": {},
|
|
829
|
+
"rateLimit": { "perMinute": 30 }
|
|
830
|
+
},
|
|
831
|
+
|
|
797
832
|
{
|
|
798
833
|
"type": "gem.viewport",
|
|
799
834
|
"direction": "parentToFrame",
|
|
@@ -823,11 +858,34 @@
|
|
|
823
858
|
"variables stay unset and a game's `var(--gem-safe-top, env(...))` falls",
|
|
824
859
|
"back to the browser's own answer.",
|
|
825
860
|
"",
|
|
826
|
-
"
|
|
861
|
+
"GEOMETRY ONLY — no chrome state, no form factor, no orientation. What the",
|
|
827
862
|
"parent's chrome is doing is the parent's business, and a form-factor",
|
|
828
863
|
"signal crossing the bridge is an owner decision this message does not",
|
|
829
|
-
"reopen (
|
|
830
|
-
"game learns
|
|
864
|
+
"reopen (docs/what-a-game-must-do.md, the television section). A",
|
|
865
|
+
"game learns WHICH part of its box is unusable, never why.",
|
|
866
|
+
"",
|
|
867
|
+
"AMENDED, DELIBERATELY: this message once carried insets only. It now also",
|
|
868
|
+
"carries `reserved`, rectangles of the frame covered by the parent's own",
|
|
869
|
+
"controls floating over the game (a Back tab in a corner, the wallet chips",
|
|
870
|
+
"shown beside it). An inset can only describe a full-width or full-height band,",
|
|
871
|
+
"and a corner tab expressed as a top inset would take the whole top strip",
|
|
872
|
+
"from a full-bleed game to clear a control a few dozen pixels wide. A",
|
|
873
|
+
"rectangle says exactly which part, and still says nothing about what the",
|
|
874
|
+
"control is, whether it is showing a balance, or why it is there: the",
|
|
875
|
+
"principle above holds, only the shape of the answer is finer.",
|
|
876
|
+
"",
|
|
877
|
+
"THE FOLD, for a frame that cannot read `reserved`. A frame that did not",
|
|
878
|
+
"advertise `reservedAreas` predates the field and would ignore it (a",
|
|
879
|
+
"receiver ignores unknown fields), leaving its HUD under the parent's",
|
|
880
|
+
"control. So a conforming parent sends `reserved` ONLY to a frame that",
|
|
881
|
+
"advertised `reservedAreas`, with the true insets beside it; to any other",
|
|
882
|
+
"frame it sends NO `reserved` and instead folds the area into",
|
|
883
|
+
"`insets.top`: the larger of the true top inset and the bottom edge",
|
|
884
|
+
"(`y + height`) of every reserved rectangle that touches the frame's top",
|
|
885
|
+
"edge (`y` of 0). A game honouring `--gem-safe-top` then clears the control",
|
|
886
|
+
"at the cost of a full-width band, which is the best an older frame can",
|
|
887
|
+
"be told. A reserved rectangle that does not touch the top edge has no",
|
|
888
|
+
"fold and is not sent to such a frame at all.",
|
|
831
889
|
"",
|
|
832
890
|
"NOT the TV overscan allowance. `env()` is 0 on every television and the",
|
|
833
891
|
"title-safe margin is documented as the game's own; folding the arcade's",
|
|
@@ -847,6 +905,30 @@
|
|
|
847
905
|
"bottom": { "kind": "int", "min": 0, "max": 4096 },
|
|
848
906
|
"left": { "kind": "int", "min": 0, "max": 4096 }
|
|
849
907
|
}
|
|
908
|
+
},
|
|
909
|
+
"reserved": {
|
|
910
|
+
"kind": "array",
|
|
911
|
+
"maxItems": 4,
|
|
912
|
+
"optional": true,
|
|
913
|
+
"$comment": [
|
|
914
|
+
"Rectangles of the frame covered by the parent's controls, in CSS px of",
|
|
915
|
+
"the frame's own coordinate space measured from its top-left corner — the",
|
|
916
|
+
"same space as `insets` and `gem.rewardCue`'s `origin`. A game must not put",
|
|
917
|
+
"essential UI or a touch target inside one. Sent only to a frame that",
|
|
918
|
+
"advertised `reservedAreas`; absent means the parent reserves nothing, and",
|
|
919
|
+
"an EMPTY array means the same, said explicitly. Rounded OUTWARD by the",
|
|
920
|
+
"sender, like the insets: the conservative direction keeps a button clear",
|
|
921
|
+
"of the control. 16384 is a bound, not a size."
|
|
922
|
+
],
|
|
923
|
+
"items": {
|
|
924
|
+
"kind": "object",
|
|
925
|
+
"fields": {
|
|
926
|
+
"x": { "kind": "int", "min": 0, "max": 16384 },
|
|
927
|
+
"y": { "kind": "int", "min": 0, "max": 16384 },
|
|
928
|
+
"width": { "kind": "int", "min": 0, "max": 16384 },
|
|
929
|
+
"height": { "kind": "int", "min": 0, "max": 16384 }
|
|
930
|
+
}
|
|
931
|
+
}
|
|
850
932
|
}
|
|
851
933
|
},
|
|
852
934
|
"rateLimit": { "perMinute": 60 }
|
|
@@ -945,7 +1027,7 @@
|
|
|
945
1027
|
"The parent holds that socket because it holds the player token and",
|
|
946
1028
|
"`POST /v1/ws/ticket` accepts only player tokens. The frame holds a",
|
|
947
1029
|
"game-scoped token by design, so it cannot open the socket and must not be",
|
|
948
|
-
"handed a credential for one. See
|
|
1030
|
+
"handed a credential for one. See docs/rooms-and-matches.md §3.",
|
|
949
1031
|
"",
|
|
950
1032
|
"CARRIES NO ROOM STATE. No member list, no display names, no join code, no",
|
|
951
1033
|
"connection details. The frame re-reads the room over its OWN token and",
|
|
@@ -1118,7 +1200,7 @@
|
|
|
1118
1200
|
"look at a room, and cannot seat anybody in one.",
|
|
1119
1201
|
"",
|
|
1120
1202
|
"NO PARTY ID CROSSES. The payload gem-api sends carries one; it stops",
|
|
1121
|
-
"here. The frame has no party surface (
|
|
1203
|
+
"here. The frame has no party surface (docs/p2p-and-parties.md",
|
|
1122
1204
|
"§7.1), and the v2 room routes deliberately replaced the platform party",
|
|
1123
1205
|
"id with a room-scoped marker — relaying it here would put back the",
|
|
1124
1206
|
"cross-game social-graph fact that removal was for.",
|
|
@@ -1197,7 +1279,7 @@
|
|
|
1197
1279
|
"the frame over the frame's OWN TLS connection (POST",
|
|
1198
1280
|
"/matches/{id}/join-credential), so a parent that rewrites an SDP cannot",
|
|
1199
1281
|
"impersonate a peer — it can only break the connection. See",
|
|
1200
|
-
"
|
|
1282
|
+
"docs/p2p-and-parties.md §3.",
|
|
1201
1283
|
"",
|
|
1202
1284
|
"`data` is gem-api's grammar, not this bridge's. It is emitted as a",
|
|
1203
1285
|
"bounded plain-data object rather than a declared field list because the",
|
|
@@ -1378,7 +1460,7 @@
|
|
|
1378
1460
|
"party name. A party is ACCOUNT-LEVEL AND CROSS-GAME — its record carries",
|
|
1379
1461
|
"which other games the members suggest playing and what platforms they are",
|
|
1380
1462
|
"on — so handing it to a game frame would export the player's social graph",
|
|
1381
|
-
"and their activity across the whole catalog. See
|
|
1463
|
+
"and their activity across the whole catalog. See docs/p2p-and-parties.md",
|
|
1382
1464
|
"§6 and DESIGN.md §10."
|
|
1383
1465
|
],
|
|
1384
1466
|
"fields": {
|
|
@@ -2210,6 +2292,155 @@
|
|
|
2210
2292
|
"rateLimit": { "perMinute": 60 }
|
|
2211
2293
|
},
|
|
2212
2294
|
|
|
2295
|
+
{
|
|
2296
|
+
"type": "gem.walletDisplay",
|
|
2297
|
+
"direction": "frameToParent",
|
|
2298
|
+
"transport": "port",
|
|
2299
|
+
"$comment": [
|
|
2300
|
+
"Whether the arcade shows the player's platform wallets (Gems, Tickets, the",
|
|
2301
|
+
"achievement score) over the game right now — and, optionally, which of them.",
|
|
2302
|
+
"",
|
|
2303
|
+
"OPT-OUT. With no message at all, the parent shows EVERY wallet: a game that",
|
|
2304
|
+
"does nothing gets them. A game hides them while it needs the screen — active",
|
|
2305
|
+
"gameplay, typically — and shows them again on its menus.",
|
|
2306
|
+
"",
|
|
2307
|
+
" visible: false hidden, except event pops (below)",
|
|
2308
|
+
" visible: true, no currencies/score every wallet",
|
|
2309
|
+
" visible: true, currencies/score only those: the slugs in `currencies`,",
|
|
2310
|
+
" plus the score if `score` is true",
|
|
2311
|
+
"",
|
|
2312
|
+
"The narrowing is in force when EITHER optional field is present, and is",
|
|
2313
|
+
"ignored while `visible` is false. It is for a screen about one currency (a",
|
|
2314
|
+
"ticket store); leaving it out is always safe. `currencies` are platform",
|
|
2315
|
+
"currency slugs (`GEMS`, `ARCADE_TICKETS`), matched exactly and",
|
|
2316
|
+
"case-sensitively against the wallets the parent draws; a slug it does not",
|
|
2317
|
+
"draw is ignored. `score` is its own field so it can never collide with a slug.",
|
|
2318
|
+
"",
|
|
2319
|
+
"SCREEN STATE, NOT A TOGGLE. Each message is the whole answer and the latest",
|
|
2320
|
+
"one wins; nothing accumulates. The SDK re-sends the latest state after every",
|
|
2321
|
+
"handshake, so a parent that reconnects never shows wallets over gameplay the",
|
|
2322
|
+
"game had hidden them from.",
|
|
2323
|
+
"",
|
|
2324
|
+
"EVENTS ARE THE PARENT'S AND OVERRIDE THIS. While wallets are hidden (or",
|
|
2325
|
+
"narrowed away), the parent still pops one up briefly when something happens",
|
|
2326
|
+
"to it: a reward cue, a pending grant, a landing, a spend, a daily-maximum",
|
|
2327
|
+
"note, a score change. The game's state never cancels those. Only wallets",
|
|
2328
|
+
"shown BECAUSE OF THIS STATE reserve space: they extend `gem.viewport`'s",
|
|
2329
|
+
"`reserved`, and hiding them shrinks it back to the parent's own Back tab. An",
|
|
2330
|
+
"event pop floats over the game and never moves its layout. Where the parent",
|
|
2331
|
+
"docks its wallets beside the frame rather than over it, this changes",
|
|
2332
|
+
"nothing.",
|
|
2333
|
+
"",
|
|
2334
|
+
"A PRESENTATION REQUEST ONLY. Not an authorization control, and it carries no",
|
|
2335
|
+
"numbers: the parent takes every figure it shows from the platform. A game",
|
|
2336
|
+
"never draws a platform balance itself and never implements a pending state.",
|
|
2337
|
+
"",
|
|
2338
|
+
"Gated by `walletDisplay`: sent only to a parent that advertised it."
|
|
2339
|
+
],
|
|
2340
|
+
"fields": {
|
|
2341
|
+
"visible": { "kind": "boolean" },
|
|
2342
|
+
"currencies": {
|
|
2343
|
+
"kind": "array",
|
|
2344
|
+
"maxItems": 8,
|
|
2345
|
+
"optional": true,
|
|
2346
|
+
"items": { "kind": "string", "maxLength": 64 },
|
|
2347
|
+
"$comment": "Narrow to these platform currency slugs while visible, each 1..64 characters; the SDK drops an empty or over-long entry on the way out rather than cutting it into a different slug."
|
|
2348
|
+
},
|
|
2349
|
+
"score": {
|
|
2350
|
+
"kind": "boolean",
|
|
2351
|
+
"optional": true,
|
|
2352
|
+
"$comment": "With the narrowing: whether the achievement score is among the wallets shown."
|
|
2353
|
+
}
|
|
2354
|
+
},
|
|
2355
|
+
"rateLimit": { "perMinute": 60 }
|
|
2356
|
+
},
|
|
2357
|
+
|
|
2358
|
+
{
|
|
2359
|
+
"type": "gem.requestShare",
|
|
2360
|
+
"direction": "frameToParent",
|
|
2361
|
+
"transport": "port",
|
|
2362
|
+
"$comment": [
|
|
2363
|
+
"Ask the arcade to share a link to this game - to a puzzle, a board, a",
|
|
2364
|
+
"level - opened with the launch parameters in `params`. Gated by the",
|
|
2365
|
+
"`share` capability: sent only to a parent that advertised it.",
|
|
2366
|
+
"",
|
|
2367
|
+
"WHY THE PARENT BUILDS THE LINK. A link the game built itself carries no",
|
|
2368
|
+
"share token, does not unfurl with the game's own card, and the frame is",
|
|
2369
|
+
"denied `clipboard-write`, so it has no copy fallback where the platform",
|
|
2370
|
+
"share sheet is missing. The game says WHAT to open; the parent decides",
|
|
2371
|
+
"whether to share, builds the URL, holds the token and renders every",
|
|
2372
|
+
"surface.",
|
|
2373
|
+
"",
|
|
2374
|
+
"ONE WAY, AND NOTHING ANSWERS IT. No requestId, no text, no URL and no",
|
|
2375
|
+
"game id travel in this message, and no message travels back. The game",
|
|
2376
|
+
"never learns whether the player shared, cancelled or was refused, so it",
|
|
2377
|
+
"has nothing to gate progress or a reward on. An empty `params` array is",
|
|
2378
|
+
"valid: it shares the game with no deep link.",
|
|
2379
|
+
"",
|
|
2380
|
+
"WHAT THE PARENT OWES, none of which this schema can enforce:",
|
|
2381
|
+
"",
|
|
2382
|
+
" REFUSE RESERVED KEYS. A key the parent reads from the play URL itself",
|
|
2383
|
+
" refuses the whole request: the reserved launch keys, the same list the",
|
|
2384
|
+
" SDK refuses before sending, which includes `play`, `channel`, `_s`,",
|
|
2385
|
+
" every `utm_` key, `fbclid`, `ttclid`, `gclid` and `msclkid`.",
|
|
2386
|
+
"",
|
|
2387
|
+
" BUILD FROM THE FRAMED GAME, NEVER FROM THE MESSAGE. The link is",
|
|
2388
|
+
" `/games/<id>?play=1&<params>&_s=<token>`, where `<id>` is the game the",
|
|
2389
|
+
" parent framed. It never carries `channel`: a shared link opens the live",
|
|
2390
|
+
" game, whatever channel the sharer was playing.",
|
|
2391
|
+
"",
|
|
2392
|
+
" ROUND-TRIP THE BUILT URL. Parse the built link's query with the parent's",
|
|
2393
|
+
" own launch-query parse and refuse the whole request if what comes back",
|
|
2394
|
+
" differs from what went in, in any key, value or order. Never trim to",
|
|
2395
|
+
" make it fit. The whole URL is capped at 2 KB.",
|
|
2396
|
+
"",
|
|
2397
|
+
" CHECK ACTIVATION ON RECEIPT. Read `navigator.userActivation.isActive`",
|
|
2398
|
+
" synchronously when the message arrives, before any `await`. With it,",
|
|
2399
|
+
" share or copy in the same tick (a click in the frame activates its",
|
|
2400
|
+
" ancestors).",
|
|
2401
|
+
"",
|
|
2402
|
+
" WITHOUT ACTIVATION, OPEN YOUR OWN SHARE PANEL. The player shares from",
|
|
2403
|
+
" the parent's chrome with a click of their own. Never write the",
|
|
2404
|
+
" clipboard unprompted.",
|
|
2405
|
+
"",
|
|
2406
|
+
" ONE AT A TIME, AND CAPPED. One request in flight at a time, a 10 s",
|
|
2407
|
+
" cooldown after the player dismisses one, and 20 per session. A dropped",
|
|
2408
|
+
" request is logged by the parent, never surfaced to the player or the",
|
|
2409
|
+
" frame.",
|
|
2410
|
+
"",
|
|
2411
|
+
" RETURN FOCUS TO THE FRAME when the sheet or panel closes, so the game's",
|
|
2412
|
+
" keyboard input keeps working.",
|
|
2413
|
+
"",
|
|
2414
|
+
" THE TEXT IS THE PARENT'S. The share text is the parent's fixed,",
|
|
2415
|
+
" localized \"Play {game} on Gem Arcade\". No game-supplied text is",
|
|
2416
|
+
" shared: copy a game authored, sent under the platform's name, is a",
|
|
2417
|
+
" phishing kit with the platform's name on it.",
|
|
2418
|
+
"",
|
|
2419
|
+
" SEND NOTHING BACK. No outcome, no acknowledgement, and no error for a",
|
|
2420
|
+
" refusal - not even for a malformed or reserved request.",
|
|
2421
|
+
"",
|
|
2422
|
+
"Everything in `params` is public: it is written into a link the player",
|
|
2423
|
+
"hands to anyone. A game puts no personal identifier in it."
|
|
2424
|
+
],
|
|
2425
|
+
"fields": {
|
|
2426
|
+
"params": {
|
|
2427
|
+
"kind": "array",
|
|
2428
|
+
"maxItems": 16,
|
|
2429
|
+
"items": {
|
|
2430
|
+
"kind": "object",
|
|
2431
|
+
"fields": {
|
|
2432
|
+
"key": { "kind": "string", "maxLength": 64 },
|
|
2433
|
+
"value": { "kind": "string", "maxLength": 512 }
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
},
|
|
2438
|
+
"rateLimit": {
|
|
2439
|
+
"perMinute": 6,
|
|
2440
|
+
"$comment": "Bounds a BUGGY game. NOT a security control: a hostile game posts on the port it holds rather than calling the SDK. What bounds a player's experience is the parent's one-at-a-time rule, its cooldown and its per-session cap."
|
|
2441
|
+
}
|
|
2442
|
+
},
|
|
2443
|
+
|
|
2213
2444
|
{
|
|
2214
2445
|
"type": "gem.frameStats",
|
|
2215
2446
|
"direction": "frameToParent",
|
|
@@ -2957,6 +3188,208 @@
|
|
|
2957
3188
|
}
|
|
2958
3189
|
},
|
|
2959
3190
|
"rateLimit": { "perMinute": 12 }
|
|
3191
|
+
},
|
|
3192
|
+
{
|
|
3193
|
+
"type": "gem.requestFriendChallenge",
|
|
3194
|
+
"direction": "frameToParent",
|
|
3195
|
+
"transport": "port",
|
|
3196
|
+
"$comment": [
|
|
3197
|
+
"Ask the parent to send one friend challenge to one of the player's",
|
|
3198
|
+
"friends, on the player's behalf. Gated by the `friendChallenges`",
|
|
3199
|
+
"capability. Answered by `gem.friendChallengeResult`.",
|
|
3200
|
+
"",
|
|
3201
|
+
"WHY THIS CROSSES THE BRIDGE AT ALL. A game knows the moment a challenge",
|
|
3202
|
+
"is worth sending - a board just beaten, a score just set - but a send",
|
|
3203
|
+
"puts a card in another person's Arcade inbox, and that is an account-",
|
|
3204
|
+
"session act the game-scoped token is refused by construction. So the game",
|
|
3205
|
+
"ASKS, and the parent, which holds the account session, previews it,",
|
|
3206
|
+
"renders its own confirmation sheet, and sends only on the player's Send.",
|
|
3207
|
+
"This is a platform decision amending DESIGN.md section 10's 'no invites",
|
|
3208
|
+
"in the frame'.",
|
|
3209
|
+
"",
|
|
3210
|
+
"NO GAME ID, NO CHANNEL AND NO TEXT ARE SENT, deliberately. The parent",
|
|
3211
|
+
"takes the game and the channel from what it framed; a game id or a",
|
|
3212
|
+
"channel the frame supplied would be one the frame chose. And the card's",
|
|
3213
|
+
"words are not the game's to write here: the platform renders them",
|
|
3214
|
+
"server-side from the template this game PUBLISHED in its config, filled",
|
|
3215
|
+
"with `params`, and screened there. Game-authored copy, rendered inside",
|
|
3216
|
+
"the platform's own chrome and delivered to someone else's inbox, is a",
|
|
3217
|
+
"spam and phishing channel with the platform's name on it.",
|
|
3218
|
+
"",
|
|
3219
|
+
"WHAT THE PARENT OWES, none of which this schema can enforce:",
|
|
3220
|
+
"",
|
|
3221
|
+
" ONE SHEET AT A TIME. An ask arriving while a challenge sheet - or any",
|
|
3222
|
+
" other sheet or dialog that holds the stage: a purchase, a sign-in",
|
|
3223
|
+
" prompt, a leave question - is open is answered `cancelled` with",
|
|
3224
|
+
" nothing rendered. The busy set is two-way: an open challenge sheet",
|
|
3225
|
+
" holds the stage against those asks too. Twelve sheets a minute is",
|
|
3226
|
+
" inside the rate limit below and is a consent-fatigue attack.",
|
|
3227
|
+
"",
|
|
3228
|
+
" A QUIET INPUT WINDOW AFTER PAINT. The frame chooses the moment it asks,",
|
|
3229
|
+
" so a Send control that is actionable the instant it paints is a",
|
|
3230
|
+
" tap-jacking target. Quiet input on it briefly after paint, as for",
|
|
3231
|
+
" gem.requestPurchase.",
|
|
3232
|
+
"",
|
|
3233
|
+
" RENDER ONLY FROM THE PREVIEW RESPONSE. The recipient's name, the game's",
|
|
3234
|
+
" title and the challenge line on the sheet come from the platform's",
|
|
3235
|
+
" preview of this ask, never from anything in this message. That is what",
|
|
3236
|
+
" makes the sheet something a game cannot forge.",
|
|
3237
|
+
"",
|
|
3238
|
+
" TREAT EVERY FIELD AS UNTRUSTED TEXT. The generated validator checks",
|
|
3239
|
+
" type and length and nothing else - these strings may carry `../`,",
|
|
3240
|
+
" `%2e%2e`, NUL, newlines, RTL overrides and astral Unicode. Pre-check",
|
|
3241
|
+
" them (the grammar is on each field below), pass them to the platform",
|
|
3242
|
+
" only as body fields, never interpolate one into a path, a query or a",
|
|
3243
|
+
" log line, and never echo one to a player.",
|
|
3244
|
+
"",
|
|
3245
|
+
"NOT AN AUTHORIZATION CONTROL. The player's Send press in the parent's",
|
|
3246
|
+
"chrome is the control, and the platform's account-session send is the",
|
|
3247
|
+
"enforcement: it re-checks the friendship, the template, the params and",
|
|
3248
|
+
"every budget, whatever reached it."
|
|
3249
|
+
],
|
|
3250
|
+
"fields": {
|
|
3251
|
+
"requestId": {
|
|
3252
|
+
"kind": "string",
|
|
3253
|
+
"maxLength": 64,
|
|
3254
|
+
"$comment": "Opaque and frame-chosen. The parent treats it as an echo-only correlator and derives its own request identity, exactly as it does for gem.requestPurchase - it is not unique across tabs or reloads and must never key anything. It may also contain newlines and control characters, so it is not safe to write into a log line unescaped."
|
|
3255
|
+
},
|
|
3256
|
+
"playerId": {
|
|
3257
|
+
"kind": "string",
|
|
3258
|
+
"maxLength": 64,
|
|
3259
|
+
"$comment": "The friend's player id, as `gem.friends.list()` returned it. BOTH the SDK and the parent require a UUID and answer anything else `invalid` with code `recipient_invalid` and no `param` - not `param_invalid` with `param: 'playerId'`, which a template may legitimately declare as a param of its own. A well-formed id is still frame-chosen: whether it names a friend is the platform's to judge, on the preview and again on the send, and a non-friend is answered `refused`."
|
|
3260
|
+
},
|
|
3261
|
+
"template": {
|
|
3262
|
+
"kind": "string",
|
|
3263
|
+
"maxLength": 64,
|
|
3264
|
+
"$comment": "A template key from this game's published `friend_challenge_templates` config. The grammar is `^[a-z0-9][a-z0-9_-]{0,63}$`; codegen emits type and length only, so the SDK and the parent each pre-check the charset and answer a miss `invalid` with code `unknown_template`. Whether the key exists on the framed channel is the platform's to judge."
|
|
3265
|
+
},
|
|
3266
|
+
"params": {
|
|
3267
|
+
"kind": "json",
|
|
3268
|
+
"optional": true,
|
|
3269
|
+
"$comment": [
|
|
3270
|
+
"The template's params: a plain object of at most 8 keys, each value",
|
|
3271
|
+
"a string or a safe integer. ABSENT when the game passed none, and the",
|
|
3272
|
+
"parent reads absent as `{}`. Bounded by the message-level walk",
|
|
3273
|
+
"(`maxMessageNodes` and the rest) rather than by a field list - the",
|
|
3274
|
+
"`gem.gameContext.context` treatment - because the keys are declared",
|
|
3275
|
+
"by the game's own config, not by this schema.",
|
|
3276
|
+
"",
|
|
3277
|
+
"The parent pre-checks the shape and the key count and answers a",
|
|
3278
|
+
"miss `invalid` (`param_invalid`, or `param_undeclared` over 8 keys);",
|
|
3279
|
+
"the platform is authoritative on every value. NEVER A PLAYER NAME:",
|
|
3280
|
+
"the platform shows who sent the challenge itself, from its own",
|
|
3281
|
+
"records."
|
|
3282
|
+
]
|
|
3283
|
+
}
|
|
3284
|
+
},
|
|
3285
|
+
"rateLimit": {
|
|
3286
|
+
"perMinute": 12,
|
|
3287
|
+
"$comment": "Bounds a BUGGY game, and sits deliberately ABOVE the platform's own 10 sends a minute per sender, so the platform's `rate_limited` - with its Retry-After - is what a game actually meets, rather than an ask silently discarded here. NOT a security control: a hostile game posts on the port it holds rather than calling the SDK. What stops a game nagging is the parent's one-sheet-at-a-time rule and the player's own Send press."
|
|
3288
|
+
}
|
|
3289
|
+
},
|
|
3290
|
+
{
|
|
3291
|
+
"type": "gem.friendChallengeResult",
|
|
3292
|
+
"direction": "parentToFrame",
|
|
3293
|
+
"transport": "port",
|
|
3294
|
+
"$comment": [
|
|
3295
|
+
"The answer to one gem.requestFriendChallenge. Gated by the",
|
|
3296
|
+
"`friendChallenges` capability.",
|
|
3297
|
+
"",
|
|
3298
|
+
"HOW A GAME SHOULD READ EACH ONE:",
|
|
3299
|
+
" accepted - the player pressed Send and the platform accepted it.",
|
|
3300
|
+
" NOT A DELIVERY RECEIPT: a delivery, a duplicate and",
|
|
3301
|
+
" every drop - moderation, age, a mute, the recipient's",
|
|
3302
|
+
" budget - answer identically, by design, so a game",
|
|
3303
|
+
" cannot learn a stranger's settings from it. Do not",
|
|
3304
|
+
" resend.",
|
|
3305
|
+
" cancelled - the player closed the sheet, or something else held",
|
|
3306
|
+
" the stage and nothing was rendered. Return to the game",
|
|
3307
|
+
" SILENTLY; an error after a deliberate Cancel reads as a",
|
|
3308
|
+
" bug.",
|
|
3309
|
+
" refused - this player cannot send this one: a guest or signed",
|
|
3310
|
+
" out, not friends with that player, or no access to",
|
|
3311
|
+
" this channel. No reason rides on it, deliberately -",
|
|
3312
|
+
" each is an account-state fact, and the parent owns",
|
|
3313
|
+
" every account-state explanation. Age and moderation",
|
|
3314
|
+
" NEVER answer refused: a denied or restricted sender",
|
|
3315
|
+
" is answered accepted (a silent drop) or cancelled",
|
|
3316
|
+
" (the sheet with Send withheld).",
|
|
3317
|
+
" unavailable - no answer arrived, or the platform could not take a",
|
|
3318
|
+
" challenge just now (switched off for this game, no",
|
|
3319
|
+
" build on this channel, an outage). It does NOT prove",
|
|
3320
|
+
" nothing was sent: the parent may have sent and the",
|
|
3321
|
+
" answer been lost at a gate or a timeout. A game may",
|
|
3322
|
+
" fall back to its own share sheet.",
|
|
3323
|
+
" rate_limited - the player's sending budget is spent. `retryAfterMs`",
|
|
3324
|
+
" says for how long, and it can be a whole day.",
|
|
3325
|
+
" invalid - a programming error for the developer, not the",
|
|
3326
|
+
" player: `code` says which, and `param`, `channel`,",
|
|
3327
|
+
" `configVersion` and `detail` narrow it.",
|
|
3328
|
+
"",
|
|
3329
|
+
"NEVER AUTO-RETRIED; ONLY A FRESH PLAYER ACTION MAY ASK AGAIN. Each ask",
|
|
3330
|
+
"puts a sheet in front of a person, and a daily-budget wait can be 24",
|
|
3331
|
+
"hours - a game that retried on `rate_limited` or `unavailable` would be",
|
|
3332
|
+
"nagging, not recovering.",
|
|
3333
|
+
"",
|
|
3334
|
+
"EVERY FIELD EXCEPT `requestId` AND `outcome` IS OPTIONAL, AND A RECEIVER",
|
|
3335
|
+
"MUST TOLERATE ANY COMBINATION OF THEM. Each one's comment says when the",
|
|
3336
|
+
"Arcade sends it; that is a description of one sender, not a constraint",
|
|
3337
|
+
"this schema makes. A bare {requestId, outcome} is always valid.",
|
|
3338
|
+
"",
|
|
3339
|
+
"`accepted`, `cancelled`, `refused` and `unavailable` CARRY NO REASON,",
|
|
3340
|
+
"and a parent must not attach one: no `code`, no `detail`."
|
|
3341
|
+
],
|
|
3342
|
+
"fields": {
|
|
3343
|
+
"requestId": {
|
|
3344
|
+
"kind": "string",
|
|
3345
|
+
"maxLength": 64,
|
|
3346
|
+
"$comment": "Echoes the gem.requestFriendChallenge that asked. Echo-only."
|
|
3347
|
+
},
|
|
3348
|
+
"outcome": {
|
|
3349
|
+
"kind": "enum",
|
|
3350
|
+
"values": ["accepted", "cancelled", "refused", "unavailable", "rate_limited", "invalid"],
|
|
3351
|
+
"$comment": "SIX VALUES, CLOSED PERMANENTLY. `check-schema-freeze` treats a new enum value as breaking: an N-1 frame's generated validator refuses the whole message and the ask times out. So the vocabulary that may grow - why a request was invalid - lives in `code`, a string, and not here."
|
|
3352
|
+
},
|
|
3353
|
+
"code": {
|
|
3354
|
+
"kind": "string",
|
|
3355
|
+
"maxLength": 64,
|
|
3356
|
+
"optional": true,
|
|
3357
|
+
"$comment": "Sent with `outcome: 'invalid'`. One of `unknown_template`, `template_not_on_channel`, `param_invalid`, `param_undeclared`, `recipient_invalid` today - the BRIDGE vocabulary: the platform's own code with its `friend_challenge_` prefix stripped, plus `recipient_invalid`, which only the parent and the SDK raise. A STRING, NOT AN ENUM, deliberately, so the platform can add a detail code without a bridge break. The SDK passes it through verbatim; a receiver must not fail on a value it does not know."
|
|
3358
|
+
},
|
|
3359
|
+
"param": {
|
|
3360
|
+
"kind": "string",
|
|
3361
|
+
"maxLength": 64,
|
|
3362
|
+
"optional": true,
|
|
3363
|
+
"$comment": "Sent when `code` is `param_invalid` or `param_undeclared` and a single param key is known to be at fault. A key from the game's own config, echoed."
|
|
3364
|
+
},
|
|
3365
|
+
"channel": {
|
|
3366
|
+
"kind": "string",
|
|
3367
|
+
"maxLength": 16,
|
|
3368
|
+
"optional": true,
|
|
3369
|
+
"$comment": "Sent when `code` is `template_not_on_channel`: the channel the send named, whose promoted config lacks the template. A STRING, on `code`'s argument, so a new channel is not a bridge break. Reaches only a TEAM MEMBER of the game - everyone else is answered `unknown_template` - because only a team member can act on it, and non-release config is not everyone's to learn about."
|
|
3370
|
+
},
|
|
3371
|
+
"configVersion": {
|
|
3372
|
+
"kind": "int",
|
|
3373
|
+
"min": 0,
|
|
3374
|
+
"max": 9007199254740991,
|
|
3375
|
+
"optional": true,
|
|
3376
|
+
"$comment": "Sent when `code` is `template_not_on_channel` and a config version is promoted to that channel: the version the template is missing from. Team members only, as `channel`."
|
|
3377
|
+
},
|
|
3378
|
+
"retryAfterMs": {
|
|
3379
|
+
"kind": "int",
|
|
3380
|
+
"min": 0,
|
|
3381
|
+
"max": 86400000,
|
|
3382
|
+
"optional": true,
|
|
3383
|
+
"$comment": "Sent with `outcome: 'rate_limited'`: the platform's Retry-After, in milliseconds - the bridge's unit, as `gem.token.denied.retryAfterMs` and `gem.willTerminate.graceMs` are. At most a day, because the daily budget is the longest window. The SDK converts it to whole seconds for `GemApiError.retryAfterSeconds`. A floor for the next PLAYER-initiated ask, never a timer to retry on."
|
|
3384
|
+
},
|
|
3385
|
+
"detail": {
|
|
3386
|
+
"kind": "string",
|
|
3387
|
+
"maxLength": 300,
|
|
3388
|
+
"optional": true,
|
|
3389
|
+
"$comment": "Sent ONLY with `outcome: 'invalid'`, and never with any other outcome: the platform's own message for the refusal, truncated, for the developer's console. With `template_not_on_channel` it carries the command that fixes it. Never shown to a player."
|
|
3390
|
+
}
|
|
3391
|
+
},
|
|
3392
|
+
"rateLimit": { "perMinute": 30 }
|
|
2960
3393
|
}
|
|
2961
3394
|
],
|
|
2962
3395
|
|