@rydr/game-sdk 1.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.
Files changed (82) hide show
  1. package/README.md +220 -0
  2. package/dist/client/HardwareStore.d.ts +40 -0
  3. package/dist/client/HardwareStore.d.ts.map +1 -0
  4. package/dist/client/HardwareStore.js +36 -0
  5. package/dist/client/HardwareStore.js.map +1 -0
  6. package/dist/client/PlatformClient.d.ts +182 -0
  7. package/dist/client/PlatformClient.d.ts.map +1 -0
  8. package/dist/client/PlatformClient.js +374 -0
  9. package/dist/client/PlatformClient.js.map +1 -0
  10. package/dist/client/Room.d.ts +127 -0
  11. package/dist/client/Room.d.ts.map +1 -0
  12. package/dist/client/Room.js +235 -0
  13. package/dist/client/Room.js.map +1 -0
  14. package/dist/client/adminContent.d.ts +65 -0
  15. package/dist/client/adminContent.d.ts.map +1 -0
  16. package/dist/client/adminContent.js +75 -0
  17. package/dist/client/adminContent.js.map +1 -0
  18. package/dist/client/index.d.ts +7 -0
  19. package/dist/client/index.d.ts.map +1 -0
  20. package/dist/client/index.js +7 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/replayCodec.d.ts +27 -0
  23. package/dist/client/replayCodec.d.ts.map +1 -0
  24. package/dist/client/replayCodec.js +74 -0
  25. package/dist/client/replayCodec.js.map +1 -0
  26. package/dist/host/PlatformHost.d.ts +169 -0
  27. package/dist/host/PlatformHost.d.ts.map +1 -0
  28. package/dist/host/PlatformHost.js +248 -0
  29. package/dist/host/PlatformHost.js.map +1 -0
  30. package/dist/host/index.d.ts +3 -0
  31. package/dist/host/index.d.ts.map +1 -0
  32. package/dist/host/index.js +3 -0
  33. package/dist/host/index.js.map +1 -0
  34. package/dist/index.d.ts +12 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +12 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/protocol/boards.d.ts +61 -0
  39. package/dist/protocol/boards.d.ts.map +1 -0
  40. package/dist/protocol/boards.js +53 -0
  41. package/dist/protocol/boards.js.map +1 -0
  42. package/dist/protocol/buttons.d.ts +13 -0
  43. package/dist/protocol/buttons.d.ts.map +1 -0
  44. package/dist/protocol/buttons.js +2 -0
  45. package/dist/protocol/buttons.js.map +1 -0
  46. package/dist/protocol/capabilities.d.ts +23 -0
  47. package/dist/protocol/capabilities.d.ts.map +1 -0
  48. package/dist/protocol/capabilities.js +10 -0
  49. package/dist/protocol/capabilities.js.map +1 -0
  50. package/dist/protocol/gamedata.d.ts +23 -0
  51. package/dist/protocol/gamedata.d.ts.map +1 -0
  52. package/dist/protocol/gamedata.js +2 -0
  53. package/dist/protocol/gamedata.js.map +1 -0
  54. package/dist/protocol/guards.d.ts +15 -0
  55. package/dist/protocol/guards.d.ts.map +1 -0
  56. package/dist/protocol/guards.js +45 -0
  57. package/dist/protocol/guards.js.map +1 -0
  58. package/dist/protocol/identity.d.ts +21 -0
  59. package/dist/protocol/identity.d.ts.map +1 -0
  60. package/dist/protocol/identity.js +2 -0
  61. package/dist/protocol/identity.js.map +1 -0
  62. package/dist/protocol/index.d.ts +12 -0
  63. package/dist/protocol/index.d.ts.map +1 -0
  64. package/dist/protocol/index.js +12 -0
  65. package/dist/protocol/index.js.map +1 -0
  66. package/dist/protocol/messages.d.ts +407 -0
  67. package/dist/protocol/messages.d.ts.map +1 -0
  68. package/dist/protocol/messages.js +2 -0
  69. package/dist/protocol/messages.js.map +1 -0
  70. package/dist/protocol/replays.d.ts +67 -0
  71. package/dist/protocol/replays.d.ts.map +1 -0
  72. package/dist/protocol/replays.js +14 -0
  73. package/dist/protocol/replays.js.map +1 -0
  74. package/dist/protocol/room.d.ts +55 -0
  75. package/dist/protocol/room.d.ts.map +1 -0
  76. package/dist/protocol/room.js +13 -0
  77. package/dist/protocol/room.js.map +1 -0
  78. package/dist/protocol/version.d.ts +12 -0
  79. package/dist/protocol/version.d.ts.map +1 -0
  80. package/dist/protocol/version.js +12 -0
  81. package/dist/protocol/version.js.map +1 -0
  82. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # @rydr/game-sdk
