diff --git a/skills/rudder-web-sdk/SKILL.md b/skills/rudder-web-sdk/SKILL.md new file mode 100644 index 0000000..6b4a95b --- /dev/null +++ b/skills/rudder-web-sdk/SKILL.md @@ -0,0 +1,136 @@ +--- +name: rudder-web-sdk +description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/custom auth, observable domains (player, inventory, catalog, stores, remote config, storage), leaderboards, quests, battle pass, scenario effects, and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears. +--- + +# Rudder Web SDK (@rudder/js-sdk) + +Browser-facing player SDK for the Rudder LiveOps platform. Source of truth: +`liveops-web-sdk/src` (generated wire types in `src/generated` come from the +gateway's apigen — never edit them by hand). + +## Install + +Published to a private registry, not npmjs. Point the `@rudder` scope at it in +`.npmrc` (anonymous read, no token): + +``` +@rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/ +``` + +```bash +npm install @rudder/js-sdk +``` + +ESM + CJS + `.d.ts`; `type: module`; zero runtime dependencies. + +## Init + +```ts +import { RudderClient } from '@rudder/js-sdk'; + +const client = new RudderClient({ + baseUrl: 'https://api.rudder.build', // required + projectKey: 'your-project-key', // required + // tokenStore?: TokenStore — default: localStorage, in-memory fallback + // requestTimeoutMs?: number — default 10000 + // syncIntervalMs?: number — revision poll, default 30000 (±20% jitter) + // onEffectError?: (error) => void — default console.error + // runtime?: { planStateStore?, loginEvent? } — advanced, see reference/scenarios.md +}); +``` + +`client` is generic: `RudderClient>` +types `client.remoteConfig.get()`. Missing `baseUrl`/`projectKey` throws +`RudderError` with `code: 'sdk/invalid-options'` from the constructor. + +Call `client.dispose()` on unmount/HMR to stop the sync poll, wait timers, and +drop cached state. + +## Auth essentials + +```ts +await client.auth.loginWithDevice({ region, language, nickname }); // device id auto-generated/persisted +await client.auth.loginWithCustom({ customData, region, language, nickname }); +client.auth.logout(); +client.auth.isAuthenticated; // boolean +client.auth.onAuthStateChange(cb); // fires immediately, returns unsubscribe +``` + +Tokens are saved to the `TokenStore` automatically. The transport injects +`Authorization: Bearer `, retries GETs, and single-flight refreshes on +401; a failed refresh clears tokens and emits `signed-out`. Details: +[reference/auth.md](reference/auth.md). + +## Client surface (all domains that exist) + +Every member below is a property on `RudderClient`. There is no UGC, wallet +service, or standalone economy service in this SDK — wallets live on +`client.player.data.wallets`, and purchases go through `client.stores`. + +| Area | Access | Kind | +|---|---|---| +| Auth | `client.auth` | service | +| Player profile + wallets | `client.player` | observable `SyncedState` | +| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState` | +| Catalog | `client.catalog` | observable `SyncedState>` | +| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerId, opts?)` | observable `SyncedState` | +| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed | +| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations | +| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation | +| Leaderboards | `client.leaderboards.findBySlug(slug)` | service → cached handle | +| Battle pass | `client.battlePass` | plain service (no live sync) | +| Quests | `client.quests` | plain service (no live sync) | +| Scenario effects | `client.effects.on*` | event subscriptions | + +## Observable domains — the shared pattern + +`player`, `catalog`, `inventory`, `remoteConfig`, `storage`, +`projectStorage`, and `stores` all extend `SyncedState`: + +- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`, + `.error?: Error` +- `onChange(cb: (snapshot: SyncedSnapshot) => void): () => void` — fires + immediately with the current snapshot; maps onto React `useSyncExternalStore` +- `load(): Promise` — deduplicated, no-op when ready +- `reload(): Promise` — forced refetch + +`remoteConfig`, `player`, `stores`, and `catalog` are warmed once at login; +`inventory`, `storage`, and `projectStorage` load on first use. All observable +domains are then kept fresh by a revision poll every 30 s (±20% jitter); +polling pauses while the tab is hidden. Mutations (purchases, storage writes, +scenario callbacks) invalidate affected domains immediately. Call `reload()` +when you need a freshness guarantee. + +`leaderboards`, `battlePass`, and `quests` are NOT observable — refetch +explicitly after mutations. + +## Errors + +All SDK errors extend `RudderError` (optional machine-readable `code`): + +- `RudderNetworkError` — fetch failed / timeout (`error.cause` holds the original) +- `RudderHttpError` — non-2xx, carries `status`, `statusText`, `body`, `code` +- `RudderAuthError extends RudderHttpError` — 401 after failed refresh; tokens + already cleared, `signed-out` already emitted +- `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`) + — constructor validation + +Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`): +`early_completion`, `forbidden`, `level_not_reached`, `node_not_active`, +`objectives_incomplete`, `run_expired`, `run_not_active`, +`scenario_not_active`, `unknown_run`. Compare with +`error.code === RudderErrorCodes.runExpired`. + +## Reference files + +- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle +- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`) +- [reference/inventory.md](reference/inventory.md) — inventory domain, merged `InventoryItem`, catalog +- [reference/stores.md](reference/stores.md) — `ShopHandle`/`OfferHandle`, purchases, idempotency +- [reference/remote-config.md](reference/remote-config.md) — typed `get()`, value types, overrides +- [reference/storage.md](reference/storage.md) — player + project storage, limits +- [reference/leaderboards.md](reference/leaderboards.md) — handles, submit, list +- [reference/quests.md](reference/quests.md) — list/claim/reportProgress, `QuestMetrics` +- [reference/battlepass.md](reference/battlepass.md) — progress, XP, claims, premium +- [reference/scenarios.md](reference/scenarios.md) — effects, scenario runtime, wait/quest/battlepass nodes diff --git a/skills/rudder-web-sdk/reference/auth.md b/skills/rudder-web-sdk/reference/auth.md new file mode 100644 index 0000000..b887a2b --- /dev/null +++ b/skills/rudder-web-sdk/reference/auth.md @@ -0,0 +1,106 @@ +# Auth — `client.auth` + +`AuthService` (source: `src/auth/AuthService.ts`). Player authentication: +device ID login (primary for game clients), custom webhook login, logout, and +auth state observation. + +## Methods + +```ts +loginWithDevice(options?: LoginWithDeviceOptions): Promise +loginWithCustom(options: LoginWithCustomOptions): Promise +logout(): void +get isAuthenticated(): boolean +onAuthStateChange(listener: AuthStateListener): () => void +``` + +### `LoginWithDeviceOptions` + +```ts +{ + region?: string; // default 'global' + language?: string; // default 'en' + nickname?: string; // omitted from the request when not set +} +``` + +The device ID is auto-generated on first call and persisted in localStorage +(`src/device/DeviceId.ts`). The request also carries the client's +`projectKey`. + +### `LoginWithCustomOptions` + +```ts +{ + customData: Record; // required — forwarded to the project's custom auth webhook + region?: string; // default 'global' + language?: string; // default 'en' + nickname?: string; +} +``` + +### Login responses + +```ts +interface LoginViaDeviceResponse { accessToken?: string; refreshToken?: string } +interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string } +``` + +On success both tokens are saved to the client's `TokenStore`, the runtime +starts (domains warmed, scenario engine restored, `player_login` event fired), +and the state flips to `'signed-in'`. + +## Auth state + +```ts +type AuthState = 'signed-in' | 'signed-out'; +type AuthStateListener = (state: AuthState) => void; +``` + +- `isAuthenticated` is `true` while an access token is present in the token store. +- `onAuthStateChange` fires the listener **immediately** with the current state + and returns an unsubscribe function. +- `logout()` clears tokens, stops the runtime (sync poll, scenario runs, + cached domain data), and emits `'signed-out'`. + +## Token refresh (automatic, transport level) + +Source: `src/transport/request.ts`. + +- Every request injects `Authorization: Bearer ` when a token exists. +- On 401 the transport does a single-flight refresh against + `POST /sdk/v1/authorization/refresh` (concurrent 401s share one refresh) and + retries the original request once. +- If refresh fails, tokens are cleared, `onAuthStateChange` listeners get + `'signed-out'`, and the request throws `RudderAuthError`. + +## TokenStore + +```ts +interface TokenStore { + getAccessToken(): string | null; + getRefreshToken(): string | null; + saveTokens(accessToken: string, refreshToken: string): void; + clear(): void; +} +``` + +Factories (exported from the package root): + +- `createDefaultTokenStore()` — localStorage, with a silent in-memory fallback + where localStorage is unavailable (SSR, private mode). This is the default + when `tokenStore` is omitted from `RudderClientOptions`. +- `createLocalStorageTokenStore()` — keys `rudder_access_token` / + `rudder_refresh_token`. + +Provide a custom `TokenStore` via `RudderClientOptions.tokenStore` for other +backends (sessionStorage, cookies). + +## Errors + +- Constructor: missing `baseUrl`/`projectKey` → `RudderError`, + `code: 'sdk/invalid-options'`. +- Login failure → `RudderHttpError` (e.g. unknown project key) or + `RudderNetworkError`. +- Any later request with an expired session → `RudderAuthError` (after the + refresh attempt above fails). diff --git a/skills/rudder-web-sdk/reference/battlepass.md b/skills/rudder-web-sdk/reference/battlepass.md new file mode 100644 index 0000000..48a5f17 --- /dev/null +++ b/skills/rudder-web-sdk/reference/battlepass.md @@ -0,0 +1,87 @@ +# Battle pass — `client.battlePass` + +`BattlePassService` (source: `src/battlepass/BattlePassService.ts`). +Call-and-response access to battle pass endpoints. Battle pass state is tied to +a **scenario battle pass node**, so calls carry `scenarioId` + `nodeId` (plus +`runId` for mutating calls). NOT observable — re-fetch progress explicitly +after a mutation. + +In most games you do not call this service directly: a scenario battle pass +node surfaces through `client.effects.onBattlePass` with a session object that +wraps these calls (see `reference/scenarios.md`). Use the service directly when +you already know the scenario/node/run identifiers. + +## Methods + +```ts +getProgress(scenarioId: string, nodeId: string): Promise +addXp(request: AddBattlePassXpRequest): Promise +claimReward(request: ClaimBattlePassRewardRequest): Promise +purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise +``` + +## Request / response types + +```ts +interface AddBattlePassXpRequest { + amount?: number; + nodeId?: string; + runId?: string; + scenarioId?: string; + source?: string; // configured XP source +} +interface AddBattlePassXpResponse { + level?: number; + leveledUp?: boolean; + maxLevel?: boolean; + plan?: ExecutionPlan; // scenario plan continuation, handled by the runtime + xp?: number; +} + +interface ClaimBattlePassRewardRequest { + level?: number; + nodeId?: string; + runId?: string; + scenarioId?: string; + track?: 'free' | 'premium'; +} +interface ClaimBattlePassRewardResponse { + alreadyClaimed?: boolean; + error?: string; + granted?: Reward[]; // { amount?, currency?, itemId? } + success?: boolean; +} + +interface GetBattlePassProgressResponse { + claimedTiers?: ClaimedTier[]; // { level?, track? } + level?: number; + premiumOwned?: boolean; + xp?: number; +} + +interface PurchaseBattlePassPremiumRequest { + idempotencyKey?: string; + nodeId?: string; + runId?: string; + scenarioId?: string; +} +interface PurchaseBattlePassPremiumResponse { + error?: string; + plan?: ExecutionPlan; + success?: boolean; +} +``` + +## Semantics + +- `addXp` credits XP from a configured source; returns the new `xp`/`level` + plus `leveledUp` / `maxLevel` flags. +- `claimReward` claims a tier reward at a reached level; idempotent + server-side (`alreadyClaimed`). Claiming a tier above the current level (or + a premium tier without premium) fails server-side — check `success`/`error` + in the response and the typed error `code` (`RudderErrorCodes`). +- `purchasePremium` charges the player's wallet; idempotent; pass your own + `idempotencyKey` for safe retries. +- Mutations return `ExecutionPlan` continuations — when driving battle pass + manually you are responsible for the scenario run state; prefer the + `onBattlePass` effect session which handles this. diff --git a/skills/rudder-web-sdk/reference/inventory.md b/skills/rudder-web-sdk/reference/inventory.md new file mode 100644 index 0000000..d4211c4 --- /dev/null +++ b/skills/rudder-web-sdk/reference/inventory.md @@ -0,0 +1,56 @@ +# Inventory + catalog — `client.inventory`, `client.catalog` + +## Inventory + +`InventoryDomain` (source: `src/domains/InventoryDomain.ts`) — owned items +merged with their catalog entries. Synced under revision key `inventory`; +also refreshed whenever the catalog changes. + +```ts +client.inventory.data // InventoryItem[] | undefined +client.inventory.onChange(cb); +await client.inventory.load() / .reload(); +``` + +### `InventoryItem` (merged view, source: `src/state/inventory.ts`) + +```ts +interface InventoryItem { + slug: string; // '' when the wire item has no slug + amount: number; // 0 when absent on the wire + name: string; // nameOverride || catalog name || slug + properties: Record; // catalog properties + propertiesOverride (override wins) + tags: string[]; // from the catalog entry +} +``` + +The raw wire shape is `PlayerInventoryItem` +(`slug?, amount?, nameOverride?, propertiesOverride?, updatedAt?`) — you rarely +need it; the domain hands out the merged `InventoryItem`. + +## Catalog + +`CatalogDomain` (source: `src/domains/CatalogDomain.ts`) — the item catalog +keyed by slug. Synced under revision key `catalog`. + +```ts +client.catalog.data // Map | undefined + +interface CatalogItem { + name?: string; + properties?: { [key: string]: unknown }; + slug?: string; + tags?: string[]; +} +``` + +Items without a `slug` are skipped when the map is built. + +## Behavior notes + +- The catalog is warmed at login; inventory loads on first use, waits on the + catalog load, and merges, so `client.inventory.data` always has catalog + fields filled in. +- Invalidated after store purchases and scenario callbacks. +- Inventory has no client-side mutations — items change via purchases, quest / + battle pass rewards, and scenario nodes (all server-side). diff --git a/skills/rudder-web-sdk/reference/leaderboards.md b/skills/rudder-web-sdk/reference/leaderboards.md new file mode 100644 index 0000000..f0ec0e1 --- /dev/null +++ b/skills/rudder-web-sdk/reference/leaderboards.md @@ -0,0 +1,50 @@ +# Leaderboards — `client.leaderboards` + +`LeaderboardsService` (source: `src/leaderboards/LeaderboardsService.ts`). +Plain call-and-response service, NOT observable — refetch explicitly. + +## Surface + +```ts +const board = client.leaderboards.findBySlug('weekly-kills'); // cached handle +await board.submit(score); +const entries = await board.list(limit?); +board.getEntries(); +``` + +## `LeaderboardHandle` + +```ts +class LeaderboardHandle { + readonly slug: string; + + getEntries(): readonly RankEntry[]; // last fetched list, [] initially + submit(score: number): Promise; + list(limit = 100): Promise; // fetches and caches +} +``` + +- `findBySlug(slug)` caches handles per slug — repeated calls return the same + instance (and its cached entries). +- `list(limit)`: `limit <= 0` sends no limit to the server; default is 100. +- `submit` does not update the cached entries; call `list()` afterwards to see + the effect. + +## Types + +```ts +interface RankEntry { + playerId?: string; + playerName?: string; + rank?: number; + score?: number; +} +``` + +## Notes + +- No player-around-me or metadata endpoints are exposed by this SDK — submit + and top-N list only. +- Scenario `leaderboard` nodes surface through + `client.effects.onLeaderboard` (`end()`, `rewardClaimed()`) — see + `reference/scenarios.md`. diff --git a/skills/rudder-web-sdk/reference/player.md b/skills/rudder-web-sdk/reference/player.md new file mode 100644 index 0000000..afff3d7 --- /dev/null +++ b/skills/rudder-web-sdk/reference/player.md @@ -0,0 +1,53 @@ +# Player profile + wallets — `client.player` + +`PlayerDomain` (source: `src/domains/PlayerDomain.ts`) — the observable player +profile: identity plus currency wallets. Synced under revision key `profile`. + +There is no separate wallet service in this SDK. Wallet balances are read from +the profile and are credited/debited server-side (purchases, quest/battle pass +rewards, scenario nodes). + +## Surface + +`client.player` is a `SyncedState`: + +```ts +client.player.data // PlayerProfile | undefined +client.player.status // 'idle' | 'loading' | 'ready' | 'error' +client.player.onChange((snapshot) => { /* fires immediately */ }); +await client.player.load(); +await client.player.reload(); // force refetch +``` + +## Types + +```ts +interface PlayerProfile { + player?: Player; + wallets?: Wallet[]; +} + +interface Player { + createdAt?: string; + id?: string; + language?: string; + nickname?: string; + projectId?: string; + region?: string; +} + +interface Wallet { + balance?: number; + currency?: string; // currency code configured in the dashboard +} +``` + +## Behavior notes + +- Warmed automatically at login (one of the four parallel warm loads). +- Invalidated (refetched) after every successful store purchase and after + scenario server callbacks — subscribers see fresh balances without waiting + for the revision poll. +- All fields are optional on the wire (`?`); code defensively. +- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK + README "React recipe"). diff --git a/skills/rudder-web-sdk/reference/quests.md b/skills/rudder-web-sdk/reference/quests.md new file mode 100644 index 0000000..0c309fd --- /dev/null +++ b/skills/rudder-web-sdk/reference/quests.md @@ -0,0 +1,75 @@ +# Quests — `client.quests` + +`QuestsService` (source: `src/quests/QuestsService.ts`). The player's **global** +quests — list, claim, report metric progress. Plain call-and-response service, +NOT observable (no sync revision key): re-list after a claim or report. + +Distinct from scenario quest nodes, which advance through +`client.effects.onQuest` (see `reference/scenarios.md`). + +## Methods + +```ts +list(): Promise +claim(questId: string): Promise +reportProgress(metric: string, amount: number): Promise // ids of quests completed by this report +``` + +## Types + +```ts +interface Quest { + id?: string; + name?: string; + objectives?: QuestObjectiveProgress[]; + rewards?: Reward[]; + status?: 'active' | 'claimed' | 'completed'; +} + +interface QuestObjectiveProgress { + completed?: boolean; + current?: number; + metric?: string; + objectiveId?: string; + target?: number; +} + +interface ClaimQuestResponse { + alreadyClaimed?: boolean; + error?: string; + granted?: Reward[]; // { amount?, currency?, itemId? } + success?: boolean; +} +``` + +## Usage notes + +```ts +const quests = await client.quests.list(); +for (const quest of quests) { + if (quest.status === 'completed' && quest.id) { + const res = await client.quests.claim(quest.id); + // res.success / res.alreadyClaimed / res.granted + } +} +const completedIds = await client.quests.reportProgress('kills', 1); +``` + +- `claim` is idempotent server-side; check `success` / `alreadyClaimed` / + `error` in the response rather than relying on exceptions. +- Objective completion is judged server-side from metric reports. + +## `QuestMetrics` helpers + +Exported as a namespace: `import { QuestMetrics } from '@rudder/js-sdk'` +(source: `src/quests/QuestMetrics.ts`). + +```ts +QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack" +QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry" +``` + +Purchase metrics are reported automatically server-side by the store purchase +fan-out — these helpers exist so quest configs and client code name the format +consistently. Catalog counter slugs are reported via `reportProgress`. Custom +free-text metrics no longer progress quests — they no-op at runtime. diff --git a/skills/rudder-web-sdk/reference/remote-config.md b/skills/rudder-web-sdk/reference/remote-config.md new file mode 100644 index 0000000..ee3e395 --- /dev/null +++ b/skills/rudder-web-sdk/reference/remote-config.md @@ -0,0 +1,63 @@ +# Remote config — `client.remoteConfig` + +`ConfigDomain` (source: `src/domains/ConfigDomain.ts`, base class +`src/state/RemoteConfigState.ts`) — typed remote configuration as an +observable entity. Synced under revision key `config`. + +## Typed access + +```ts +interface GameConfig extends Record { + player_speed: number; + feature_x: boolean; +} + +const client = new RudderClient({ baseUrl, projectKey }); + +client.remoteConfig.get('player_speed', 200); // number +client.remoteConfig.get('feature_x'); // boolean | undefined +``` + +`get()` overloads: + +```ts +get(key): TConfig[key] | undefined; +get(key, defaultValue: TConfig[key]): TConfig[key]; +``` + +- Values are parsed synchronously from the loaded snapshot according to the + config's server-declared `valueType`. +- Returns the default value (or `undefined`) while not loaded, for unknown + keys, and when parsing fails. +- Parsing rules: `int`/`integer` → `parseInt`; `float`/`double`/`number` → + `parseFloat`; `bool`/`boolean` → `value === 'true'`; `json`/`object` → + `JSON.parse`; anything else → raw string. + +## Observable + +```ts +client.remoteConfig.data // Map | undefined +client.remoteConfig.onChange(cb); // fires immediately +await client.remoteConfig.reload(); +``` + +```ts +interface RemoteConfig { + active?: boolean; createdAt?: string; description?: string; + environment?: string; id?: string; key?: string; projectId?: string; + updatedAt?: string; value?: string; + valueType?: 'bool' | 'float' | 'int' | 'json' | 'string'; +} +``` + +Only configs that are not explicitly `active: false` enter the map (the server +already filters inactive configs out and may omit the flag). + +## Behavior notes + +- Warmed at login. +- Scenario `remote_config_override` nodes patch the local snapshot and fire + `client.effects.onConfigChanged({ key })` — the patched value is what + `get()` returns afterwards. +- Without a `TConfig` type argument, `RemoteConfigShape` defaults to + `Record` and `get()` returns `unknown`. diff --git a/skills/rudder-web-sdk/reference/scenarios.md b/skills/rudder-web-sdk/reference/scenarios.md new file mode 100644 index 0000000..34fcc4d --- /dev/null +++ b/skills/rudder-web-sdk/reference/scenarios.md @@ -0,0 +1,161 @@ +# Scenarios + effects — `client.effects` + +Scenarios are server-authored node graphs (configured in the dashboard) that +run per player. The SDK's scenario runtime is deliberately **not** part of the +public client surface: scenario nodes surface as typed effects through +`client.effects` (source: `src/effects/EffectsCenter.ts`), and the engine +itself loads lazily on first login. + +## Lifecycle + +- The runtime starts after login: scenario state is restored from persistence, + then the login event (`'player_login'` by default) is fired to trigger + event-driven scenarios. +- Runs persist to IndexedDB by default, so waits and pending nodes survive + page reloads. On restore, each run is reconciled with the server + (`unknown_run` / `expired` runs are dropped). +- `logout()` / `dispose()` clear all runs and persisted state. + +### Runtime options (`RudderClientOptions.runtime`) + +```ts +runtime?: { + // Scenario plan persistence. undefined = default IndexedDB store (created + // lazily); null = disable persistence entirely; or pass a PlanStateStore. + planStateStore?: PlanStateStore | null; + + // Scenario event fired automatically after login. + // undefined = 'player_login'; null = fire nothing. + loginEvent?: string | null; +} +``` + +## Subscribing + +Every `on*` method takes a handler `(effect) => void | Promise` and +returns an unsubscribe function. Errors thrown inside handlers go to the +`onEffectError` client option (default `console.error`). + +```ts +const off = client.effects.onNotification(async (n) => { + showToast(n.title, n.message); + await n.done(); // always resolve the session or the run stalls +}); +``` + +## Effect types + +```ts +interface Effects { + onNotification(handler): EffectUnsubscribe; + onStoreOffer(handler): EffectUnsubscribe; + onLeaderboard(handler): EffectUnsubscribe; + onConfigChanged(handler): EffectUnsubscribe; + onWait(handler): EffectUnsubscribe; + onScenarioCompleted(handler): EffectUnsubscribe; + onScenarioFailed(handler): EffectUnsubscribe; + onQuest(handler): EffectUnsubscribe; + onBattlePass(handler): EffectUnsubscribe; + onBattlePassLevel(handler): EffectUnsubscribe; +} +``` + +### `NotificationEffect` — a notification node became active + +```ts +{ readonly title: string; readonly message: string; done(): Promise } +``` + +### `StoreOfferEffect` — a store node became active + +```ts +{ + readonly store: ShopHandle; + readonly offers: readonly OfferHandle[]; + readonly message?: string; + buy(offer: OfferHandle, options?: BuyOptions): Promise; + dismiss(): Promise; // declines the offer and advances the run +} +``` + +### `LeaderboardEffect` — a leaderboard node became active + +```ts +{ end(): Promise; rewardClaimed(): Promise } +``` + +### `ConfigChangedEffect` — a `remote_config_override` node patched config + +```ts +{ readonly key: string } // client.remoteConfig.get(key) already returns the override +``` + +### `WaitEffect` — a wait node became active + +```ts +{ readonly deadlineUtc: Date } // run resumes automatically at the deadline +``` + +### `QuestEffect` — a scenario quest node became active + +```ts +{ + readonly name: string; + readonly objectives: ReadonlyArray>; + reportProgress(objectiveId: string, amount?: number): Promise; +} +``` + +The node auto-completes server-side once every objective is satisfied; the SDK +crosses the `onComplete` boundary itself when the server reports completion. + +### `BattlePassEffect` — a battle pass node became active + +```ts +{ + getProgress(): Promise; + addXp(source: string, amount: number): Promise; + claimReward(level: number, track?: 'free' | 'premium'): Promise; + purchasePremium(): Promise; + levelUp(): Promise; + end(): Promise; +} +``` + +These wrap `client.battlePass` with the run's `scenarioId`/`nodeId`/`runId` +already bound — prefer them over calling the service manually. + +### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier) + +```ts +{ readonly level: number; claim(): Promise } +``` + +### Run lifecycle effects + +```ts +interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioId: string } +interface ScenarioFailedEffect { + readonly runId: string; readonly scenarioId: string; readonly nodeId: string; + readonly error: Error; // transport gave up, or unsupported node type +} +``` + +Always subscribe to `onScenarioFailed` — otherwise run failures surface only +as console warnings. + +## Supported node types + +`wait`, `remote_config_override`, `notification`, `store`, `leaderboard`, +`quest`, `battlepass`, `battlepass_level`. Any other node type fails the run +(surfaced via `onScenarioFailed`). + +## Reliability notes + +- Node completion is idempotent client-side (completed handles are tracked). +- Transient boundary failures (network error, 5xx) leave the node active for + retry on reconnect; terminal server errors fail the run. +- Server scenario errors use the typed codes `run_expired`, `run_not_active`, + `node_not_active`, `scenario_not_active`, `unknown_run`, + `early_completion`, `objectives_incomplete`, `level_not_reached`, + `forbidden` (`RudderErrorCodes`). diff --git a/skills/rudder-web-sdk/reference/storage.md b/skills/rudder-web-sdk/reference/storage.md new file mode 100644 index 0000000..b690c8d --- /dev/null +++ b/skills/rudder-web-sdk/reference/storage.md @@ -0,0 +1,69 @@ +# Storage — `client.storage` + `client.projectStorage` + +Two key/value stores: per-player (`StorageDomain`, revision key `storage`) and +project-wide shared (`ProjectStorageDomain`, revision key `projectStorage`). +Sources: `src/domains/StorageDomain.ts`, `src/domains/ProjectStorageDomain.ts`. + +## Player storage + +```ts +client.storage.data // GetStorageResponse | undefined +await client.storage.save(items); +await client.storage.delete(type); +``` + +```ts +interface GetStorageResponse { + items?: StorageItem[]; + nextCursor?: string; +} +interface StorageItem { + data?: string; // opaque payload, JSON-stringify yourself if needed + id?: string; + type?: string; // the storage "collection" key +} +``` + +- `save(items: StorageItem[]): Promise` — upserts items, then + invalidates the domain so subscribers refetch. +- `delete(type: string): Promise` — deletes **all** player storage items + of that type, then invalidates. + +## Project storage + +```ts +client.projectStorage.data // GetProjectStorageResponse | undefined +await client.projectStorage.save(items); +``` + +```ts +interface GetProjectStorageResponse { + items?: ProjectStorageItem[]; + nextCursor?: string; +} +interface ProjectStorageItem { + data?: string; + expiresAt?: string; + id?: string; + readPermission?: 'public' | 'serverOnly'; + size?: number; + type?: string; + updatedAt?: string; + version?: number; + writePermission?: 'public' | 'serverOnly'; +} +interface ProjectStorageUpdateItem { data?: string; type?: string } +``` + +- `save(items: ProjectStorageUpdateItem[]): Promise` — upserts, then + invalidates. Writing is only possible for items whose `writePermission` is + `public`; `serverOnly` items are read-only for clients. +- The SDK exposes no client-side project-storage delete. + +## Limits and notes + +- Both domains load with `{ limit: 100 }` — the observable snapshot holds at + most 100 items and `nextCursor` pagination is not surfaced by the domain. +- `data` is a raw string on the wire; serialize/deserialize JSON yourself. +- Both are observable (`SyncedState`) and warmed by the revision poll only when + in use; mutations self-invalidate. diff --git a/skills/rudder-web-sdk/reference/stores.md b/skills/rudder-web-sdk/reference/stores.md new file mode 100644 index 0000000..c427153 --- /dev/null +++ b/skills/rudder-web-sdk/reference/stores.md @@ -0,0 +1,80 @@ +# Stores / purchases — `client.stores` + +`StoresDomain` (source: `src/domains/StoresDomain.ts`, handles in +`src/state/shops.ts`). The observable store list plus the purchase executor. +Synced under revision key `stores`. This is the SDK's economy surface — there +is no separate economy service. + +## Surface + +```ts +client.stores.data // ShopHandle[] | undefined +client.stores.onChange(cb); +await client.stores.load() / .reload(); + +// Direct purchase (bypassing handles): +await client.stores.purchase(storeSlug, offerId, options?); +``` + +## Handles + +```ts +class ShopHandle { + readonly slug: string; + readonly name?: string; + readonly description?: string; + readonly data?: { [key: string]: unknown }; + readonly offers: OfferHandle[]; +} + +class OfferHandle { + readonly id: string; + readonly name?: string; + readonly price?: OfferPrice; // { amount?: number; currency?: string } + readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[] + readonly maxPurchases?: number; + + buy(options?: BuyOptions): Promise; +} + +interface BuyOptions { idempotencyKey?: string } + +interface PurchaseOfferResponse { + error?: string; + purchaseId?: string; + success?: boolean; +} +``` + +## Purchase semantics + +- `stores.purchase(storeSlug, offerId, options?)` and `offer.buy(options?)` are + the same executor; handles just bind the slug/id. +- When `options.idempotencyKey` is omitted the SDK generates + `crypto.randomUUID()` per call — safe retries require passing your own key. +- On success the executor invalidates `player`, `inventory`, and `stores`, so + subscribers observe fresh wallet/inventory/store data immediately. +- Check `response.success` / `response.error` — a failed purchase is a resolved + response, not necessarily a thrown error. +- Purchase metrics (`purchase.offer:`, `purchase.item:`) are + reported to quests automatically server-side — do not report them manually + (see `reference/quests.md`). + +## Wire types + +```ts +interface Store { + createdAt?: string; data?: { [key: string]: unknown }; description?: string; + environment?: string; id?: string; name?: string; offers?: Offer[]; + projectId?: string; scenarioId?: string; slug?: string; status?: string; + updatedAt?: string; +} +interface Offer { + contents?: OfferContent[]; createdAt?: string; id?: string; + maxPurchases?: number; name?: string; price?: OfferPrice; updatedAt?: string; +} +``` + +`ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store +without `slug` / an offer without `id` — in practice the domain only builds +handles from server data that has both.