@indiegems/gem-web-sdk 7.6.0 → 7.7.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.
@@ -28,6 +28,10 @@ response, and their rank among everyone is not the rank most screens want anyway
28
28
  last runs"_. `get()` is for a standings screen. `friends()` is for a standings screen among people
29
29
  the player knows.
30
30
 
31
+ **Want today's standings rather than all-time ones?** That is the same four reads over a board
32
+ declared `per_period` — one board for each day of a daily challenge — and it has its own section:
33
+ [A board for each day](#a-board-for-each-day-period-boards).
34
+
31
35
  `character()` is `mine()` for a character. **Read the leg your server WRITES.** Runs carry the
32
36
  entity that owns them, so if your server reports character-owned runs, `mine()` returns nothing and
33
37
  `character()` is the read you want — and vice versa. That mismatch is the commonest reason one of
@@ -226,6 +230,139 @@ is the second most likely reason a board looks empty.
226
230
  const theirs = await gem.leaderboards.character('time-trial', characterId);
227
231
  ```
228
232
 
233
+ ## A board for each day — period boards
234
+
235
+ Everything above ranks **every run a board has ever been given**. A daily challenge wants something
236
+ else: _today's_ standings, which start empty each day and close when the day does. Declare the board
237
+ `per_period` and it exists once per period of a challenge — one board for each day of a daily
238
+ challenge, each week of a weekly one, each month of a monthly one.
239
+
240
+ You do not give the board a schedule. It takes the challenge's: the same days, the same reset
241
+ time, the same time-zone rule. A board cannot drift from the challenge it belongs to, because it
242
+ has no clock of its own.
243
+
244
+ ### Declare it
245
+
246
+ Three pieces, and the middle one is the link. The run type names the **challenge** its runs belong
247
+ to — the `id` of a progression in the same config with a `reset_schedule` — and the board asks to
248
+ be split by that challenge's periods:
249
+
250
+ ```yaml
251
+ progressions:
252
+ - id: 4f2a9c71-3e8b-4d15-9a62-7c0e5b1d8f43
253
+ slug: daily-puzzle
254
+ config:
255
+ completion_threshold: 1
256
+ reset_schedule: { type: daily }
257
+
258
+ run_types:
259
+ - id: daily-run
260
+ int_value_1_name: moves
261
+ challenge: 4f2a9c71-3e8b-4d15-9a62-7c0e5b1d8f43
262
+
263
+ leaderboards:
264
+ - id: daily
265
+ run_type: daily-run
266
+ order_by:
267
+ - column: int_value_1
268
+ direction: asc
269
+ per_period: true
270
+ ```
271
+
272
+ - **If you already have a daily challenge, use its id.** The progression in
273
+ [daily-challenges.md](daily-challenges.md) is the same kind of thing; nothing new is needed.
274
+ - **The challenge does not have to be shown on your game's card.** A progression with a
275
+ `reset_schedule` and nothing else is enough — no `platform_display`, no stored content.
276
+ - **`daily`, `weekly`, `monthly` and a cron expression all work.** A progression with no
277
+ `reset_schedule`, or one that resets on an event, has no periods and is refused at upload.
278
+ - **One set of runs can back two boards.** Leave `per_period` off a second board over the same run
279
+ type and it ranks every run: "today" and "all time" from the same reports.
280
+
281
+ ### Record a run
282
+
283
+ Nothing changes in how a run is reported — your [serverless function](serverless-functions.md) or
284
+ your dedicated server reports it, never the browser. What changes is that the platform files each
285
+ run under a period:
286
+
287
+ - **The platform decides the period, from when it receives the run and the player's own time
288
+ zone.** Not from the device clock, and not from the run's `occurred_at`.
289
+ - **Name the period the run was played for.** Send `period_id` on the run, set to the challenge's
290
+ `periodLocalDate` from `gem.challenges.list()` as the player's game had it. A run that finishes
291
+ seconds before midnight and is checked seconds after still counts for the day it was played,
292
+ for **10 minutes** after that day ends. Leave it out and the run goes to whatever period it
293
+ arrives in — tomorrow's board, for that late run.
294
+ - ⚠️ **A run for a period that is not open for the player is refused, and the whole batch with
295
+ it** — `409 run_period_closed`. That is a day that ended more than ten minutes ago, or one that
296
+ has not started for them. Do not retry it: tell the player the day has closed.
297
+ - ⚠️ **Every run is a row.** A period board ranks every run it is given, so a player who plays
298
+ five times today holds five rows. For one row per player, have your function record one run per
299
+ player per period — `gem.leaderboards.mine('daily')` tells it whether today already has one.
300
+
301
+ ### Read it
302
+
303
+ Every read answers for **one period**, and says which:
304
+
305
+ ```js
306
+ const today = await gem.leaderboards.get('daily');
307
+ const yesterday = await gem.leaderboards.get('daily', { periodsBack: 1 });
308
+
309
+ heading(today.period.localDate, today.period.final ? 'Final' : 'So far');
310
+ ```
311
+
312
+ | Option | Reads |
313
+ | ----------------- | --------------------------------------------------------------------------------- |
314
+ | _(none)_ | the player's current period |
315
+ | `periodsBack: n` | `n` periods before it — yesterday for a daily, last week for a weekly. 0 to 400 |
316
+ | `period: id` | exactly that period, from an earlier result's `period.id` — for a shared link or a history screen |
317
+
318
+ `friends()`, `mine()` and `character()` take the same options. On `mine()` the runs, the personal
319
+ best and the total are that one period's, so `personalBest` is "your best today" and an empty
320
+ result each morning is the ordinary state.
321
+
322
+ The result's `period` is absent on an ordinary board and carries, on a period board:
323
+
324
+ | Field | What it is |
325
+ | -------------------- | ------------------------------------------------------------------------------- |
326
+ | `id` | the board's identity. Pass it back as `period` to read this same board again |
327
+ | `localDate` | the date — the same value the challenge gives as `periodLocalDate`. Absent for a cron challenge |
328
+ | `startAt`, `endsAt` | the **player's own** day, in Unix seconds |
329
+ | `opensAt`, `closesAt` | when the board accepts runs from **anyone**, in Unix seconds |
330
+ | `final` | `true` once no run can still arrive |
331
+
332
+ ⚠️ **Ask the platform which day it is; never work it out.** The player's day is a date on their
333
+ own calendar, in the time zone the platform holds for them. A board id built from the device clock
334
+ — `new Date().toISOString().slice(0, 10)` — names the wrong board for every player whose day is
335
+ not the UTC day, and for the hours either side of midnight for everyone else. Read with no option
336
+ for today, and take `period.id` from the result when you need to name it.
337
+
338
+ ### One day, many time zones
339
+
340
+ On the default `local` boundary each player's day is their own: it begins at their midnight.
341
+ **Players on the same date share one board, whatever their time zone** — the same board the
342
+ challenge's seed gives them all. Three consequences follow, and each one surprises somebody:
343
+
344
+ - **A day's board stays open about 50 hours.** It opens when the date begins in the earliest time
345
+ zone and closes when it ends in the latest. So the standings can change after _your_ day has
346
+ ended, and "you finished 3rd today" shown at the player's midnight can be wrong an hour later.
347
+ Say "so far" until `period.final` is `true`. Two or three days' boards are open at any moment.
348
+ - **Players whose day starts later see the earlier results first.** The puzzle is the same for
349
+ everyone on the date, so a player in a later time zone can see scores — and may be handed
350
+ solutions — before they play. If your board has to be strictly fair, put the challenge on one
351
+ clock for everybody: `reset_schedule: { type: daily, boundary: UTC }`. Everyone's day then turns
352
+ over at the same instant, and the board is open for exactly that day.
353
+ - **Rows carry no time.** `occurredAt` is `0` on a period board that follows each player's own time
354
+ zone: beside the board's date, a run's time would show where its player is. Render nothing for
355
+ `0`. Ranks are unaffected.
356
+
357
+ **Changing time zone does not buy a longer day.** A player's first run on a date fixes when that
358
+ date ends for them. Runs after that moment are refused for the date, even if the player has since
359
+ moved to a time zone where it is still running.
360
+
361
+ **From a serverless function or a dedicated server, name the player.** Their credential carries no
362
+ player, so a period read is answered in UTC unless you pass
363
+ `{ playerId }` — which is not that player's day on a `local` challenge. In the browser the read is
364
+ always for the signed-in player and `playerId` is ignored.
365
+
229
366
  ## Where the rows come from
230
367
 
231
368
  Runs are reported **from the server side** — by a dedicated server during its match, or by your
@@ -262,6 +399,9 @@ a `code`, and testing `err.reason === 'bad_request'` never matches, because a 40
262
399
  | `reason: 'invalid'`, `code: 'invalid_bucket'` | bucket values do not match the board's `bucket_by` | supply exactly the buckets the board declares |
263
400
  | `reason: 'not_found'`, `code: 'leaderboard_not_found'`, on `mine()` / `character()` | **also** the entity is not one you may read — an unknown character, one you do not own, one in another game, or a malformed id | **the same code as the row above**, which is the point: the entity read answers ONE refusal for every reason that depends on the target, deliberately, because a distinguishable answer would tell a caller whether a given player plays this game. Check the entity id as well as the board id — nothing in the response separates them |
264
401
  | `reason: 'forbidden'`, on `mine()` / `character()` / `friends()` | the caller may not act for this entity at all, or (on `friends()`) carries no player identity at all — a game server token, say. Decided from the caller's own claims, not from what was asked for | this one is worth telling apart, and it is not about the target: it will not start working because the player played more. Treat it as a bug in how the call was made, not as an empty board. `get()` never answers this — the global board needs no viewer |
402
+ | `reason: 'invalid'`, `code: 'invalid_period'` | `periodsBack` or `period` asked of a board that is not `per_period`; or a `period` that is not one of the challenge's, or more than 400 periods back | a board that ranks every run has no periods to select: drop the option, or declare the board `per_period`. Otherwise take the id from an earlier result's `period.id` rather than building one |
403
+ | `reason: 'invalid'`, `code: 'period_not_begun'` | the `period` asked for has not begun for this player, even if it has for players in time zones ahead of theirs | read with no option for the player's own current period |
404
+ | `TypeError` (thrown, no request sent) | `periodsBack` out of 0–400, a `period` that is not a period id, or both passed at once | pass one of them, or neither |
265
405
  | `reason: 'invalid'`, `code: 'bad_request'` | `limit` is not an integer in 1–100 | the SDK refuses this locally with a `TypeError` before any request, so seeing it from the server means something else built the URL |
266
406
  | `TypeError` (thrown, no request sent) | `limit` out of range, passed to `mine()` / `character()` | pass an integer 1–100, or omit it for the default 25 |
267
407
 
@@ -43,8 +43,8 @@
43
43
  },
44
44
  {
45
45
  "file": "daily-challenges.md",
46
- "sha256": "4820868e911cc518a5c37cbd8c31b36a8a2374e280be14f3a6dfa45985685372",
47
- "updated": "2026-10-01T19:59:43Z"
46
+ "sha256": "7ba296ba3056407ca3c8c9757a3351f3a8062a943335ee52b4ee92e1b2721d3b",
47
+ "updated": "2026-10-02T18:52:17Z"
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": "fd859a01988ecf57c857dcfba9958a8a32a65ff2a8ecfaf55f33f9045d368180",
72
- "updated": "2026-10-02T17:28:15Z"
71
+ "sha256": "4a3c179022bbc7143b190a27a78c6fac8f7f92d8964b2dc6aec7342b052f40e1",
72
+ "updated": "2026-10-02T19:06:36Z"
73
73
  },
74
74
  {
75
75
  "file": "inventory-and-catalogs.md",
@@ -78,8 +78,8 @@
78
78
  },
79
79
  {
80
80
  "file": "leaderboards.md",
81
- "sha256": "940be101a9a06a6ddad85a1afe18a23e9acca43772d158ff7be70ed0f2f6b28d",
82
- "updated": "2026-09-28T19:46:26Z"
81
+ "sha256": "4f065b915604284ec810aa3c7bc046bfd99f343332a70b35921ecf061503f867",
82
+ "updated": "2026-10-02T18:52:17Z"
83
83
  },
84
84
  {
85
85
  "file": "match-session.md",
@@ -118,8 +118,8 @@
118
118
  },
119
119
  {
120
120
  "file": "server-bundles.md",
121
- "sha256": "8190da0484b7e1ae6f636068b06a43729f3ef132488f5a0cfd2fc5c2988bc55b",
122
- "updated": "2026-10-02T17:28:15Z"
121
+ "sha256": "7d48bdbc026a03c78d26c24bf217fab1aa8df9d358fbe54c64fa53f18aa0d77b",
122
+ "updated": "2026-10-02T19:06:36Z"
123
123
  },
124
124
  {
125
125
  "file": "serverless-functions-js.md",
@@ -128,8 +128,8 @@
128
128
  },
129
129
  {
130
130
  "file": "serverless-functions.md",
131
- "sha256": "3b61ee955d3bc439c39a7cad9339f8ffe8c0fb584f0a25621b877e185160a45a",
132
- "updated": "2026-10-01T18:13:51Z"
131
+ "sha256": "c830daf736713744909700bf93ff5ba139b8b5d8ad3c2b4e057ca813fc6179f7",
132
+ "updated": "2026-10-02T18:52:17Z"
133
133
  },
134
134
  {
135
135
  "file": "trading-and-gifting.md",
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
80
80
  also publishes on its download host, with the hash beside it:
81
81
 
82
82
  ```bash
83
- curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.6.0/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v7.6.0/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.7.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v7.7.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -183,11 +183,15 @@ do not decide whether a reward was earned. So:
183
183
  zone, and on one with no reset it grants once, ever.
184
184
  `progression_max` grants only an improvement on a best. The platform keeps that state, and the
185
185
  input cannot reset it;
186
- - if you key a paying order with a `nonce` instead, build the key from something the platform
187
- decides: the UTC date from `now-ms` (`daily:2026-09-26`), or an id the platform issued, such as
188
- a match it ran. **Never use an id from the call's input.** A run id your client made up is a new
189
- id on every call, so it stops nothing. A nonce is also only remembered for 72 hours, so it
190
- guards a period, not a lifetime;
186
+ - if you key the order that grants with a `nonce` instead, build the key from something the
187
+ platform decides: the date of the player's daily, or an id the platform issued, such as a match
188
+ it ran. That date is `period_local_date` on your
189
+ [daily challenge](daily-challenges.md#the-one-call-that-answers-both), read for the caller with
190
+ `GET /v1/challenges?player_id=<their id>` (`daily:2026-09-26`), and it is the player's own day
191
+ unless the challenge's `boundary` fixes a zone. The UTC date from `now-ms` names a UTC day,
192
+ which for a player off UTC is not the day their daily runs on. **Never use an id from the call's
193
+ input.** A run id your client made up is a new id on every call, so it stops nothing. A nonce is
194
+ also only remembered for 72 hours, so it guards a day, not a week or a lifetime;
191
195
  - never pass the caller's request straight through to a progression write.
192
196
 
193
197
  ### What `call-api` can reach
@@ -386,6 +390,31 @@ The path's `{game_id}` is the game id from `get-context`, `entity_id` is the pla
386
390
  you, and `run_id` is a UUID you choose. A run may also carry `occurred_at`, in Unix seconds, which
387
391
  defaults to now. A stored batch answers `200` with `{ "inserted": 1, "duplicates": 0 }`.
388
392
 
393
+ **A run for a daily board names its day.** When the run type belongs to a challenge — its config
394
+ names one with `challenge`, which is what a
395
+ [`per_period` board](leaderboards.md#a-board-for-each-day-period-boards) ranks — the platform
396
+ files the run under one period of that challenge. It decides which from when the run arrives and
397
+ the player's own time zone, never from `occurred_at`. Add `"period_id"` to the run, set to the
398
+ `periodLocalDate` the player's game read from `gem.challenges.list()` and sent you with the result:
399
+
400
+ ```json
401
+ {
402
+ "run_id": "5b0d3c1e-8f7a-4c2e-9d61-0a4b7e2f9c13",
403
+ "entity_type": "player",
404
+ "entity_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
405
+ "run_type": "daily-run",
406
+ "int_value_1": 14,
407
+ "period_id": "2026-10-02"
408
+ }
409
+ ```
410
+
411
+ A run that names the day it was played for still counts for that day when it arrives up to 10
412
+ minutes after the day ended for the player. Without `period_id` the run goes to the period it
413
+ arrives in. ⚠️ **`period_id` is a claim the platform checks, not a choice it obeys**: a client
414
+ cannot use it to post to yesterday's board, or to a day that has not begun for the player. For a
415
+ cron challenge the id is the period's start, as `2026-10-02T06:00:00Z`. Send no `period_id` for a
416
+ run type that names no challenge; it is refused there.
417
+
389
418
  ⚠️ **A function that records the score its caller sent, unchecked, makes your board forgeable.**
390
419
  The platform trusts a function's runs as it trusts your dedicated server's, and it cannot tell a
391
420
  checked score from a forwarded one. Check the run against what the player cannot forge (section 1)
@@ -396,6 +425,7 @@ before you record it, or any player can put any score they like on your board.
396
425
  | `400 invalid_run` | A run failed its run type's checks. `details` names it by `index` and `run_id` | Fix that run and send the batch again |
397
426
  | `400 unknown_run_type` | The run type is not in the active config of the channel the function was called on | Publish the run type on that channel |
398
427
  | `400 too_many_runs` | More than 500 runs in one request | Split the batch |
428
+ | `409 run_period_closed` | A run's challenge period is not open for its player: the day ended more than 10 minutes ago, or has not begun for them. Nothing was written, and `details` names the run by `index` and `run_id` | Do not retry that run. Tell the player the day has closed, and send the rest of the batch without it |
399
429
  | `403 function_foreign_entity_write` | A function a player called named another player, or a character not theirs | Record runs only for the caller and their characters |
400
430
  | `429 rate_limited` | The budget of 60 requests a minute is spent. Nothing was written | Send more runs in each request, and fewer requests |
401
431
  | `503 service_unavailable` | The platform could not check the budget, so it refused the request. Nothing was written | Retry shortly |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "7.6.0",
3
+ "version": "7.7.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",