@indiegems/gem-web-sdk 9.1.4 → 10.0.0

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.
@@ -2,7 +2,7 @@
2
2
  "schema_version": 2,
3
3
  "source": {
4
4
  "repo": "IndieGems/gem-web-sdk",
5
- "version": "9.1.4"
5
+ "version": "10.0.0"
6
6
  },
7
7
  "entries": [
8
8
  {
@@ -16350,6 +16350,29 @@
16350
16350
  "defaultValue": null,
16351
16351
  "guides": []
16352
16352
  },
16353
+ {
16354
+ "id": "RoomManager.queueForMatch",
16355
+ "name": "queueForMatch",
16356
+ "anchor": "roommanager-queueformatch",
16357
+ "group": "rooms",
16358
+ "signature": "queueForMatch(options: QueueForMatchOptions): Promise<MatchTicket>",
16359
+ "documented": true,
16360
+ "summary": "Queue for a match with other players, and get a ticket to wait on.\n\nFOR A MODE THAT NEEDS A FULL LOBBY BEFORE IT STARTS. The platform holds the\nplayer in a queue until it has enough players for one of the templates you\nnamed, then makes the room, seats everyone and starts the match — nobody\nhosts and nobody presses start. For a drop-in mode, where a player should\nbe in a room at once and others join around them, use\n[RoomManager.quickJoin](api-reference-game.md#roommanager-quickjoin) instead.\n\nThis call answers as soon as the player is queued. What happens next is on\nthe ticket: await [MatchTicket.outcome](api-reference-game.md#matchticket-outcome), and when it says `matched`\nthis manager is already in the room — `joined` has fired and the match\nevents follow as for any other room.\n\nA PLAYER MAY QUEUE FROM INSIDE A ROOM — a hub, or a social lobby they wait\nin. When the ticket is matched the manager moves to the match's room:\n`currentRoom` becomes it and `joined` fires. The seat in the room they\nqueued from is kept on the platform, not given up, so when the match is\nover `join()` with that room's id puts them back in it. If the tab closes\nbefore they go back, the SDK leaves both rooms.\n\nWhat it does refuse is a second thing in flight: while a ticket waits,\n`create`, `join`, `joinByCode`, `quickJoin` and another `queueForMatch`\nrefuse until it ends or is cancelled.\n\nCLOSING THE TAB CANCELS THE TICKET. The SDK sends the cancel as the page\ngoes away, so a player who left is not matched into a lobby they will\nnever arrive in.",
16361
+ "deprecated": null,
16362
+ "params": [
16363
+ {
16364
+ "name": "options",
16365
+ "type": "QueueForMatchOptions",
16366
+ "optional": false,
16367
+ "summary": "The templates the player accepts."
16368
+ }
16369
+ ],
16370
+ "returns": "The ticket, still `queued` unless a lobby was waiting for exactly\nthis player.",
16371
+ "throws": "`GemApiError` when the platform refuses the ticket. Two refusals\nare worth their own branch, by [GemApiError.code](api-reference-game.md#gemapierror-code):\n`already_queued` — this player, or someone in their party, is already\non a ticket, possibly in another tab or another game — and\n`matchmaking_unavailable`, which means matchmaking is turned off or not\nanswering. Tell the player; do not loop on it, and do not quietly\nsubstitute `quickJoin`, which puts them in a different kind of room.",
16372
+ "example": null,
16373
+ "defaultValue": null,
16374
+ "guides": []
16375
+ },
16353
16376
  {
16354
16377
  "id": "RoomManager.list",
16355
16378
  "name": "list",
@@ -16790,6 +16813,22 @@
16790
16813
  "defaultValue": null,
16791
16814
  "guides": []
16792
16815
  },
16816
+ {
16817
+ "id": "RoomManager.currentTicket",
16818
+ "name": "currentTicket",
16819
+ "anchor": "roommanager-currentticket",
16820
+ "group": "rooms",
16821
+ "signature": "readonly currentTicket: MatchTicket | null",
16822
+ "documented": true,
16823
+ "summary": "The matchmaking ticket this player is waiting on, or `null`.\n\nSet from the moment [RoomManager.queueForMatch](api-reference-game.md#roommanager-queueformatch) answers until the\nticket ends — so it is `null` again by the time a matched ticket's room is\n[RoomManager.currentRoom](api-reference-game.md#roommanager-currentroom).",
16824
+ "deprecated": null,
16825
+ "params": [],
16826
+ "returns": null,
16827
+ "throws": null,
16828
+ "example": null,
16829
+ "defaultValue": null,
16830
+ "guides": []
16831
+ },
16793
16832
  {
16794
16833
  "id": "RoomManager.currentMatch",
16795
16834
  "name": "currentMatch",
@@ -17083,6 +17122,240 @@
17083
17122
  "defaultValue": null,
17084
17123
  "guides": []
17085
17124
  },
17125
+ {
17126
+ "id": "QueueForMatchOptions",
17127
+ "name": "QueueForMatchOptions",
17128
+ "anchor": "queueformatchoptions",
17129
+ "group": "rooms",
17130
+ "signature": "interface QueueForMatchOptions",
17131
+ "documented": true,
17132
+ "summary": "What you pass to `gem.rooms.queueForMatch(...)`.\n\nNAME EVERY MODE THE PLAYER WILL ACCEPT. The ticket waits for all of them at\nonce and is matched under whichever fills first, so \"any mode\" is one ticket\nnaming several templates rather than several tickets — a player can only be\non one.\n\n```js\nconst ticket = await gem.rooms.queueForMatch({ templateSlugs: ['duel', 'brawl'] });\n```",
17133
+ "deprecated": null,
17134
+ "params": [],
17135
+ "returns": null,
17136
+ "throws": null,
17137
+ "example": null,
17138
+ "defaultValue": null,
17139
+ "guides": [
17140
+ {
17141
+ "slug": "rooms-and-matches",
17142
+ "anchor": null
17143
+ }
17144
+ ]
17145
+ },
17146
+ {
17147
+ "id": "QueueForMatchOptions.templateSlugs",
17148
+ "name": "templateSlugs",
17149
+ "anchor": "queueformatchoptions-templateslugs",
17150
+ "group": "rooms",
17151
+ "signature": "readonly templateSlugs: readonly string[]",
17152
+ "documented": true,
17153
+ "summary": "The room templates this player accepts, by slug — one to eight of them.\n\nEVERY ONE MUST HAVE MATCHMAKING TURNED ON in your game config. A template\nwithout it refuses the whole ticket rather than being skipped, so a typo\nis an error you see and not a mode nobody is ever matched into.",
17154
+ "deprecated": null,
17155
+ "params": [],
17156
+ "returns": null,
17157
+ "throws": null,
17158
+ "example": null,
17159
+ "defaultValue": null,
17160
+ "guides": []
17161
+ },
17162
+ {
17163
+ "id": "QueueForMatchOptions.partyId",
17164
+ "name": "partyId",
17165
+ "anchor": "queueformatchoptions-partyid",
17166
+ "group": "rooms",
17167
+ "signature": "readonly partyId?: string",
17168
+ "documented": true,
17169
+ "summary": "A party to queue as one unit. Normally omit it — the SDK brings the\nplayer's party along when they lead one, exactly as a room call does.\n\nA party is seated together or not at all. If it gains or loses a member\nwhile it waits, the ticket ends `cancelled` with reason `party_changed`.",
17170
+ "deprecated": null,
17171
+ "params": [],
17172
+ "returns": null,
17173
+ "throws": null,
17174
+ "example": null,
17175
+ "defaultValue": null,
17176
+ "guides": []
17177
+ },
17178
+ {
17179
+ "id": "QueueForMatchOptions.signal",
17180
+ "name": "signal",
17181
+ "anchor": "queueformatchoptions-signal",
17182
+ "group": "rooms",
17183
+ "signature": "readonly signal?: AbortSignal",
17184
+ "documented": true,
17185
+ "summary": "Cancel the request that creates the ticket.\n\nOnly that request. Once you hold a ticket, [MatchTicket.cancel](api-reference-game.md#matchticket-cancel) is\nwhat takes the player out of the queue.",
17186
+ "deprecated": null,
17187
+ "params": [],
17188
+ "returns": null,
17189
+ "throws": null,
17190
+ "example": null,
17191
+ "defaultValue": null,
17192
+ "guides": []
17193
+ },
17194
+ {
17195
+ "id": "MatchTicketStatus",
17196
+ "name": "MatchTicketStatus",
17197
+ "anchor": "matchticketstatus",
17198
+ "group": "rooms",
17199
+ "signature": "type MatchTicketStatus = \"queued\" | \"matched\" | \"expired\" | \"cancelled\" | \"failed\" | \"lost\" | \"unavailable\"",
17200
+ "documented": true,
17201
+ "summary": "Where a ticket is: `queued` while it waits, and anything else for good.\n\n- `matched` — the player is seated and the match is starting.\n- `expired` — it waited too long, or stopped being kept alive.\n- `cancelled` — the player backed out, or their party changed.\n- `failed` — the platform formed a match and could not start it, repeatedly.\n- `lost` — the platform no longer has the ticket. Queue again.\n- `unavailable` — matchmaking is turned off or not answering.",
17202
+ "deprecated": null,
17203
+ "params": [],
17204
+ "returns": null,
17205
+ "throws": null,
17206
+ "example": null,
17207
+ "defaultValue": null,
17208
+ "guides": []
17209
+ },
17210
+ {
17211
+ "id": "MatchTicketOutcome",
17212
+ "name": "MatchTicketOutcome",
17213
+ "anchor": "matchticketoutcome",
17214
+ "group": "rooms",
17215
+ "signature": "type MatchTicketOutcome = { status: \"matched\"; room: Room } | { status: \"expired\"; reason: string } | { status: \"cancelled\"; reason: string } | { status: \"failed\"; reason: string } | { status: \"lost\" } | { status: \"unavailable\" }",
17216
+ "documented": true,
17217
+ "summary": "How a ticket ended. Branch on `status`.\n\nONLY `matched` CARRIES A ROOM, and by the time you see it the SDK is already\nin that room: `gem.rooms.currentRoom` is it, `joined` has fired, and the\nmatch events follow as they do for any other room.\n\nEvery other ending leaves the player in no room and on no ticket, free to\nqueue again. None of them is retried for you, and nothing falls back to\n`quickJoin` — a game that wants either does it on purpose.\n\n`reason` says why, where the platform gave one: `timed_out` or\n`heartbeat_lapsed` for `expired`, `requested` or `party_changed` for\n`cancelled`, `handoff_failed` for `failed`. Treat a value you do not\nrecognise as the status alone.",
17218
+ "deprecated": null,
17219
+ "params": [],
17220
+ "returns": null,
17221
+ "throws": null,
17222
+ "example": null,
17223
+ "defaultValue": null,
17224
+ "guides": []
17225
+ },
17226
+ {
17227
+ "id": "MatchTicket",
17228
+ "name": "MatchTicket",
17229
+ "anchor": "matchticket",
17230
+ "group": "rooms",
17231
+ "signature": "interface MatchTicket",
17232
+ "documented": true,
17233
+ "summary": "A place in the matchmaking queue — what `gem.rooms.queueForMatch(...)`\nanswers with.\n\nYou do not keep it alive; the SDK does, for as long as it waits. What you do\nwith it is wait for [MatchTicket.outcome](api-reference-game.md#matchticket-outcome), and offer the player a\nbutton that calls [MatchTicket.cancel](api-reference-game.md#matchticket-cancel).\n\n```js\nconst ticket = await gem.rooms.queueForMatch({ templateSlugs: ['duel'] });\nshowSearching({ onCancel: () => ticket.cancel() });\n\nconst outcome = await ticket.outcome;\nif (outcome.status === 'matched') enterMatch(outcome.room);\nelse backToMenu(outcome.status);\n```",
17234
+ "deprecated": null,
17235
+ "params": [],
17236
+ "returns": null,
17237
+ "throws": null,
17238
+ "example": null,
17239
+ "defaultValue": null,
17240
+ "guides": [
17241
+ {
17242
+ "slug": "rooms-and-matches",
17243
+ "anchor": null
17244
+ }
17245
+ ]
17246
+ },
17247
+ {
17248
+ "id": "MatchTicket.id",
17249
+ "name": "id",
17250
+ "anchor": "matchticket-id",
17251
+ "group": "rooms",
17252
+ "signature": "readonly id: string",
17253
+ "documented": true,
17254
+ "summary": "The ticket's id. For logs and bug reports; nothing you call takes it.",
17255
+ "deprecated": null,
17256
+ "params": [],
17257
+ "returns": null,
17258
+ "throws": null,
17259
+ "example": null,
17260
+ "defaultValue": null,
17261
+ "guides": []
17262
+ },
17263
+ {
17264
+ "id": "MatchTicket.status",
17265
+ "name": "status",
17266
+ "anchor": "matchticket-status",
17267
+ "group": "rooms",
17268
+ "signature": "readonly status: MatchTicketStatus",
17269
+ "documented": true,
17270
+ "summary": "Where the ticket is right now. Changes once, from `queued` to how it ended.",
17271
+ "deprecated": null,
17272
+ "params": [],
17273
+ "returns": null,
17274
+ "throws": null,
17275
+ "example": null,
17276
+ "defaultValue": null,
17277
+ "guides": []
17278
+ },
17279
+ {
17280
+ "id": "MatchTicket.partySize",
17281
+ "name": "partySize",
17282
+ "anchor": "matchticket-partysize",
17283
+ "group": "rooms",
17284
+ "signature": "readonly partySize: number",
17285
+ "documented": true,
17286
+ "summary": "How many players the ticket seats: 1, or the size of the party.",
17287
+ "deprecated": null,
17288
+ "params": [],
17289
+ "returns": null,
17290
+ "throws": null,
17291
+ "example": null,
17292
+ "defaultValue": null,
17293
+ "guides": []
17294
+ },
17295
+ {
17296
+ "id": "MatchTicket.queuedAt",
17297
+ "name": "queuedAt",
17298
+ "anchor": "matchticket-queuedat",
17299
+ "group": "rooms",
17300
+ "signature": "readonly queuedAt: number",
17301
+ "documented": true,
17302
+ "summary": "When the ticket joined the queue, in Unix seconds.",
17303
+ "deprecated": null,
17304
+ "params": [],
17305
+ "returns": null,
17306
+ "throws": null,
17307
+ "example": null,
17308
+ "defaultValue": null,
17309
+ "guides": []
17310
+ },
17311
+ {
17312
+ "id": "MatchTicket.expiresAt",
17313
+ "name": "expiresAt",
17314
+ "anchor": "matchticket-expiresat",
17315
+ "group": "rooms",
17316
+ "signature": "readonly expiresAt: number",
17317
+ "documented": true,
17318
+ "summary": "When the ticket gives up if it is still waiting, in Unix seconds. Good for\na countdown; the platform decides the length, per template.",
17319
+ "deprecated": null,
17320
+ "params": [],
17321
+ "returns": null,
17322
+ "throws": null,
17323
+ "example": null,
17324
+ "defaultValue": null,
17325
+ "guides": []
17326
+ },
17327
+ {
17328
+ "id": "MatchTicket.outcome",
17329
+ "name": "outcome",
17330
+ "anchor": "matchticket-outcome",
17331
+ "group": "rooms",
17332
+ "signature": "readonly outcome: Promise<MatchTicketOutcome>",
17333
+ "documented": true,
17334
+ "summary": "How the ticket ends. Settles exactly once, and RESOLVES for every ending\nthe platform reports — matched, expired, cancelled and the rest are\noutcomes to branch on, not errors to catch.\n\nIt rejects in one case only: the ticket was matched and the SDK could not\nread the room it was seated in. The player holds a seat then, so\n`gem.rooms.myRooms()` shows the room and `gem.rooms.join(id)` enters it.",
17335
+ "deprecated": null,
17336
+ "params": [],
17337
+ "returns": null,
17338
+ "throws": null,
17339
+ "example": null,
17340
+ "defaultValue": null,
17341
+ "guides": []
17342
+ },
17343
+ {
17344
+ "id": "MatchTicket.cancel",
17345
+ "name": "cancel",
17346
+ "anchor": "matchticket-cancel",
17347
+ "group": "rooms",
17348
+ "signature": "cancel(): Promise<MatchTicketOutcome>",
17349
+ "documented": true,
17350
+ "summary": "Take the player — and their party — out of the queue.\n\nA CANCEL CAN LOSE THE RACE, and then the answer is `matched`. A ticket the\nplatform has already seated is never undone, so read the outcome this\nreturns rather than assuming: when it says `matched` the player is in a\nroom, and backing out is `gem.rooms.leave()`.\n\nSafe to call more than once, and after the ticket has ended.",
17351
+ "deprecated": null,
17352
+ "params": [],
17353
+ "returns": "The same promise as [MatchTicket.outcome](api-reference-game.md#matchticket-outcome).",
17354
+ "throws": null,
17355
+ "example": null,
17356
+ "defaultValue": null,
17357
+ "guides": []
17358
+ },
17086
17359
  {
17087
17360
  "id": "RoomStatus",
17088
17361
  "name": "RoomStatus",
package/docs/changelog.md CHANGED
@@ -16,6 +16,19 @@ Its entries are grouped under up to five headings:
16
16
  - **Rebuild**: a type changed. Recompile against the new version; code that compiles today keeps compiling unless the entry says otherwise.
17
17
  - **New capability**: something a game can now do. Nothing changes unless you use it.
18
18
 
19
+ ## 10.0.0
20
+
21
+ Upgrading from 9.1.4. Impact: required.
22
+
23
+ ### Rebuild
24
+
25
+ - The OrderSource type now accepts two more values, test_account_provision and developer_grant. Recompile your game. If your code handles order sources in an exhaustive switch or a lookup table typed by OrderSource, add handling for both new values, or the build may fail to compile.
26
+
27
+ ### New capability
28
+
29
+ - A game can now put a player in a matchmaking queue for a mode that needs a formed lobby. Call RoomManager.queueForMatch with the template slugs you want. It returns a MatchTicket whose outcome settles once and whose cancel method leaves the queue. When the platform finds a match it creates the room, seats everyone and starts the match, and you follow it with the same code you use for any joined room. Options are described by QueueForMatchOptions. Ticket endings are described by MatchTicketOutcome, and the waiting state by MatchTicketStatus. RoomManager.currentTicket exposes the ticket the player is waiting on. The SDK keeps the ticket alive by itself, cancels it when the tab closes, and lets a player queue from inside a room. After a match the player leaves the room they queued from when the tab closes.
30
+ - The lower-level rooms API now has createTicket, heartbeatTicket and cancelTicket calls for working with matchmaking tickets directly. Most games should use RoomManager.queueForMatch, which sends the heartbeats and cancellation for you.
31
+
19
32
  ## 9.1.4
20
33
 
21
34
  Upgrading from 9.1.3. Impact: none.
@@ -78,9 +78,118 @@ Changing only these two is not a change of schedule, so the upload does not refu
78
78
  `completion_threshold` is the target. `client_writable` decides who may report it, and
79
79
  [section 4](#4-who-may-report-it) is where that choice actually matters.
80
80
 
81
+ `display_name` and `description` are what a player reads. To translate them, or to have the
82
+ platform write the target into them, see
83
+ [section 2a](#2a-translate-the-copy-and-write-the-target-into-it).
84
+
81
85
  **Upload the config the way you upload any other.** A challenge is not promoted differently and
82
86
  does not need a new build to reach players.
83
87
 
88
+ ## 2a. Translate the copy, and write the target into it
89
+
90
+ Both of these are optional, and a config that uses neither keeps working as it is. A progression
91
+ may carry your own translations of its name and description, and a displayed challenge may have the
92
+ platform write its target into the text:
93
+
94
+ ```yaml
95
+ progressions:
96
+ - id: 9b1d6e40-52c7-4f0a-8e3d-1a7c4b9f2d65
97
+ slug: clear-lines
98
+ display_name: 'Clear {target, plural, one {# line} other {# lines}}'
99
+ description: 'Clear lines in a single run.'
100
+ localized:
101
+ de:
102
+ display_name: 'Räume {target, plural, one {# Reihe} other {# Reihen}} ab'
103
+ description: 'Räume Reihen in einem Durchgang ab.'
104
+ pl:
105
+ description: 'Usuń linie w jednej rozgrywce.'
106
+ config:
107
+ completion_threshold: 150
108
+ reset_schedule: { type: daily }
109
+ platform_display: challenge
110
+ ```
111
+
112
+ Quote the text. YAML reads an unquoted value that starts with `{` as a mapping, so
113
+ `display_name: {target} points` is not a string.
114
+
115
+ ### Translations
116
+
117
+ Any progression may carry `localized`, whether or not it is a displayed challenge.
118
+
119
+ - **The keys are the eleven locales:** `de`, `es`, `fr`, `it`, `ja`, `ko`, `pl`, `pt-BR`, `ru`,
120
+ `tr`, `zh-Hans`. `en` is refused as a key, and so is any other key.
121
+ - **Each entry has two optional fields**, `display_name` and `description`. You may translate one
122
+ and not the other.
123
+ - **The `display_name` and `description` you write outside `localized` are the default text**, and
124
+ the platform treats them as English. A missing locale, or a missing field in a locale, falls back
125
+ to the default text, one field at a time. In the example, a Polish reader gets the default name
126
+ and the Polish description, and a French reader gets the default text for both.
127
+ - **An empty translation is refused.** To show the default text, remove the key.
128
+ - **Translations are optional**, and promoting a config does not depend on them.
129
+
130
+ ### Write the target with {target}
131
+
132
+ On a displayed challenge (`platform_display: challenge`), `{target}` in `display_name` or
133
+ `description` is replaced by the challenge's `completion_threshold`. It works in the default text
134
+ and in every translation. Copy that already contains the literal text `{target}` is now shown with
135
+ the number in its place.
136
+
137
+ | You write | Target | A player reads |
138
+ | ------------------------------------------------------ | ------ | --------------------- |
139
+ | `Score {target} points` | 12000 | Score 12,000 points |
140
+ | `Erziele {target} Punkte`, under `de` | 12000 | Erziele 12.000 Punkte |
141
+ | `Clear {target, plural, one {# line} other {# lines}}` | 1 | Clear 1 line |
142
+ | the same | 150 | Clear 150 lines |
143
+
144
+ - **`target` is the only name.** Any other `{name}` is refused, and so is `{target}` on a
145
+ progression with no `completion_threshold`.
146
+ - **The number is grouped the way the reader's language groups it:** `12,000` in English, `12.000`
147
+ in German. Each text uses one language for its words, its plural rule and its number.
148
+ - **A plural needs an `other` branch.** `Clear {target, plural, one {# line} other {# lines}}` is
149
+ "Clear 1 line" at 1 and "Clear 150 lines" at 150.
150
+ - **Write category branches (`one`, `few`, `many`, `other`), not `=N`.** An exact branch is
151
+ accepted only when `N` equals the current threshold, so if you later change the threshold, copy
152
+ that used `=N` can fail its next upload. Each text uses the categories its own language has: a
153
+ category that no number takes in that language (`few` in English) is refused, and so is a branch
154
+ written twice.
155
+ - **Each player reads the target they are working toward.** A player who started a period before
156
+ you changed the threshold keeps reading their own number, in the copy and on the meter alike.
157
+ Section 8 has what a change of threshold does to a period in progress.
158
+
159
+ ### The rules for a displayed challenge's copy
160
+
161
+ - **On a displayed challenge the name and description are templates, and `{` and `}` are syntax.**
162
+ On any other progression they are plain text, and braces mean nothing.
163
+ - **The name is at most 64 characters and the description at most 280, counted
164
+ on the line a player is shown**, in every language, never on the template. Nothing is truncated;
165
+ text that renders too long is refused. A long plural is therefore fine when each line it renders
166
+ is short. The text as written may be up to 256 characters for a name and 4096 for a description.
167
+ - **A displayed challenge's name and description are each one line.**
168
+ - **The copy is screened at upload, in every language.** It is refused for words that claim to
169
+ speak for the platform or name a platform role, for the name of a platform currency, for reward
170
+ or credential words, and for links.
171
+ - **Some problems are refused today, and some only draw a warning.** A problem in a `localized`
172
+ entry, or in text that uses `{target}`, is refused. A problem in a displayed challenge's default
173
+ text that does not use `{target}` (a stray brace, a line break, a screened word) draws the
174
+ warning `progressions_copy_refusal_pending` with the same message, and the upload succeeds. That
175
+ warning will become a refusal, so fix the text when you see it. It follows the text, not its age:
176
+ default text without `{target}` on a challenge you add today draws the same warning.
177
+ - **A translation on a progression that is not a displayed challenge is plain text**, held to 64 /
178
+ 280 characters as written. It is still refused for a link or for words that claim to speak for
179
+ the platform, and its description may have line breaks.
180
+
181
+ ### What a player reads, and what your game reads
182
+
183
+ - **Where the platform shows a progression's name and description, a reader gets your translation
184
+ when there is one for their language, and the default text otherwise.**
185
+ - **Your game's own reads do not return this text.** `gem.challenges.list()` and the progression
186
+ read (`currentValue`, `isCompleted`) carry no name and no description.
187
+ - **`gem.gameConfig.load()` returns the config as you wrote it:** `display_name` and `description`
188
+ with `{target}` still in them, not replaced, and not chosen for the player's language. It also
189
+ returns your `localized` entries as written.
190
+ - **A game that shows its own challenge UI keeps rendering its own strings**, in
191
+ `gem.prefs.locale`.
192
+
84
193
  ## 3. Report progress from your game
85
194
 
86
195
  The write is the ordinary progression write. Your game already knows how to make it:
@@ -506,7 +615,9 @@ shows, just quietly meaningless.
506
615
 
507
616
  - **A new challenge gets a new id.** Always.
508
617
  - **To rename what the player sees, change `display_name`.** That is free and is not part of the
509
- challenge's identity.
618
+ challenge's identity. Update that name's translations in `localized` too
619
+ ([section 2a](#2a-translate-the-copy-and-write-the-target-into-it)): a translation you leave
620
+ behind keeps showing the old name to readers of that language.
510
621
  - **Changing a progression's `reset_schedule` is refused at upload**, for the same reason: a daily
511
622
  that becomes a weekly would carry its old id into a history that then describes two different
512
623
  things. A different period means a new progression. (Rewriting `0 0 * * *` as `daily` is not a
@@ -590,6 +701,17 @@ own streak on your store page. Your game does not have to render any of it.
590
701
  | `cron expression never fires` | an expression naming a date that does not exist (`0 0 30 2 *`) | fix the expression |
591
702
  | `reset_schedule changed from … to …` | you changed a live progression's period | use a NEW progression; `display_name` is what renames one |
592
703
  | warning `platform_grant_max_per_window_ignored` (or `platform_grant_cap_uses_retired_max_per_entity`) | a mapping declares `max_per_window` (or `max_per_entity`) | nothing is refused and the upload succeeds; remove the key, because the platform sets the caps |
704
+ | a refusal at `localized.<key>` | the key is not one of the eleven locales, or it is `en` | use a listed locale; the text outside `localized` is the English |
705
+ | a refusal at a `localized.<locale>` field that is empty | an empty translation | remove the key to show the default text |
706
+ | a refusal naming a `{name}` other than `target` | a displayed challenge's copy referred to something else | `target` is the only name; remove the braces from the copy |
707
+ | a refusal for `{target}` with no threshold | the progression has no `completion_threshold` | set one, or remove `{target}` |
708
+ | a refusal on a plural branch | no `other` branch, an `=N` that is not the threshold, or a category the language does not have | write the category branches that language has, and always `other` |
709
+ | a refusal for text that is too long | the line a player is shown passes 64 (name) or 280 (description) | shorten the text; nothing is truncated for you |
710
+ | warning `progressions_copy_refusal_pending` | default text without `{target}` on a displayed challenge breaks a copy rule | the upload succeeds; fix the text now, because this will become a refusal |
711
+
712
+ A refusal about a progression's copy reads `progressions[<i>] (<slug>): <path>: <reason>`, where
713
+ `<path>` is `display_name`, `description`, `localized.<locale>.display_name`,
714
+ `localized.<locale>.description`, or `localized.<key>` for a refused key.
593
715
 
594
716
  Every refusal names the field and the reason. **Read the error rather than a list written down
595
717
  here** — the platform is what decides these, and this table is only as current as the day it was
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "9.1.4"
53
+ "@indiegems/gem-web-sdk": "10.0.0"
54
54
  }
55
55
  ```
56
56
 
@@ -33,8 +33,8 @@
33
33
  },
34
34
  {
35
35
  "file": "changelog.md",
36
- "sha256": "6333e652b2c3ffade4ced6759436b8ab3dcb95b25a43c1bd82fe9a6f23fd8a7d",
37
- "updated": "2026-10-10T01:45:03Z"
36
+ "sha256": "cdaa2661a18afd096def083e5a44b2e125275d2aaa210b5d1e496631bf18f457",
37
+ "updated": "2026-10-10T18:47:07Z"
38
38
  },
39
39
  {
40
40
  "file": "cross-promotion.md",
@@ -43,8 +43,8 @@
43
43
  },
44
44
  {
45
45
  "file": "daily-challenges.md",
46
- "sha256": "c198868e2cb1f1b5d9164bb5b1d23bf3f502987e45e10163c8b048a8eba24667",
47
- "updated": "2026-10-02T23:01:00Z"
46
+ "sha256": "f4842b2fe01c8780020326f4722ab69dc01c1f40e1e6455cf2fb27752094383d",
47
+ "updated": "2026-10-10T14:37:50Z"
48
48
  },
49
49
  {
50
50
  "file": "dedicated-server-flow.md",
@@ -68,8 +68,8 @@
68
68
  },
69
69
  {
70
70
  "file": "getting-started.md",
71
- "sha256": "22fab7f0fd67966239c38541a8620ac7fe614f100b96df3e94570b573d4a9f61",
72
- "updated": "2026-10-10T01:45:03Z"
71
+ "sha256": "39812f4b84097fd29f4038324a9070eeddf526ca294f7cb5b7b3913335ac34bf",
72
+ "updated": "2026-10-10T18:47:07Z"
73
73
  },
74
74
  {
75
75
  "file": "inventory-and-catalogs.md",
@@ -113,8 +113,8 @@
113
113
  },
114
114
  {
115
115
  "file": "rooms-and-matches.md",
116
- "sha256": "0abf3717a42c94ec832dfa1310c2d93a9f58236fd75b0aacaab301976bffe286",
117
- "updated": "2026-10-07T19:27:16Z"
116
+ "sha256": "23540a20bdd8c8d058eb06d8ef11085ffd8eaaeeaadc93a807608b7c279e240c",
117
+ "updated": "2026-10-10T18:05:31Z"
118
118
  },
119
119
  {
120
120
  "file": "saving-player-progress.md",
@@ -123,8 +123,8 @@
123
123
  },
124
124
  {
125
125
  "file": "server-bundles.md",
126
- "sha256": "bc59062fefb7c17986671e2a8b6b86b714d1c7e9774956a3df98adcef38a7fd2",
127
- "updated": "2026-10-10T01:45:03Z"
126
+ "sha256": "43a7674a6da40e2091538d6a24664a66c4cf607cc16b6027f2ddf6fd7421861c",
127
+ "updated": "2026-10-10T18:47:07Z"
128
128
  },
129
129
  {
130
130
  "file": "serverless-functions-js.md",