2
+
3
+ The client SDK for building games on the **RYDR** indoor-cycling platform.
4
+
5
+ A RYDR game runs as a sandboxed cross-origin `<iframe>` embedded by the platform shell. The shell owns the hardware (BLE trainer, HRM, Zwift Play, phone) and the user; the game receives **scoped** hardware data and identity over a versioned `postMessage` wire protocol and never touches BLE or PII directly.
6
+
7
+ Beyond bridging hardware and identity, the shell also **backs a set of services** a game can call: leaderboards, opaque run records, replays/ghosts, a scoped game-data store (dev-authored content, per-player saves, and world-readable UGC), asset hosting, and realtime rooms — see [Backend services](#backend-services). A game rarely needs its own backend.
8
+
9
+ This package is the **public contract** between platform and game:
10
+
11
+ - `protocol/` — the versionless wire protocol: handshake, capabilities, scoped identity, hardware/lifecycle messages, type guards. **Treat as a public API — additive changes only.**
12
+ - `client/` — `connectToPlatform()` → a `PlatformSession` exposing a reactive hardware store, scoped identity, and trainer-control commands.
13
+ - `host/` — `createPlatformHost()`: the **platform side** of the protocol (used by the shell to embed a game).
14
+
15
+ ## Install
16
+
17
+ Public git dependency (no registry, no token):
18
+
19
+ ```jsonc
20
+ // package.json
21
+ "dependencies": { "@rydr/game-sdk": "github:bdefrenne/rydr-game-sdk#semver:^1.0.0" }
22
+ ```
23
+
24
+ **Starting a new game?** Don't wire this by hand — scaffold from
25
+ [`create-rydr-game`](https://github.com/bdefrenne/create-rydr-game) (`npx degit
26
+ bdefrenne/create-rydr-game my-game`), which comes with the SDK wired, a dev script, and an
27
+ agent-runnable `SETUP.md`.
28
+
29
+ ## The boundary
30
+
31
+ ```
32
+ platform shell ──(SDK wire protocol)──▶ game iframe
33
+ owns BLE/HRM/profile/FIT receives scoped power/HR/cadence/buttons + identity
34
+ ```
35
+
36
+ The game↔engine protocol *inside* a game (e.g. racing's Three.js engine iframe) is the game's private business and is not part of this SDK.
37
+
38
+ ## Usage (game side)
39
+
40
+ ```ts
41
+ import { connectToPlatform } from "@rydr/game-sdk";
42
+
43
+ // Games get FULL access — you don't pick capabilities. Just pass your gameId.
44
+ const session = await connectToPlatform({ gameId: "racing" });
45
+
46
+ session.hardware.subscribe((hw) => render(hw.power, hw.heartRate));
47
+ session.onButton(({ name, edge }) => handleInput(name, edge));
48
+ session.setSimulation(4.2); // request 4.2% grade on the trainer
49
+ session.ready();
50
+ ```
51
+
52
+ ## API reference
53
+
54
+ `connectToPlatform(options)` → `Promise<PlatformSession>`. Options:
55
+ `{ gameId: string; platformOrigin?: string; target?: Window; handshakeTimeoutMs?: number }`.
56
+ **Games get full access — there's no capability selection.** (`capabilities?: Capability[]`
57
+ exists but defaults to ALL; you don't set it.)
58
+
59
+ ### PlatformSession
60
+ - `identity: ScopedIdentity` — `{ playerId, displayName: string; weightKg, ftp: number }` (PII-free).
61
+ - `grantedCapabilities: readonly Capability[]` · `initialPath: string | undefined`.
62
+ - `hardware: HardwareStore` — `current: HardwareSnapshot`, and `subscribe(cb) => () => void` (fires immediately, then on every change).
63
+ - `ready()` · `reportLoadProgress(0..100)` · `reportError(message)`.
64
+ - `setSimulation(gradePercent)` · `setTargetPower(watts)` · `setErgMode(enabled)` — trainer control.
65
+ - `setRoute(path)` · `setChrome(visible)` · `requestExit()` · `requestHardwareModal()`.
66
+
67
+ > **No activity/FIT API.** The platform records every session automatically from its own hardware stream — games do nothing for recording.
68
+
69
+ - `boards: readonly BoardDefinition[]` · `runId: string` · `dataHost: string` — the game's leaderboard catalog (from its manifest), the run this session is recorded under, and the realtime backend host.
70
+ - **Backend services** (detailed below): `submitScore` · `getLeaderboard` · `saveRun`/`getRun` · `saveReplay`/`getReplays`/`getReplay` · `getContent`/`listContent` · `getData`/`listData`/`saveData`/`deleteData` · `saveContent`/`deleteContent` · `getUploadUrl` · `joinRoom`.
71
+ - `onButton(cb)` · `onPause(cb)` · `onResume(cb)` · `onIdentityChange(cb)` — each returns an unsubscribe fn.
72
+ - `dispose()`.
73
+
74
+ `HardwareSnapshot` = `{ power, cadence, heartRate, speed: number; trainerConnected, ergSupported: boolean; updatedAt: number }`
75
+ — power W · cadence rpm · heartRate bpm (0 with no HRM) · speed m/s · updatedAt ms.
76
+
77
+ `ButtonEvent` = `{ name: ButtonName; edge: "down" | "up" }` (see `protocol/buttons.ts` for the `ButtonName` union).
78
+
79
+ > The compiled types in `dist/index.d.ts` are authoritative — this section is the overview.
80
+
81
+ ## Backend services
82
+
83
+ The shell backs a handful of services so a game rarely needs its own backend. All calls go through the SDK; the platform stamps `playerId` + `runId` and enforces access. (`dist/index.d.ts` is authoritative for exact types.)
84
+
85
+ ### Leaderboards
86
+
87
+ Boards are **declarative game config** — each declares an id and how it ranks/formats, not created at runtime. They come from the game's manifest; the shell hands the catalog to the game at handshake as `session.boards: BoardDefinition[]`. **`submitScore` to an unknown `boardId` is rejected** — the board must be declared in the manifest first. (How a game declares boards in its manifest is part of its scaffolding, not the SDK.)
88
+
89
+ ```ts
90
+ // session.boards = [{ id: "waves", valueType: "count", sort: "desc", aggregate: "best" }, …]
91
+ const { rank, isPersonalBest, total } = await session.submitScore("waves", wavesCleared);
92
+ const page = await session.getLeaderboard("waves", { limit: 10 }); // { entries, you? }
93
+ ```
94
+
95
+ - `submitScore(boardId, value, { key? })` → `{ rank, isPersonalBest, total }` — for a results screen. `key` selects a parameterized board family member (e.g. per-track).
96
+ - `getLeaderboard(boardId, { key?, limit? })` → `{ entries: BoardEntry[], you? }` — top-N plus the requester's own row.
97
+ - `formatBoardValue(valueType, value)` formats a raw value for display. It is a **standalone package export, not a session method** — `import { formatBoardValue } from "@rydr/game-sdk"`, then `formatBoardValue(board.valueType, entry.value)`.
98
+
99
+ ### Run records
100
+
101
+ ```ts
102
+ session.saveRun({ outcome: "win", waves: 12 }); // fire-and-forget — returns void, do NOT await
103
+ const detail = await session.getRun(someEntry.runId); // read a breakdown back (e.g. leaderboard detail)
104
+ ```
105
+
106
+ - `saveRun(breakdown)` stores an opaque, game-specific object against this session's `runId` (which links to the FIT activity). Fire-and-forget — returns `void`, do **not** await.
107
+ - `getRun(runId)` → `Promise<unknown | null>` reads a stored breakdown back (e.g. expand a leaderboard row; `BoardEntry.runId` is the key). `null` if absent.
108
+
109
+ ### Replays / ghosts
110
+
111
+ A replay is an **array of frames** the game interpolates over to render a ghost. Every frame is `{ t, power, customData? }`: `t` (ms from start) and `power` (watts) are **mandatory and platform-readable** — so the timeline and power of any replay are legible to the platform/tooling — while `customData` is the game's own opaque per-frame payload (position, lean, animation…). Timing lives entirely in `t`, so frames need **not** be evenly spaced (no global sample rate / frame count to drift).
112
+
113
+ The SDK owns the wire shape: `saveReplay` packs the frames into a versioned, gzip+base64 blob, and **derives** a small `ReplayMeta` summary — `{ durationMs, avgPower, maxPower }` — stored alongside it so a ghost list can render without decompressing every blob. Who/score/when are *not* in the meta; they're on the leaderboard entry sharing the same `runId`. Because the leaderboard stamps `runId` on every entry, a replay is also the **ghost for that standing**.
114
+
115
+ ```ts
116
+ // After a run: store the ghost against this session's runId. The SDK encodes + derives meta.
117
+ await session.saveReplay(session.runId, frames); // frames: { t, power, customData? }[]
118
+
119
+ // Cheap ghost list — meta only, no blob decode:
120
+ const ghosts = await session.getReplays("lap", { key: trackId, top: 5 });
121
+ for (const g of ghosts) {
122
+ if (g.meta) showRow(g.displayName, g.rank, g.value, g.meta.durationMs, g.meta.avgPower);
123
+ }
124
+
125
+ // Race against a specific ghost — decode its frames:
126
+ const r = await session.getReplay(ghosts[0].runId);
127
+ if (r) spawnGhost(r.body.frames); // r = { body: ReplayBody, meta: ReplayMeta | null }
128
+ ```
129
+
130
+ - `saveReplay(runId, frames, { version? })` → `Promise<void>` — encode `ReplayFrame[]` and persist the blob + derived meta keyed by `runId`. Large blobs are chunked server-side; for truly large binaries prefer [asset upload](#asset-upload) (R2) and store the URL.
131
+ - `getReplays(boardId, { key?, top? })` → `Promise<ReplayRef[]>` — the top entries' ghosts. Each `ReplayRef` = `{ runId, rank, displayName, value, blob: string | null, meta: ReplayMeta | null }` (`blob`/`meta` are `null` for an entry with no stored replay). Use `meta` for display; this does **not** decode frames. `top` defaults to 10; `key` selects a parameterized board member.
132
+ - `getReplay(runId)` → `Promise<{ body: ReplayBody, meta: ReplayMeta | null } | null>` — fetch and decode one replay (a board entry's `runId`, the session's own, or a shared-link id). `null` if none stored.
133
+ - `encodeReplay(frames, version?)` / `decodeReplay(blob)` — the codec, exported standalone for tooling or when you hold a raw blob.
134
+
135
+ ### Game-data store (opaque docs)
136
+
137
+ Three scopes. `data` is opaque to the platform — the game owns the shape. Docs are `GameDoc` (`{ id, data, updatedAt, ownerId?, draft? }`).
138
+
139
+ | Scope | Who can read / write | Methods |
140
+ |-------|----------------------|---------|
141
+ | `player` *(default)* | the player only — private saves | `getData` · `listData` · `saveData` · `deleteData` |
142
+ | `public` | owner writes, **world-readable** — player UGC | same methods, with `{ scope: "public" }` |
143
+ | `shared` | **world-readable, author-gated write** — dev-authored content | `getContent` · `listContent` (read) · `saveContent` · `deleteContent` (write) |
144
+
145
+ ```ts
146
+ await session.saveData("saves", "slot1", { level: 4, hp: 80 }); // player-private (default scope)
147
+ const slot = await session.getData("saves", "slot1"); // → GameDoc | null
148
+ const tracks = await session.listContent("tracks"); // dev-authored shared content
149
+ ```
150
+
151
+ > **Player content uses `public`, not `shared`.** `saveContent`/`deleteContent`/`getUploadUrl` are gated by the game's **author allowlist** (checked by `playerId`) — a normal player calling them is **rejected**. Route player-generated content through `saveData(collection, id, value, { scope: "public" })`. Reserve `saveContent`/`getUploadUrl` for in-game **author/admin tooling** (level/chart/song editors).
152
+
153
+ ### Asset upload
154
+
155
+ For binaries (MP3s, images) backing `shared` content. **Author-gated** (same allowlist as `saveContent`).
156
+
157
+ ```ts
158
+ const { uploadUrl, url } = await session.getUploadUrl({ collection: "songs", filename: "track.mp3" });
159
+ await fetch(uploadUrl, { method: "PUT", body: file }); // PUT the bytes directly
160
+ await session.saveContent("songs", "track-1", { title: "…", audioUrl: url }); // store the public url
161
+ ```
162
+
163
+ ### Build an in-game editor
164
+
165
+ Any game can ship an **editor page** where a dev pastes the platform `ADMIN_SECRET` and authors that game's `shared` content (levels, tracks, charts, runs, …). The game reads the same content back through the session (`listContent`/`getContent`) — **one shared backend, no per-game server.**
166
+
167
+ There are two write paths to `shared` content:
168
+
169
+ | Path | Auth | Use it from |
170
+ |------|------|-------------|
171
+ | **Author allowlist** | `playerId` stamped by the shell (via the session) | an editor embedded **in-shell** (has a session) — `session.saveContent(...)` |
172
+ | **Admin Bearer** | `Authorization: Bearer <ADMIN_SECRET>` | a **standalone** editor page (no session) — `createAdminContentBackend(...)` |
173
+
174
+ A standalone editor (its own `.html`, opened outside the shell) has no session, so it uses the admin Bearer path. Prompt for the secret once, keep it in `sessionStorage` (never in the repo), and build a backend:
175
+
176
+ ```ts
177
+ import { createAdminContentBackend } from "@rydr/game-sdk";
178
+
179
+ const SECRET_KEY = "admin.secret";
180
+ function getSecret(): string {
181
+ let s = sessionStorage.getItem(SECRET_KEY);
182
+ if (!s) { s = prompt("ADMIN_SECRET")?.trim() ?? ""; if (s) sessionStorage.setItem(SECRET_KEY, s); }
183
+ return s;
184
+ }
185
+
186
+ const admin = createAdminContentBackend({
187
+ host: "https://my-game.partykit.dev", // platform origin (or http://localhost:1999 in dev)
188
+ gameId: "my-game",
189
+ getSecret,
190
+ });
191
+
192
+ const levels = await admin.list("levels"); // includes drafts (Bearer is sent)
193
+ await admin.save("levels", "level-1", { waves: [...] }); // publish
194
+ await admin.save("levels", "wip", { ... }, { draft: true }); // hidden from players until published
195
+ await admin.remove("levels", "old");
196
+ const { url } = await admin.uploadAsset({ collection: "art", filename: "bg.png", contentType: "image/png", body: file });
197
+ ```
198
+
199
+ The game then reads the published docs with the session: `await session.listContent("levels")`. (Drafts are hidden from the public read until you `save` without `draft`.)
200
+
201
+ > **Security boundary.** `ADMIN_SECRET` is the platform owner's key — full write to **any** game's shared content. It's an authoring-time credential, entered at runtime and **never shipped to players**. Player-generated content uses the `public` owner-write scope (`saveData(..., { scope: "public" })`), not this backend.
202
+
203
+ ### Realtime rooms
204
+
205
+ ```ts
206
+ const room = session.joinRoom("lobby");
207
+ const off = room.on("message", (data, from) => render(data, from));
208
+ room.on("presence", (members) => updateRoster(members));
209
+ room.send({ kick: true }); // relay to other members
210
+ room.setState({ phase: "racing" }); // merge into shared opaque state (last-write-wins)
211
+ // room.members · room.state · room.leave()
212
+ ```
213
+
214
+ `joinRoom(roomId)` → `RoomHandle` over a direct WebSocket (presence + relay + opaque shared state; the server is dumb — the game defines what messages/state mean). Events: `message`, `presence`, `state`, `open`, `close`; each `on(...)` returns an unsubscribe fn. In **standalone dev** (no shell) it falls back to a local single-member loopback room, so room code runs without a backend.
215
+
216
+ > **Status: when `room` lands.** The client + protocol are shipped, but the backend `room` party is **not yet deployed** — `joinRoom` works in standalone-dev loopback today, and goes live against the shared backend with the realtime/multiplayer follow-up. Build against it; just don't expect cross-client presence in production until then.
217
+
218
+ ## Versioning
219
+
220
+ `RYDR_PROTOCOL_VERSION` is the wire version. The shell supports a range and adapts older messages. Breaking shape changes are forbidden; evolve additively.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Reactive snapshot of bridged hardware state for a game.
3
+ *
4
+ * A game never owns hardware; it observes this store, which the
5
+ * {@link PlatformClient} keeps current from the platform's `hw.*` messages.
6
+ * Buttons are events (see `PlatformSession.onButton`), not state, so they live
7
+ * on the client rather than here.
8
+ */
9
+ export interface HardwareSnapshot {
10
+ /** Trainer power, watts. */
11
+ power: number;
12
+ /** Pedalling cadence, rpm. */
13
+ cadence: number;
14
+ /** Heart rate, bpm (0 when no HRM). */
15
+ heartRate: number;
16
+ /** Trainer-reported speed, m/s. */
17
+ speed: number;
18
+ /** Whether a trainer is currently connected. */
19
+ trainerConnected: boolean;
20
+ /** Whether the connected trainer supports ERG (target-power) control. */
21
+ ergSupported: boolean;
22
+ /** ms timestamp of the most recent update (0 before any data). */
23
+ updatedAt: number;
24
+ }
25
+ export type HardwareListener = (snapshot: HardwareSnapshot) => void;
26
+ /** Holds the latest hardware snapshot and notifies subscribers on change. */
27
+ export declare class HardwareStore {
28
+ private snapshot;
29
+ private readonly listeners;
30
+ /** The latest snapshot. Cheap to read every frame. */
31
+ get current(): HardwareSnapshot;
32
+ /** Subscribe to changes. Fires immediately with the current snapshot. Returns an unsubscribe fn. */
33
+ subscribe(listener: HardwareListener): () => void;
34
+ /**
35
+ * Merge a partial update and notify subscribers.
36
+ * @internal Used by {@link PlatformClient}; games should not call this.
37
+ */
38
+ _patch(patch: Partial<HardwareSnapshot>): void;
39
+ }
40
+ //# sourceMappingURL=HardwareStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HardwareStore.d.ts","sourceRoot":"","sources":["../../src/client/HardwareStore.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,4BAA4B;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,gBAAgB,EAAE,OAAO,CAAC;IAC1B,yEAAyE;IACzE,YAAY,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,SAAS,EAAE,MAAM,CAAC;CACnB;AAYD,MAAM,MAAM,gBAAgB,GAAG,CAAC,QAAQ,EAAE,gBAAgB,KAAK,IAAI,CAAC;AAEpE,6EAA6E;AAC7E,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAA2B;IAC3C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA+B;IAEzD,sDAAsD;IACtD,IAAI,OAAO,IAAI,gBAAgB,CAE9B;IAED,oGAAoG;IACpG,SAAS,CAAC,QAAQ,EAAE,gBAAgB,GAAG,MAAM,IAAI;IAQjD;;;OAGG;IACH,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,IAAI;CAI/C"}
@@ -0,0 +1,36 @@
1
+ const EMPTY = {
2
+ power: 0,
3
+ cadence: 0,
4
+ heartRate: 0,
5
+ speed: 0,
6
+ trainerConnected: false,
7
+ ergSupported: false,
8
+ updatedAt: 0,
9
+ };
10
+ /** Holds the latest hardware snapshot and notifies subscribers on change. */
11
+ export class HardwareStore {
12
+ snapshot = EMPTY;
13
+ listeners = new Set();
14
+ /** The latest snapshot. Cheap to read every frame. */
15
+ get current() {
16
+ return this.snapshot;
17
+ }
18
+ /** Subscribe to changes. Fires immediately with the current snapshot. Returns an unsubscribe fn. */
19
+ subscribe(listener) {
20
+ this.listeners.add(listener);
21
+ listener(this.snapshot);
22
+ return () => {
23
+ this.listeners.delete(listener);
24
+ };
25
+ }
26
+ /**
27
+ * Merge a partial update and notify subscribers.
28
+ * @internal Used by {@link PlatformClient}; games should not call this.
29
+ */
30
+ _patch(patch) {
31
+ this.snapshot = { ...this.snapshot, ...patch };
32
+ for (const listener of this.listeners)
33
+ listener(this.snapshot);
34
+ }
35
+ }
36
+ //# sourceMappingURL=HardwareStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HardwareStore.js","sourceRoot":"","sources":["../../src/client/HardwareStore.ts"],"names":[],"mappings":"AAyBA,MAAM,KAAK,GAAqB;IAC9B,KAAK,EAAE,CAAC;IACR,OAAO,EAAE,CAAC;IACV,SAAS,EAAE,CAAC;IACZ,KAAK,EAAE,CAAC;IACR,gBAAgB,EAAE,KAAK;IACvB,YAAY,EAAE,KAAK;IACnB,SAAS,EAAE,CAAC;CACb,CAAC;AAIF,6EAA6E;AAC7E,MAAM,OAAO,aAAa;IAChB,QAAQ,GAAqB,KAAK,CAAC;IAC1B,SAAS,GAAG,IAAI,GAAG,EAAoB,CAAC;IAEzD,sDAAsD;IACtD,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,oGAAoG;IACpG,SAAS,CAAC,QAA0B;QAClC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC7B,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACxB,OAAO,GAAG,EAAE;YACV,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAClC,CAAC,CAAC;IACJ,CAAC;IAED;;;OAGG;IACH,MAAM,CAAC,KAAgC;QACrC,IAAI,CAAC,QAAQ,GAAG,EAAE,GAAG,IAAI,CAAC,QAAQ,EAAE,GAAG,KAAK,EAAE,CAAC;QAC/C,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,SAAS;YAAE,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACjE,CAAC;CACF"}
@@ -0,0 +1,182 @@
1
+ import { type Capability } from "../protocol/capabilities";
2
+ import type { ScopedIdentity } from "../protocol/identity";
3
+ import type { ButtonName, ButtonEdge } from "../protocol/buttons";
4
+ import type { BoardDefinition, LeaderboardPage, SubmitScoreResult } from "../protocol/boards";
5
+ import type { GameDoc } from "../protocol/gamedata";
6
+ import type { ReplayBody, ReplayFrame, ReplayMeta, ReplayRef } from "../protocol/replays";
7
+ import { HardwareStore } from "./HardwareStore";
8
+ import { type RoomHandle } from "./Room";
9
+ /** A canonical controller button event delivered to the game. */
10
+ export interface ButtonEvent {
11
+ name: ButtonName;
12
+ edge: ButtonEdge;
13
+ }
14
+ export interface ConnectOptions {
15
+ /** Stable game id, matching the game's platform manifest. */
16
+ gameId: string;
17
+ /**
18
+ * Capabilities to request. Optional — defaults to ALL. Games get full access; the
19
+ * platform doesn't ask games to pick. (Field kept for forward-compat / tests.)
20
+ */
21
+ capabilities?: Capability[];
22
+ /**
23
+ * Origin the shell is served from. Messages from any other origin are ignored,
24
+ * and outbound messages are posted only to this origin. Defaults to `"*"`
25
+ * (acceptable for local dev only — always set it in production).
26
+ */
27
+ platformOrigin?: string;
28
+ /** Window to talk to. Defaults to `window.parent` (the embedding shell). */
29
+ target?: Window;
30
+ /** How long to wait for the shell's `welcome` before rejecting. Default 15000ms. */
31
+ handshakeTimeoutMs?: number;
32
+ }
33
+ /** The live connection a game holds to the platform. */
34
+ export interface PlatformSession {
35
+ /** Scoped, PII-free identity granted by the shell. */
36
+ readonly identity: ScopedIdentity;
37
+ /** Capabilities the shell actually granted (subset of those requested). */
38
+ readonly grantedCapabilities: readonly Capability[];
39
+ /** Deep-link path the game should route to on start, if the shell supplied one. */
40
+ readonly initialPath: string | undefined;
41
+ /** Reactive bridged hardware state. */
42
+ readonly hardware: HardwareStore;
43
+ /** The game's declared leaderboard boards (catalog from the manifest). */
44
+ readonly boards: readonly BoardDefinition[];
45
+ /** The run this session is recorded under (links score/run to the FIT activity). */
46
+ readonly runId: string;
47
+ /** The `rydr` backend host — used to open realtime room WebSockets. */
48
+ readonly dataHost: string;
49
+ /** Tell the shell the game has loaded and is ready to be shown. */
50
+ ready(): void;
51
+ /** Report load progress (0–100) for the shell's loading affordance. */
52
+ reportLoadProgress(progress: number): void;
53
+ /** Request grade-based resistance on the trainer. */
54
+ setSimulation(gradePercent: number): void;
55
+ /** Request power-based (ERG) resistance on the trainer. */
56
+ setTargetPower(watts: number): void;
57
+ /** Toggle ERG mode. */
58
+ setErgMode(enabled: boolean): void;
59
+ /**
60
+ * Submit a score to a leaderboard `boardId` (must be one of {@link boards}).
61
+ * The shell performs the authenticated write (stamping playerId + runId).
62
+ * Resolves with the player's rank/PB after the submit, for a results screen.
63
+ * `opts.key` selects a parameterized board family member (e.g. per-track).
64
+ */
65
+ submitScore(boardId: string, value: number, opts?: {
66
+ key?: string;
67
+ }): Promise<SubmitScoreResult>;
68
+ /** Read a leaderboard page (top-N + the requester's own row). */
69
+ getLeaderboard(boardId: string, opts?: {
70
+ key?: string;
71
+ limit?: number;
72
+ }): Promise<LeaderboardPage>;
73
+ /** Save an opaque, game-specific run breakdown against this session's runId. Fire-and-forget. */
74
+ saveRun(breakdown: unknown): void;
75
+ /** Read back an opaque run breakdown by `runId` (e.g. a leaderboard entry's `runId`). `null` if absent. */
76
+ getRun(runId: string): Promise<unknown | null>;
77
+ /**
78
+ * Save a replay/ghost: an array of {@link ReplayFrame} (`{ t, power, customData }`) keyed by
79
+ * `runId` (default the session's own {@link runId}). The SDK packs the frames into a versioned,
80
+ * gzip+base64 blob and derives a {@link ReplayMeta} summary (duration + power) stored alongside it
81
+ * for cheap ghost lists. A replay aligns to a leaderboard entry through the shared `runId`, so it
82
+ * can be fetched back as the ghost for that standing.
83
+ */
84
+ saveReplay(runId: string, frames: ReplayFrame[], opts?: {
85
+ version?: number;
86
+ }): Promise<void>;
87
+ /**
88
+ * Fetch the replays for a board's top entries (the "ghosts"). Reads the leaderboard page, then
89
+ * fetches each ranked entry's stored blob + meta. `opts.key` selects a parameterized board member
90
+ * (e.g. per-track); `opts.top` bounds how many entries (default 10). Entries without a stored
91
+ * replay come back with `blob: null` and `meta: null`. This does NOT decode frames — use the
92
+ * `meta` for display and {@link getReplay} (or `decodeReplay`) when you need the timeline.
93
+ */
94
+ getReplays(boardId: string, opts?: {
95
+ key?: string;
96
+ top?: number;
97
+ }): Promise<ReplayRef[]>;
98
+ /**
99
+ * Fetch and decode a single stored replay by `runId` (e.g. a leaderboard entry's `runId`, the
100
+ * session's own {@link runId}, or a shared-link id). Returns the decoded {@link ReplayBody} plus
101
+ * its {@link ReplayMeta}, or `null` if no replay was stored for that run.
102
+ */
103
+ getReplay(runId: string): Promise<{
104
+ body: ReplayBody;
105
+ meta: ReplayMeta | null;
106
+ } | null>;
107
+ /** Read dev-authored shared content (public). */
108
+ getContent(collection: string, id: string): Promise<GameDoc | null>;
109
+ /** List dev-authored shared content (public). */
110
+ listContent(collection: string): Promise<GameDoc[]>;
111
+ /** Read an owned doc. `scope` defaults to `player` (private); use `public` for UGC. */
112
+ getData(collection: string, id: string, opts?: {
113
+ scope?: "player" | "public";
114
+ }): Promise<GameDoc | null>;
115
+ /** List owned docs (`player`) or world-readable UGC (`public`). */
116
+ listData(collection: string, opts?: {
117
+ scope?: "player" | "public";
118
+ }): Promise<GameDoc[]>;
119
+ /** Write an owned doc. `scope` defaults to `player` (private); `public` = world-readable UGC. */
120
+ saveData(collection: string, id: string, value: unknown, opts?: {
121
+ scope?: "player" | "public";
122
+ }): Promise<void>;
123
+ /** Delete an owned doc. */
124
+ deleteData(collection: string, id: string, opts?: {
125
+ scope?: "player" | "public";
126
+ }): Promise<void>;
127
+ /** Author `shared` (official) content — charts, songs, levels. Requires author rights on this
128
+ * game (the platform checks the per-game author allowlist by your playerId). Used by in-game
129
+ * editors. */
130
+ saveContent(collection: string, id: string, value: unknown): Promise<void>;
131
+ /** Delete `shared` content (author rights required). */
132
+ deleteContent(collection: string, id: string): Promise<void>;
133
+ /**
134
+ * Get a presigned URL to upload a binary asset (e.g. an MP3) for `shared` content. Author
135
+ * rights required. Returns `{ uploadUrl, url }`: PUT the file to `uploadUrl` directly, then
136
+ * store the public `url` in a doc via {@link saveContent}.
137
+ */
138
+ getUploadUrl(opts: {
139
+ collection: string;
140
+ filename: string;
141
+ contentType?: string;
142
+ }): Promise<{
143
+ uploadUrl: string;
144
+ url: string;
145
+ }>;
146
+ /**
147
+ * Join a realtime room (presence + relay + opaque shared state + trusted peer telemetry). The
148
+ * shell owns the socket and relays for the game, so identity and telemetry can't be forged. With
149
+ * no shell-backed rooms (standalone dev), this returns a local loopback room.
150
+ */
151
+ joinRoom(roomId: string): RoomHandle;
152
+ /** Tell the shell the game's internal route changed (for URL projection). */
153
+ setRoute(path: string): void;
154
+ /** Ask the shell to show/hide its chrome (navbar). Default is visible. */
155
+ setChrome(visible: boolean): void;
156
+ /** Ask the shell to show/hide the trainerless power bar. Default is visible. */
157
+ setPowerBar(visible: boolean): void;
158
+ /** Ask the shell to exit the game (back to the launcher). */
159
+ requestExit(): void;
160
+ /** Ask the shell to surface its hardware (reconnect) UI. */
161
+ requestHardwareModal(): void;
162
+ /** Report a fatal error to the shell. */
163
+ reportError(message: string): void;
164
+ /** Subscribe to controller buttons. Returns an unsubscribe fn. */
165
+ onButton(cb: (e: ButtonEvent) => void): () => void;
166
+ /** Subscribe to shell-driven pause. Returns an unsubscribe fn. */
167
+ onPause(cb: () => void): () => void;
168
+ /** Subscribe to shell-driven resume. Returns an unsubscribe fn. */
169
+ onResume(cb: () => void): () => void;
170
+ /** Subscribe to scoped-identity changes. Returns an unsubscribe fn. */
171
+ onIdentityChange(cb: (identity: ScopedIdentity) => void): () => void;
172
+ /** Tear down listeners. */
173
+ dispose(): void;
174
+ }
175
+ /**
176
+ * Connect to the embedding platform shell.
177
+ *
178
+ * Sends `hello` (retrying until the shell answers, to survive a not-yet-ready
179
+ * parent) and resolves once `welcome` arrives, or rejects on `reject`/timeout.
180
+ */
181
+ export declare function connectToPlatform(options: ConnectOptions): Promise<PlatformSession>;
182
+ //# sourceMappingURL=PlatformClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PlatformClient.d.ts","sourceRoot":"","sources":["../../src/client/PlatformClient.ts"],"names":[],"mappings":"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"}