Add agent skill (SKILL.md + per-domain reference)
This commit is contained in:
@@ -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<TConfig extends Record<string, unknown>>`
|
||||||
|
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 <token>`, 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<PlayerProfile>` |
|
||||||
|
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` |
|
||||||
|
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` |
|
||||||
|
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerId, opts?)` | observable `SyncedState<ShopHandle[]>` |
|
||||||
|
| 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<T>`:
|
||||||
|
|
||||||
|
- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`,
|
||||||
|
`.error?: Error`
|
||||||
|
- `onChange(cb: (snapshot: SyncedSnapshot<T>) => void): () => void` — fires
|
||||||
|
immediately with the current snapshot; maps onto React `useSyncExternalStore`
|
||||||
|
- `load(): Promise<T>` — deduplicated, no-op when ready
|
||||||
|
- `reload(): Promise<T>` — 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
|
||||||
@@ -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<LoginViaDeviceResponse>
|
||||||
|
loginWithCustom(options: LoginWithCustomOptions): Promise<LoginViaCustomResponse>
|
||||||
|
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<string, unknown>; // 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 <accessToken>` 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).
|
||||||
@@ -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<GetBattlePassProgressResponse>
|
||||||
|
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
|
||||||
|
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
|
||||||
|
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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<string, unknown>; // 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<string, CatalogItem> | 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).
|
||||||
@@ -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<void>;
|
||||||
|
list(limit = 100): Promise<readonly RankEntry[]>; // 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`.
|
||||||
@@ -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<PlayerProfile>`:
|
||||||
|
|
||||||
|
```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").
|
||||||
@@ -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<Quest[]>
|
||||||
|
claim(questId: string): Promise<ClaimQuestResponse>
|
||||||
|
reportProgress(metric: string, amount: number): Promise<string[]> // 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.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Remote config — `client.remoteConfig`
|
||||||
|
|
||||||
|
`ConfigDomain<TConfig>` (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<string, unknown> {
|
||||||
|
player_speed: number;
|
||||||
|
feature_x: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new RudderClient<GameConfig>({ 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<string, RemoteConfig> | 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<string, unknown>` and `get()` returns `unknown`.
|
||||||
@@ -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<void>` 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<void> }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `StoreOfferEffect` — a store node became active
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
readonly store: ShopHandle;
|
||||||
|
readonly offers: readonly OfferHandle[];
|
||||||
|
readonly message?: string;
|
||||||
|
buy(offer: OfferHandle, options?: BuyOptions): Promise<PurchaseOfferResponse>;
|
||||||
|
dismiss(): Promise<void>; // declines the offer and advances the run
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `LeaderboardEffect` — a leaderboard node became active
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ end(): Promise<void>; rewardClaimed(): Promise<void> }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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<Record<string, unknown>>;
|
||||||
|
reportProgress(objectiveId: string, amount?: number): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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<GetBattlePassProgressResponse>;
|
||||||
|
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
|
||||||
|
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
|
||||||
|
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
|
||||||
|
levelUp(): Promise<void>;
|
||||||
|
end(): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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<void> }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`).
|
||||||
@@ -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<void>` — upserts items, then
|
||||||
|
invalidates the domain so subscribers refetch.
|
||||||
|
- `delete(type: string): Promise<void>` — 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<void>` — 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.
|
||||||
@@ -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<PurchaseOfferResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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:<offerId>`, `purchase.item:<itemId>`) 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.
|
||||||
Reference in New Issue
Block a user