feat: modules and resources domains, drop removed domains, regenerated models

This commit is contained in:
edmand46
2026-09-17 12:08:03 +03:00
parent f45261439f
commit 6f37b00fb4
71 changed files with 516 additions and 4184 deletions
+36 -28
View File
@@ -1,38 +1,46 @@
# Changelog
## Unreleased
- Login and token refresh send `platform: 'web'` and, when the new optional
`appVersion` client option is set, `appVersion`. The backend stores them on
the player for segment rules (`player.platform`, `player.appVersion`).
## 2.0.0
Breaking change — major bump, required by the backend environments release.
Every project now has exactly two environments, `staging` and `prod`, and the
SDK key passed as `projectKey` decides which one the player belongs to. The
environment never appears in the client API: it is resolved at login and
carried inside the access and refresh tokens. Tokens issued before this
release have no environment claim and are rejected with 401, so the first call
after the backend upgrade refreshes, fails, clears the token store and emits
Breaking change — kernel surface. Module-specific client methods are removed.
The SDK now exposes the kernel only: auth, player (including wallets), catalog
currencies, storage, project storage, revision sync,
`client.modules.call(module, fn, args)`, and `client.resources(slug)`.
### Removed
- `client.battlePass` / `BattlePassService`
- `client.quests` / `QuestsService` / `QuestMetrics`
- `client.leaderboards` / `LeaderboardsService` / `LeaderboardHandle`
- `client.stores` / `StoresDomain` / `ShopHandle` / `OfferHandle`
- `client.inventory` / `InventoryDomain`
- `client.remoteConfig` / `ConfigDomain` / `RemoteConfigState`
- Scenario runtime (`ScenarioService`, login-event trigger, pending poll)
- Module-specific effects: `onStoreOffer`, `onLeaderboard`, `onQuest`,
`onBattlePass`, `onBattlePassLevel`, `onConfigChanged`
- Generated wire types for battle pass, quests, stores, inventory,
leaderboards, remote config, and scenarios
Call module functions with `client.modules.call(module, fn, args)` and read or
write resource rows with `client.resources(slug)` (`list` / `get` / `create` /
`update` / `delete`).
Also required by the backend environments release: every project now has
exactly two environments, `staging` and `prod`, and the SDK key passed as
`projectKey` decides which one the player belongs to. The environment never
appears in the client API: it is resolved at login and carried inside the
access and refresh tokens. Tokens issued before this release have no
environment claim and are rejected with 401, so the first call after the
backend upgrade refreshes, fails, clears the token store and emits
`'signed-out'` to every `onAuthStateChange` listener. Log the player in again
with `client.auth.loginWithDevice()` / `loginWithCustom()`.
Quests, scenarios and offers are addressed by their slug instead of their id,
because ids differ between staging and prod while slugs are stable. Renamed
accordingly: `Quest.id` is now `Quest.slug`, `client.quests.claim()` takes a
quest slug, `reportProgress()` resolves to the slugs of the completed quests
(`ReportQuestProgressResponse.completedQuestSlugs`),
`client.stores.purchase(storeSlug, offerSlug, options?)` takes an offer slug
and `OfferHandle` carries a `slug` alongside its `id`,
`QuestMetrics.purchaseOffer(offerSlug)` builds its metric from the offer slug,
and every scenario-scoped type exposes `scenarioSlug` instead of `scenarioId` —
`PendingEffect`, `ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle
pass requests and `client.battlePass.getProgress(scenarioSlug, nodeId)`.
`offer.buy()` is unchanged; it now binds the slug for you. Leaderboards, items
and stores already used slugs and are untouched.
Also shipped here, previously committed but never published: the effects client
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run
that disappears server-side (finished elsewhere, expired after a promote) now
emits `onScenarioCompleted` instead of lingering; and `run_not_active` joins
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
emits `onScenarioFailed`.
## 1.0.0
- Scenario execution moved server-side. The SDK no longer walks a local DAG
+20 -93
View File
@@ -1,8 +1,7 @@
# @rudder/js-sdk
LiveOps Web SDK for browser games — device auth, observable player state,
stores, remote config, leaderboards, quests, battle pass, and a scenario runtime
surfaced as typed effects.
catalog currencies, storage, generic resources, and module function calls.
## Install
@@ -29,22 +28,18 @@ const client = new RudderClient({
projectKey: 'your-project-key',
// tokenStore is optional: defaults to localStorage with an in-memory
// fallback where localStorage is unavailable (SSR, private mode).
appVersion: '1.4.2', // optional; sent with platform 'web' on login and refresh
});
await client.auth.loginWithDevice(); // region 'global', language 'en'
// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' });
// Observable domains — read `.data`, subscribe with `onChange`, force `reload()`.
const profile = client.player.data;
const speed = client.remoteConfig.get('player_speed', 200);
const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance;
client.stores.onChange(({ data: stores }) => renderStores(stores ?? []));
const items = await client.resources('items').list();
const result = await client.modules.call('quests', 'list');
// Scenario-driven UI arrives through effects.
client.effects.onStoreOffer((offer) => showOffer(offer));
client.effects.onScenarioFailed(({ error }) => console.error('scenario failed', error));
// Tear down background work (sync poll, wait timers) on unmount / HMR.
client.dispose();
```
@@ -54,75 +49,15 @@ client.dispose();
|---|---|
| Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
| Player / wallets | `client.player` (observable) |
| Inventory | `client.inventory` (observable, catalog-merged) |
| Catalog | `client.catalog` (observable) |
| Stores | `client.stores` (observable) + `client.stores.purchase(slug, offerId)` |
| Remote config | `client.remoteConfig.get(key, default)` (observable, typed) |
| Catalog currencies | `client.catalog` (observable) |
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
| Leaderboards | `client.leaderboards.findBySlug(slug)` → `handle.submit(score)` / `handle.list(limit?)` |
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) |
| Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` |
| Scenario effects | `client.effects.on*` |
| Project storage | `client.projectStorage` (observable) + `.save(items)` |
| Modules | `client.modules.call(module, fn, args?)` |
| Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` |
Type the remote config for `client.remoteConfig`:
```ts
interface GameConfig extends Record<string, unknown> {
player_speed: number;
feature_x: boolean;
}
const client = new RudderClient<GameConfig>({ /* … */ });
client.remoteConfig.get('player_speed', 200); // number
```
## Quests
`client.quests` covers the player's global quests — list with per-objective
progress, claim, and metric reports. These are distinct from scenario quest
nodes, which advance through the `onQuest` effect's `QuestSession`. Global
quests have no live sync: re-list after a claim or report.
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.id) {
await client.quests.claim(quest.id);
}
}
// Custom metrics advance matching objectives server-side; the call returns
// the ids of quests completed by this report.
const completedIds = await client.quests.reportProgress('kills', 1);
```
Purchase metrics are reported automatically when a purchase goes through
`client.stores`; the `QuestMetrics` helpers name the format so quest configs
and client code agree on it:
```ts
import { QuestMetrics } from '@rudder/js-sdk';
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
## Scenario effects
The scenario runtime is not exposed directly; scenario nodes surface through
`client.effects`:
- `onNotification`, `onStoreOffer`, `onLeaderboard`, `onConfigChanged`
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
- `onScenarioCompleted`, `onScenarioFailed`
The server executes the scenario graph. The SDK is a thin effects client
(trigger, pending poll, callbacks) loaded lazily on first login — a client
that only reads domains never pulls it into the page. `onConfigChanged`
stays on the surface, but scenario `remote_config_override` nodes are
applied server-side and no longer emit that effect.
Errors thrown inside effect handlers are reported via the `onEffectError`
client option (default: `console.error`).
Game-design features (quests, stores, inventory, leaderboards, battle pass,
remote config) live in installed modules. Call them through
`client.modules.call` and read their rows through `client.resources(slug)`.
## Error handling
@@ -135,7 +70,6 @@ import {
RudderNetworkError,
RudderHttpError,
RudderAuthError,
RudderErrorCodes,
} from '@rudder/js-sdk';
try {
@@ -146,9 +80,6 @@ try {
// cleared tokens and emitted 'signed-out' via onAuthStateChange.
showLogin();
} else if (error instanceof RudderHttpError) {
if (error.code === RudderErrorCodes.runExpired) {
// typed server error code
}
console.error(error.status, error.code, error.body);
} else if (error instanceof RudderNetworkError) {
// fetch failed / timeout — the underlying error is in error.cause
@@ -171,15 +102,14 @@ client.auth.onAuthStateChange((state) => {
## Data liveness model
Observable domains (`player`, `inventory`, `catalog`, `stores`, `remoteConfig`,
`storage`) are warmed once at login and then kept fresh by a revision poll:
the client asks the gateway for entity revisions every **30 s (±20% jitter)**
and reloads only the entities whose revision grew. Polling **pauses while the
tab is hidden** and resumes on `visibilitychange`. Mutations (purchases,
storage writes, scenario callbacks) invalidate the affected domains
immediately, so subscribers see fresh data without waiting for the next poll.
Expect data to be eventually consistent within one poll interval; call
`reload()` when you need a guarantee.
Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are
warmed at login (`player` and `catalog`) or on first use, then kept fresh by
a revision poll: the client asks the gateway for entity revisions every
**30 s (±20% jitter)** and reloads only the entities whose revision grew.
Polling **pauses while the tab is hidden** and resumes on `visibilitychange`.
Storage writes invalidate the affected domain immediately. Expect data to be
eventually consistent within one poll interval; call `reload()` when you need
a guarantee.
## React recipe
@@ -202,9 +132,6 @@ function Wallet() {
}
```
The same pattern works for `client.remoteConfig`, `client.stores`, and
`client.auth.onAuthStateChange`.
## Development
```bash
+1 -1
View File
@@ -4,7 +4,7 @@
"publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
},
"description": "LiveOps Web SDK for browser games — player authentication, leaderboards, stores, remote config, and typed runtime effects",
"description": "LiveOps Web SDK for browser games — player authentication, catalog currencies, storage, generic resources, and module function calls",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
+30 -45
View File
@@ -1,6 +1,6 @@
---
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/google/apple/custom auth, identity linking, 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.
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/google/apple/custom auth, identity linking, observable kernel domains (player, catalog, storage), modules.call, resources(slug), and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears.
---
# Rudder Web SDK (@rudder/js-sdk)
@@ -9,6 +9,11 @@ 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).
2.0 kernel surface: auth, player (wallets), catalog currencies, storage,
project storage, revision sync, `client.modules.call(module, fn, args)`,
and `client.resources(slug)`. Module-specific methods (battle pass, quests,
leaderboards, stores, inventory, remote config, scenarios) are gone.
## Install
Published to a private registry, not npmjs. Point the `@rudder` scope at it in
@@ -36,16 +41,14 @@ const client = new RudderClient({
// requestTimeoutMs?: number — default 10000
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
// onEffectError?: (error) => void — default console.error
// runtime?: { 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.
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.
Call `client.dispose()` on unmount/HMR to stop the sync poll and drop cached
state.
## Auth essentials
@@ -54,14 +57,13 @@ await client.auth.loginWithDevice({ region, language, nickname }); // device id
await client.auth.loginWithGoogle({ idToken, region, language, nickname });
await client.auth.loginWithApple({ idToken, region, language, nickname });
await client.auth.loginWithCustom({ customData, provider?, region, language, nickname });
// identity linking (requires an active session):
await client.auth.linkWithGoogle(idToken);
await client.auth.linkWithApple(idToken);
await client.auth.linkWithCustom({ customData, provider? });
await client.auth.unlinkIdentity(provider); // 'device' | 'google' | 'apple' | 'custom' | 'custom:<name>'
client.auth.logout();
client.auth.isAuthenticated; // boolean
client.auth.onAuthStateChange(cb); // fires immediately, returns unsubscribe
client.auth.isAuthenticated;
client.auth.onAuthStateChange(cb);
```
Linked identities are listed on the profile: `client.player.data.identities`
@@ -72,31 +74,28 @@ Tokens are saved to the `TokenStore` automatically. The transport injects
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`.
## Client surface
| 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, offerSlug, opts?)` | observable `SyncedState<ShopHandle[]>` |
| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
| Catalog currencies | `client.catalog` | observable `SyncedState<Map<string, CatalogCurrency>>` |
| 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 |
| Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionResponse` |
| Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` |
There is no UGC, wallet service, or standalone economy service — wallets live
on `client.player.data.wallets`. Quests, stores, inventory, leaderboards,
battle pass, and remote config are installed modules: call
`client.modules.call('quests', 'list')` (etc.) and read rows with
`client.resources('items')`.
## Observable domains — the shared pattern
`player`, `catalog`, `inventory`, `remoteConfig`, `storage`,
`projectStorage`, and `stores` all extend `SyncedState<T>`:
`player`, `catalog`, `storage`, and `projectStorage` all extend
`SyncedState<T>`:
- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`,
`.error?: Error`
@@ -105,16 +104,12 @@ service, or standalone economy service in this SDK — wallets live on
- `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()`
`player` and `catalog` are warmed once at login; `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. Storage mutations invalidate their domain 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`):
@@ -126,21 +121,11 @@ All SDK errors extend `RudderError` (optional machine-readable `code`):
- `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`.
Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`) are
compared with `error.code === RudderErrorCodes.forbidden`.
## 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
+5 -5
View File
@@ -70,9 +70,9 @@ interface LoginViaAppleResponse { 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'`.
On success both tokens are saved to the client's `TokenStore`, player and
catalog are warmed, the revision poll starts, and the state flips to
`'signed-in'`.
## Identity linking
@@ -127,8 +127,8 @@ 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'`.
- `logout()` clears tokens, stops the revision poll, drops cached domain
data, and emits `'signed-out'`.
## Token refresh (automatic, transport level)
@@ -1,84 +0,0 @@
# 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 `scenarioSlug` + `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(scenarioSlug: 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;
scenarioSlug?: string;
source?: string; // configured XP source
}
interface AddBattlePassXpResponse {
level?: number;
leveledUp?: boolean;
maxLevel?: boolean;
xp?: number;
}
interface ClaimBattlePassRewardRequest {
level?: number;
nodeId?: string;
runId?: string;
scenarioSlug?: 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;
scenarioSlug?: string;
}
interface PurchaseBattlePassPremiumResponse {
error?: string;
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.
- Prefer the `onBattlePass` effect session, which binds `scenarioSlug` /
`nodeId` / `runId` and posts scenario callbacks for you.
@@ -1,56 +0,0 @@
# 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).
@@ -1,50 +0,0 @@
# 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`.
+5 -6
View File
@@ -4,8 +4,8 @@
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).
the profile and are credited/debited server-side (admin wallet adjust, module
functions).
## Surface
@@ -51,10 +51,9 @@ interface Wallet {
## 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.
- Warmed automatically at login (with catalog).
- Kept fresh by the revision poll; call `reload()` after a module function
that mutates wallets.
- All fields are optional on the wire (`?`); code defensively.
- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK
README "React recipe").
-75
View File
@@ -1,75 +0,0 @@
# 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(questSlug: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // slugs of quests completed by this report
```
## Types
```ts
interface Quest {
slug?: string; // stable across environments — what claim() takes
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.slug) {
const res = await client.quests.claim(quest.slug);
// res.success / res.alreadyClaimed / res.granted
}
}
const completedSlugs = 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.
@@ -1,63 +0,0 @@
# 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 are applied server-side and do not
reach the client. Reload or wait for the config sync poll to observe the
patched value via `get()`.
- Without a `TConfig` type argument, `RemoteConfigShape` defaults to
`Record<string, unknown>` and `get()` returns `unknown`.
@@ -1,166 +0,0 @@
# Scenarios + effects — `client.effects`
Scenarios are server-authored node graphs (configured in the dashboard) that
run per player. The server executes the graph. The SDK is a thin effects
client: it sends trigger events, turns `PendingEffect` payloads into typed
`client.effects` handlers, posts callbacks when the game completes an
effect, and polls for pending effects. The runtime is deliberately **not**
part of the public client surface (source: `src/effects/EffectsCenter.ts`)
and loads lazily on first login.
## Lifecycle
- After login the SDK fetches `GET /sdk/v1/scenarios/pending` (replacing any
local restore), then fires the login event (`'player_login'` by default).
- A heartbeat polls pending every ~30s (±20% jitter), paused while
`document.hidden`. Each effect with a `waitDeadline` also schedules a
local timer so waits fire on time.
- An effect with an already-active `(runId, nodeId)` is not re-emitted.
- `logout()` / `dispose()` stop the poll, cancel wait timers, and drop
active effects. There is no local plan persistence.
### Runtime options (`RudderClientOptions.runtime`)
```ts
runtime?: {
// 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;
}
```
`onConfigChanged` remains on the public surface but scenario
`remote_config_override` nodes no longer reach the client — the server
applies them. Patched values show up through `client.remoteConfig` after
sync/reload.
### `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>; claim(): Promise<void>; rewardClaimed(): Promise<void> }
```
`rewardClaimed()` is a deprecated alias for `claim()`.
### `WaitEffect` — a wait node became active
```ts
{ readonly deadlineUtc: Date } // SDK polls pending at the deadline; the server advances the run
```
### `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
counter response may carry the next `PendingEffect`.
### `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 `scenarioSlug`/`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 scenarioSlug: string }
interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioSlug: string; readonly nodeId: string;
readonly error: Error;
}
```
Always subscribe to `onScenarioFailed` — otherwise run failures surface only
as console warnings.
## Supported node types
`wait`, `notification`, `store`, `leaderboard`, `quest`, `battlepass`,
`battlepass_level`. Any other node type fails the run (surfaced via
`onScenarioFailed`). `remote_config_override` is applied server-side and is
not delivered to the client.
## Reliability notes
- Completion methods POST `/sdk/v1/scenarios/callback` with
`{scenarioSlug, runId, nodeId, handle}` using the handles `output`,
`onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`,
`onPremiumPurchase`.
- Transient callback failures (network error, 5xx) leave the effect active
for retry; `unknown_run` / `run_expired` drop that run's effects and fire
`onScenarioFailed`.
- 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`).
-82
View File
@@ -1,82 +0,0 @@
# 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, offerSlug, 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; // authoring id, differs between staging and prod
readonly slug: string; // stable across environments — what purchases use
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, offerSlug, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the store slug and the offer slug.
- 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:<offerSlug>`, `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; slug?: string;
updatedAt?: string;
}
```
`ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store
without `slug` / an offer without `id` or `slug` — in practice the domain only
builds handles from server data that has all of them.
+9 -2
View File
@@ -7,7 +7,6 @@
*/
import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
import { getOrCreateDeviceId } from '../device/DeviceId.js';
import { api } from '../generated/api.js';
import type {
@@ -72,7 +71,7 @@ export interface LinkWithCustomOptions {
provider?: string;
}
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
export class AuthService {
private readonly listeners = new Set<AuthStateListener>();
private state: AuthState;
@@ -118,6 +117,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region,
language,
nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
});
this.ctx.options.tokenStore.saveTokens(
@@ -145,6 +146,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region,
language,
nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
});
this.ctx.options.tokenStore.saveTokens(
@@ -169,6 +172,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region,
language,
nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
});
this.ctx.options.tokenStore.saveTokens(
@@ -193,6 +198,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region,
language,
nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
});
this.ctx.options.tokenStore.saveTokens(
-46
View File
@@ -1,46 +0,0 @@
/**
* BattlePassService — call-and-response access to the battle pass endpoints.
*
* Battle pass state is tied to a scenario battle pass node, so every call
* carries a `scenarioSlug` + `nodeId` (and a `runId` for the mutating calls).
* Battle pass has no sync-revision key, so this is a plain service, not an
* observable domain; re-fetch progress explicitly after a mutation.
*/
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type {
AddBattlePassXpRequest,
AddBattlePassXpResponse,
ClaimBattlePassRewardRequest,
ClaimBattlePassRewardResponse,
PurchaseBattlePassPremiumRequest,
PurchaseBattlePassPremiumResponse,
GetBattlePassProgressResponse,
} from '../generated/battlepass.js';
export class BattlePassService {
constructor(private readonly ctx: RudderContext) {}
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioSlug, nodeId });
}
/** Credits XP from a configured source. Returns the new xp/level. */
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse> {
return api.addBattlePassXp(this.ctx, request);
}
/** Claims a tier reward at a reached level (idempotent server-side). */
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse> {
return api.claimBattlePassReward(this.ctx, request);
}
/** Purchases the premium track (charges the wallet, idempotent). */
purchasePremium(
request: PurchaseBattlePassPremiumRequest,
): Promise<PurchaseBattlePassPremiumResponse> {
return api.purchaseBattlePassPremium(this.ctx, request);
}
}
+20 -112
View File
@@ -1,16 +1,12 @@
/**
* RudderClient — the main entry point for the LiveOps Web SDK.
*
* Composes the observable domains, the scenario runtime, and the core HTTP
* transport that every service uses to reach the LiveOps API gateway.
* Composes the kernel domains (auth, player, catalog, storage, modules,
* resources) and the HTTP transport every service uses to reach the API.
*
* The client builds a {@link RudderContext} first and injects it into every
* domain and service, so they depend on that narrow surface rather than on the
* client (which keeps the module graph acyclic). Cross-domain wiring (the
* inventory↔catalog link, purchase invalidation) is done here at construction.
*
* The scenario effects client is loaded lazily on first use — a client that
* never logs in never pays for the scenario machinery.
* domain, so they depend on that narrow surface rather than on the client
* (which keeps the module graph acyclic).
*/
import type {
@@ -22,45 +18,32 @@ import type { RudderContext } from '../core/context.js';
import { request, type RequestContext } from '../transport/request.js';
import { createDefaultTokenStore } from '../token/TokenStore.js';
import { AuthService } from '../auth/AuthService.js';
import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
import { BattlePassService } from '../battlepass/BattlePassService.js';
import { QuestsService } from '../quests/QuestsService.js';
import type { ScenarioService } from '../scenario/ScenarioService.js';
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
import { SyncEngine } from '../state/SyncEngine.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
import { PlayerDomain } from '../domains/PlayerDomain.js';
import { CatalogDomain } from '../domains/CatalogDomain.js';
import { InventoryDomain } from '../domains/InventoryDomain.js';
import { ConfigDomain } from '../domains/ConfigDomain.js';
import { StorageDomain } from '../domains/StorageDomain.js';
import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
import { StoresDomain } from '../domains/StoresDomain.js';
import { ModulesDomain } from '../domains/ModulesDomain.js';
import { ResourcesDomain, type ResourceHandle } from '../domains/ResourcesDomain.js';
type ManagedDomain = { invalidate(): void; reset(): void };
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
export class RudderClient {
public readonly options: ResolvedRudderClientOptions;
public readonly auth: AuthService<TConfig>;
public readonly leaderboards: LeaderboardsService;
public readonly battlePass: BattlePassService;
public readonly quests: QuestsService;
public readonly auth: AuthService;
public readonly effects: Effects;
public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain;
public readonly inventory: InventoryDomain;
public readonly remoteConfig: ConfigDomain<TConfig>;
public readonly storage: StorageDomain;
public readonly projectStorage: ProjectStorageDomain;
public readonly stores: StoresDomain;
public readonly modules: ModulesDomain;
private readonly resourcesDomain: ResourcesDomain;
private readonly effectsCenter: EffectsCenter;
private scenarioRuntime: ScenarioService | null = null;
private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
private readonly syncEngine: SyncEngine;
private readonly loginEvent: string | null;
private readonly ctx: RudderContext;
private readonly transportContext: RequestContext;
private runtimeStarted = false;
@@ -78,13 +61,12 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
}
this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() };
this.loginEvent =
options.runtime?.loginEvent === undefined ? 'player_login' : options.runtime.loginEvent;
this.transportContext = {
baseUrl: this.options.baseUrl,
tokenStore: this.options.tokenStore,
requestTimeoutMs: this.options.requestTimeoutMs,
appVersion: this.options.appVersion,
refreshState: {},
onAuthFailure: () => {
this.auth.notifySessionExpired();
@@ -105,29 +87,18 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.player = new PlayerDomain(ctx);
this.catalog = new CatalogDomain(ctx);
this.inventory = new InventoryDomain(ctx, this.catalog);
this.remoteConfig = new ConfigDomain<TConfig>(ctx);
this.storage = new StorageDomain(ctx);
this.projectStorage = new ProjectStorageDomain(ctx);
this.stores = new StoresDomain(ctx, () => {
this.player.invalidate();
this.inventory.invalidate();
this.stores.invalidate();
});
this.modules = new ModulesDomain(ctx);
this.resourcesDomain = new ResourcesDomain(ctx);
this.leaderboards = new LeaderboardsService(ctx);
this.battlePass = new BattlePassService(ctx);
this.quests = new QuestsService(ctx);
this.syncEngine = new SyncEngine(ctx, [
this.player,
this.catalog,
this.inventory,
this.remoteConfig,
this.storage,
this.projectStorage,
this.stores,
]);
this.auth = new AuthService<TConfig>(
this.auth = new AuthService(
ctx,
() => this.startRuntimeAfterLogin(),
() => this.stopRuntimeAfterLogout(),
@@ -136,81 +107,33 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
dispose(): void {
this.syncEngine.stop();
this.scenarioRuntime?.clear();
this.resetAll();
this.runtimeStarted = false;
}
resources(slug: string): ResourceHandle {
return this.resourcesDomain.of(slug);
}
/** @internal */
async startRuntimeAfterLogin(): Promise<void> {
const runtimePromise = this.ensureScenarioRuntime();
if (this.runtimeStarted) {
this.scenarioRuntime?.clear();
this.invalidateAll();
}
this.runtimeStarted = true;
await Promise.all([
this.remoteConfig.load(),
this.player.load(),
this.stores.load(),
this.catalog.load(),
]);
const runtime = await runtimePromise;
await runtime.start();
if (this.loginEvent) {
await this.sendRuntimeEvent(runtime, this.loginEvent);
}
await Promise.all([this.player.load(), this.catalog.load()]);
this.syncEngine.start();
}
private ensureScenarioRuntime(): Promise<ScenarioService> {
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
return this.scenarioRuntimePromise;
}
private async createScenarioRuntime(): Promise<ScenarioService> {
const { ScenarioService } = await import('../scenario/ScenarioService.js');
this.scenarioRuntime = new ScenarioService(
this.ctx,
{
player: this.player,
inventory: this.inventory,
stores: this.stores,
},
this.battlePass,
);
return this.scenarioRuntime;
}
private async sendRuntimeEvent(
runtime: ScenarioService,
event: string,
): Promise<void> {
try {
await runtime.send(event);
} catch (error) {
console.warn(`RudderClient: scenario event "${event}" failed after login`, error);
}
}
/** @internal */
stopRuntimeAfterLogout(): void {
this.runtimeStarted = false;
this.syncEngine.stop();
this.scenarioRuntime?.clear();
this.resetAll();
}
private get managedDomains(): ManagedDomain[] {
return [
this.player,
this.catalog,
this.inventory,
this.remoteConfig,
this.storage,
this.projectStorage,
this.stores,
];
return [this.player, this.catalog, this.storage, this.projectStorage];
}
private invalidateAll(): void {
@@ -221,18 +144,3 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
for (const domain of this.managedDomains) domain.reset();
}
}
/**
* Test/internal access to the scenario runtime, which is deliberately not part
* of the public client surface (it is driven through `client.effects`).
* Lazily loads the scenario effects client on first call.
*
* @internal
*/
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
client: RudderClient<TConfig>,
): Promise<ScenarioService> {
return (
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
).ensureScenarioRuntime();
}
+6 -7
View File
@@ -1,9 +1,5 @@
import type { TokenStore } from '../token/TokenStore.js';
export interface RudderRuntimeOptions {
loginEvent?: string | null;
}
export interface RudderClientOptions {
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
baseUrl: string;
@@ -18,6 +14,12 @@ export interface RudderClientOptions {
*/
tokenStore?: TokenStore;
/**
* Game build version sent as `appVersion` on every login and token refresh
* (used by segments). Omitted when not set.
*/
appVersion?: string;
/** Per-request timeout in milliseconds (default: 10000). */
requestTimeoutMs?: number;
@@ -29,9 +31,6 @@ export interface RudderClientOptions {
* Defaults to `console.error`.
*/
onEffectError?: (error: unknown) => void;
/** Advanced runtime knobs (login event). */
runtime?: RudderRuntimeOptions;
}
/** Client options with all defaults resolved — what `client.options` exposes. */
+4 -6
View File
@@ -3,15 +3,13 @@
*
* All errors extend `RudderError`. Network errors, HTTP errors, and
* authentication errors have dedicated subclasses for granular handling.
* The machine-readable `code` is the server-provided {@link RudderErrorCode}
* when known, or any other string for unknown servers / SDK-local codes
* The machine-readable `code` is the server-provided code (open-ended: platform
* codes and module business error codes) or an SDK-local code
* (e.g. `sdk/invalid-options` for client configuration errors).
*/
import type { RudderErrorCode } from '../generated/errors.js';
/** Code carried by SDK errors: a known server code, or any other string. */
export type RudderErrorCodeLike = RudderErrorCode | (string & {});
/** Code carried by SDK errors: a server code or an SDK-local code. */
export type RudderErrorCodeLike = string;
/** Code for client-side configuration validation failures. */
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
+9 -9
View File
@@ -1,22 +1,22 @@
import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { CatalogItem } from '../generated/catalog.js';
import type { CatalogCurrency } from '../generated/catalog.js';
/**
* CatalogDomain — the item catalog, keyed by slug. Synced under revision
* key `catalog`. Feeds the merged {@link InventoryDomain} view.
* CatalogDomain — currency definitions, keyed by slug. Synced under revision
* key `catalog`.
*/
export class CatalogDomain extends SyncedState<Map<string, CatalogItem>> {
export class CatalogDomain extends SyncedState<Map<string, CatalogCurrency>> {
readonly syncKey = 'catalog';
constructor(ctx: RudderContext) {
super(async () => {
const response = await api.listCatalogItems(ctx);
const map = new Map<string, CatalogItem>();
for (const item of response.items ?? []) {
if (!item.slug) continue;
map.set(item.slug, item);
const response = await api.listCatalogCurrencies(ctx);
const map = new Map<string, CatalogCurrency>();
for (const currency of response.currencies ?? []) {
if (!currency.slug) continue;
map.set(currency.slug, currency);
}
return map;
});
-33
View File
@@ -1,33 +0,0 @@
import { RemoteConfigState, type RemoteConfigShape } from '../state/RemoteConfigState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { RemoteConfig } from '../generated/remote-config.js';
/**
* ConfigDomain — typed remote configuration as an observable entity. Synced
* under revision key `config`. Extends {@link RemoteConfigState}, which adds
* typed `get()` and scenario override support.
*/
export class ConfigDomain<
TConfig extends RemoteConfigShape = RemoteConfigShape,
> extends RemoteConfigState<TConfig> {
readonly syncKey = 'config';
constructor(ctx: RudderContext) {
super(async () => {
const response = await api.listSdkRemoteConfigs(ctx);
const map = new Map<string, RemoteConfig>();
if (response.configs) {
for (const [key, config] of Object.entries(response.configs)) {
// The server already filters inactive configs out (the game cache skips
// !active) and may omit `active` on the wire, so keep anything that is
// not explicitly inactive instead of dropping configs that carry no flag.
if (config && config.active !== false) {
map.set(config.key ?? key, config);
}
}
}
return map;
});
}
}
-31
View File
@@ -1,31 +0,0 @@
import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import { mergeInventory, type InventoryItem } from '../state/inventory.js';
import type { CatalogDomain } from './CatalogDomain.js';
/**
* InventoryDomain — owned items merged with their catalog entries. Synced
* under revision key `inventory`; also refreshes when the catalog changes.
*/
export class InventoryDomain extends SyncedState<InventoryItem[]> {
readonly syncKey = 'inventory';
constructor(ctx: RudderContext, catalog: CatalogDomain) {
super(async () => {
const [response, catalogItems] = await Promise.all([
api.getInventory(ctx),
catalog.load(),
]);
return mergeInventory(response.items ?? [], catalogItems);
});
// Catalog changes flow into the merged inventory view.
let previousCatalog = catalog.data;
catalog.onChange((snapshot) => {
if (snapshot.data === previousCatalog) return;
previousCatalog = snapshot.data;
this.invalidate();
});
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { CallModuleFunctionClientResponse } from '../generated/modules.js';
export class ModulesDomain {
constructor(private readonly ctx: RudderContext) {}
call(module: string, fn: string, args?: unknown): Promise<CallModuleFunctionClientResponse> {
return api.callModuleFunction(this.ctx, { module, fn }, { args });
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type {
CreateResourceRowRequest,
ListResourceRowsResponse,
ResourceRow,
UpdateResourceRowRequest,
} from '../generated/resources.js';
export class ResourceHandle {
constructor(
private readonly ctx: RudderContext,
private readonly slug: string,
) {}
list(): Promise<ListResourceRowsResponse> {
return api.listResourceRows(this.ctx, { slug: this.slug });
}
get(id: string): Promise<ResourceRow> {
return api.getResourceRow(this.ctx, { slug: this.slug, id });
}
create(body: CreateResourceRowRequest): Promise<ResourceRow> {
return api.createResourceRow(this.ctx, { slug: this.slug }, body);
}
update(id: string, body: UpdateResourceRowRequest): Promise<ResourceRow> {
return api.updateResourceRow(this.ctx, { slug: this.slug, id }, body);
}
delete(id: string): Promise<void> {
return api.deleteResourceRow(this.ctx, { slug: this.slug, id });
}
}
export class ResourcesDomain {
constructor(private readonly ctx: RudderContext) {}
of(slug: string): ResourceHandle {
return new ResourceHandle(this.ctx, slug);
}
}
-62
View File
@@ -1,62 +0,0 @@
import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import { ShopHandle, type BuyOptions, type PurchaseOffer } from '../state/shops.js';
import type { PurchaseOfferResponse } from '../generated/stores.js';
/**
* StoresDomain — the observable list of stores, plus the purchase executor
* that every offer/store handle shares. Synced under revision key `stores`.
*
* A successful purchase runs `onPurchase`, which the client wires to invalidate
* the profile, inventory, and store list so subscribers observe fresh data.
*/
export class StoresDomain extends SyncedState<ShopHandle[]> {
readonly syncKey = 'stores';
/** Shared purchase executor: `stores.purchase(slug, offerSlug, opts)`. */
readonly purchase: PurchaseOffer;
private readonly ctx: RudderContext;
constructor(ctx: RudderContext, onPurchase: () => void) {
// Built before super() so it captures params, not `this`; the store/offer
// handles created in the loader all delegate to this one executor.
const purchase: PurchaseOffer = async (
storeSlug: string,
offerSlug: string,
options: BuyOptions = {},
): Promise<PurchaseOfferResponse> => {
const response = await api.purchaseOffer(
ctx,
{ storeSlug, offerSlug },
{
storeSlug,
offerSlug,
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
},
);
onPurchase();
return response;
};
super(async () => {
const response = await api.listSdkStores(ctx);
return (response.stores ?? []).map((store) => new ShopHandle(purchase, store));
});
this.ctx = ctx;
this.purchase = purchase;
}
/**
* Resolves a store by slug (cache-first). Used by scenario store nodes.
* @internal
*/
async getBySlug(slug: string): Promise<ShopHandle> {
const cached = this.data?.find((store) => store.slug === slug);
if (cached) return cached;
const store = await api.getStore(this.ctx, { slug });
return new ShopHandle(this.purchase, store);
}
}
-116
View File
@@ -1,12 +1,3 @@
import type { PurchaseOfferResponse } from '../generated/stores.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
GetBattlePassProgressResponse,
PurchaseBattlePassPremiumResponse,
} from '../generated/battlepass.js';
export type EffectUnsubscribe = () => void;
export interface NotificationEffect {
@@ -15,25 +6,6 @@ export interface NotificationEffect {
done(): Promise<void>;
}
export interface StoreOfferEffect {
readonly store: ShopHandle;
readonly offers: readonly OfferHandle[];
readonly message?: string;
buy(offer: OfferHandle, options?: BuyOptions): Promise<PurchaseOfferResponse>;
dismiss(): Promise<void>;
}
export interface LeaderboardEffect {
end(): Promise<void>;
claim(): Promise<void>;
/** @deprecated Use {@link LeaderboardEffect.claim}. Removed in the next SDK version. */
rewardClaimed(): Promise<void>;
}
export interface ConfigChangedEffect {
readonly key: string;
}
export interface WaitEffect {
readonly deadlineUtc: Date;
}
@@ -50,52 +22,20 @@ export interface ScenarioFailedEffect {
readonly error: Error;
}
export interface QuestEffect {
readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>;
}
export interface BattlePassEffect {
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>;
}
export interface BattlePassLevelEffect {
readonly level: number;
claim(): Promise<void>;
}
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
export interface Effects {
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe;
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe;
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe;
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe;
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe;
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe;
}
export class EffectsCenter implements Effects {
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
private readonly storeOfferHandlers = new Set<EffectHandler<StoreOfferEffect>>();
private readonly leaderboardHandlers = new Set<EffectHandler<LeaderboardEffect>>();
private readonly configChangedHandlers = new Set<EffectHandler<ConfigChangedEffect>>();
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
private readonly questHandlers = new Set<EffectHandler<QuestEffect>>();
private readonly battlePassHandlers = new Set<EffectHandler<BattlePassEffect>>();
private readonly battlePassLevelHandlers = new Set<EffectHandler<BattlePassLevelEffect>>();
constructor(
private readonly onError: (error: unknown) => void = (error) =>
@@ -106,18 +46,6 @@ export class EffectsCenter implements Effects {
return this.addHandler(this.notificationHandlers, handler);
}
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe {
return this.addHandler(this.storeOfferHandlers, handler);
}
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe {
return this.addHandler(this.leaderboardHandlers, handler);
}
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe {
return this.addHandler(this.configChangedHandlers, handler);
}
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
return this.addHandler(this.waitHandlers, handler);
}
@@ -130,40 +58,11 @@ export class EffectsCenter implements Effects {
return this.addHandler(this.scenarioFailedHandlers, handler);
}
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe {
return this.addHandler(this.questHandlers, handler);
}
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe {
return this.addHandler(this.battlePassHandlers, handler);
}
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe {
return this.addHandler(this.battlePassLevelHandlers, handler);
}
/** @internal */
emitNotification(effect: NotificationEffect): void {
this.emit(this.notificationHandlers, effect);
}
/** @internal */
emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
Promise.resolve(effect)
.then((value) => this.emit(this.storeOfferHandlers, value))
.catch((error) => this.onError(error));
}
/** @internal */
emitLeaderboard(effect: LeaderboardEffect): void {
this.emit(this.leaderboardHandlers, effect);
}
/** @internal */
emitConfigChanged(key: string): void {
this.emit(this.configChangedHandlers, { key });
}
/** @internal */
emitWait(effect: WaitEffect): void {
this.emit(this.waitHandlers, effect);
@@ -179,21 +78,6 @@ export class EffectsCenter implements Effects {
this.emit(this.scenarioFailedHandlers, effect);
}
/** @internal */
emitQuest(effect: QuestEffect): void {
this.emit(this.questHandlers, effect);
}
/** @internal */
emitBattlePass(effect: BattlePassEffect): void {
this.emit(this.battlePassHandlers, effect);
}
/** @internal */
emitBattlePassLevel(effect: BattlePassLevelEffect): void {
this.emit(this.battlePassLevelHandlers, effect);
}
private addHandler<TEffect>(
handlers: Set<EffectHandler<TEffect>>,
handler: EffectHandler<TEffect>,
+33 -111
View File
@@ -1,17 +1,12 @@
// Code generated by apigen. DO NOT EDIT.
import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, LoginViaAppleRequest, LoginViaAppleResponse, LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, LoginViaGoogleRequest, LoginViaGoogleResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse, UnlinkIdentityRequest } from './auth.js';
import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js';
import type { ListCatalogItemsResponse } from './catalog.js';
import type { GetInventoryResponse } from './inventory.js';
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
import type { ListCatalogCurrenciesResponse } from './catalog.js';
import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
import type { PlayerProfile } from './player.js';
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js';
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
import type { HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, ListPendingScenarioEffectsResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
import type { CreateResourceRowRequest, ListResourceRowsResponse, ResourceRow, UpdateResourceRowRequest } from './resources.js';
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
/** The minimal transport contract the generated API functions call through. */
export interface Transport {
@@ -36,23 +31,25 @@ function qs(
/** Typed request functions, one per SDK operation. */
export const api = {
addBattlePassXp: (
callModuleFunction: (
t: Transport,
body: AddBattlePassXpRequest,
): Promise<AddBattlePassXpResponse> =>
t.request<AddBattlePassXpResponse>('POST', '/sdk/v1/battlepass/xp', body),
path: { module: string; fn: string },
body: CallModuleFunctionRequest,
): Promise<CallModuleFunctionClientResponse> =>
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
claimBattlePassReward: (
createResourceRow: (
t: Transport,
body: ClaimBattlePassRewardRequest,
): Promise<ClaimBattlePassRewardResponse> =>
t.request<ClaimBattlePassRewardResponse>('POST', '/sdk/v1/battlepass/claim', body),
path: { slug: string },
body: CreateResourceRowRequest,
): Promise<ResourceRow> =>
t.request<ResourceRow>('POST', `/sdk/v1/resources/${enc(path.slug)}`, body),
claimQuest: (
deleteResourceRow: (
t: Transport,
body: ClaimQuestRequest,
): Promise<ClaimQuestResponse> =>
t.request<ClaimQuestResponse>('POST', '/sdk/v1/quests/claim', body),
path: { slug: string; id: string },
): Promise<void> =>
t.request<void>('DELETE', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
deleteStorage: (
t: Transport,
@@ -60,17 +57,6 @@ export const api = {
): Promise<void> =>
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
getBattlePassProgress: (
t: Transport,
body: GetBattlePassProgressRequest,
): Promise<GetBattlePassProgressResponse> =>
t.request<GetBattlePassProgressResponse>('POST', '/sdk/v1/battlepass/progress', body),
getInventory: (
t: Transport,
): Promise<GetInventoryResponse> =>
t.request<GetInventoryResponse>('GET', '/sdk/v1/inventory'),
getPlayerInformation: (
t: Transport,
): Promise<PlayerProfile> =>
@@ -82,18 +68,11 @@ export const api = {
): Promise<GetProjectStorageResponse> =>
t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
getRanking: (
getResourceRow: (
t: Transport,
path: { slug: string },
query?: { limit?: number },
): Promise<GetRankingResponse> =>
t.request<GetRankingResponse>('GET', `/sdk/v1/leaderboards/${enc(path.slug)}/ranking${qs([['limit', query?.limit]])}`),
getRemoteConfig: (
t: Transport,
path: { key: string },
): Promise<RemoteConfig> =>
t.request<RemoteConfig>('GET', `/sdk/v1/remote-configs/${enc(path.key)}`),
path: { slug: string; id: string },
): Promise<ResourceRow> =>
t.request<ResourceRow>('GET', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
getRevisions: (
t: Transport,
@@ -106,18 +85,6 @@ export const api = {
): Promise<GetStorageResponse> =>
t.request<GetStorageResponse>('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
getStore: (
t: Transport,
path: { slug: string },
): Promise<Store> =>
t.request<Store>('GET', `/sdk/v1/stores/${enc(path.slug)}`),
handleScenarioCallback: (
t: Transport,
body: HandleScenarioCallbackRequest,
): Promise<HandleScenarioCallbackResponse> =>
t.request<HandleScenarioCallbackResponse>('POST', '/sdk/v1/scenarios/callback', body),
linkViaApple: (
t: Transport,
body: LinkViaAppleRequest,
@@ -136,30 +103,16 @@ export const api = {
): Promise<void> =>
t.request<void>('POST', '/sdk/v1/authorization/google/link', body),
listCatalogItems: (
listCatalogCurrencies: (
t: Transport,
): Promise<ListCatalogItemsResponse> =>
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
): Promise<ListCatalogCurrenciesResponse> =>
t.request<ListCatalogCurrenciesResponse>('GET', '/sdk/v1/catalog'),
listPendingScenarioEffects: (
listResourceRows: (
t: Transport,
): Promise<ListPendingScenarioEffectsResponse> =>
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
listQuests: (
t: Transport,
): Promise<ListQuestsResponse> =>
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list'),
listSdkRemoteConfigs: (
t: Transport,
): Promise<ListRemoteConfigsResponse> =>
t.request<ListRemoteConfigsResponse>('GET', '/sdk/v1/remote-configs'),
listSdkStores: (
t: Transport,
): Promise<ListStoresResponse> =>
t.request<ListStoresResponse>('GET', '/sdk/v1/stores'),
path: { slug: string },
): Promise<ListResourceRowsResponse> =>
t.request<ListResourceRowsResponse>('GET', `/sdk/v1/resources/${enc(path.slug)}`),
loginViaApple: (
t: Transport,
@@ -185,44 +138,12 @@ export const api = {
): Promise<LoginViaGoogleResponse> =>
t.request<LoginViaGoogleResponse>('POST', '/sdk/v1/authorization/google', body),
purchaseBattlePassPremium: (
t: Transport,
body: PurchaseBattlePassPremiumRequest,
): Promise<PurchaseBattlePassPremiumResponse> =>
t.request<PurchaseBattlePassPremiumResponse>('POST', '/sdk/v1/battlepass/premium', body),
purchaseOffer: (
t: Transport,
path: { storeSlug: string; offerSlug: string },
body: PurchaseOfferRequest,
): Promise<PurchaseOfferResponse> =>
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
refreshAccessToken: (
t: Transport,
body: RefreshAccessTokenRequest,
): Promise<RefreshAccessTokenResponse> =>
t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body),
reportQuestProgress: (
t: Transport,
body: ReportQuestProgressRequest,
): Promise<ReportQuestProgressResponse> =>
t.request<ReportQuestProgressResponse>('POST', '/sdk/v1/quests/progress', body),
submitScore: (
t: Transport,
path: { slug: string },
body: SubmitScoreRequest,
): Promise<void> =>
t.request<void>('POST', `/sdk/v1/leaderboards/${enc(path.slug)}/submit-score`, body),
triggerScenario: (
t: Transport,
body: TriggerScenarioRequest,
): Promise<TriggerScenarioResponse> =>
t.request<TriggerScenarioResponse>('POST', '/sdk/v1/scenarios/trigger', body),
unlinkIdentity: (
t: Transport,
body: UnlinkIdentityRequest,
@@ -235,11 +156,12 @@ export const api = {
): Promise<void> =>
t.request<void>('PUT', '/sdk/v1/project-storage', body),
updateScenarioCounter: (
updateResourceRow: (
t: Transport,
body: UpdateScenarioCounterRequest,
): Promise<UpdateScenarioCounterResponse> =>
t.request<UpdateScenarioCounterResponse>('POST', '/sdk/v1/scenarios/counter', body),
path: { slug: string; id: string },
body: UpdateResourceRowRequest,
): Promise<ResourceRow> =>
t.request<ResourceRow>('PATCH', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`, body),
updateStorage: (
t: Transport,
+10
View File
@@ -14,10 +14,12 @@ export interface LinkViaGoogleRequest {
}
export interface LoginViaAppleRequest {
"appVersion"?: string;
"idToken": string;
"key": string;
"language": string;
"nickname"?: string;
"platform"?: string;
"region": string;
}
@@ -27,10 +29,12 @@ export interface LoginViaAppleResponse {
}
export interface LoginViaCustomRequest {
"appVersion"?: string;
"customData"?: { [key: string]: unknown };
"key"?: string;
"language"?: string;
"nickname"?: string;
"platform"?: string;
"provider"?: string;
"region"?: string;
}
@@ -41,10 +45,12 @@ export interface LoginViaCustomResponse {
}
export interface LoginViaDeviceRequest {
"appVersion"?: string;
"deviceId"?: string;
"key"?: string;
"language"?: string;
"nickname"?: string;
"platform"?: string;
"region"?: string;
}
@@ -54,10 +60,12 @@ export interface LoginViaDeviceResponse {
}
export interface LoginViaGoogleRequest {
"appVersion"?: string;
"idToken": string;
"key": string;
"language": string;
"nickname"?: string;
"platform"?: string;
"region": string;
}
@@ -67,6 +75,8 @@ export interface LoginViaGoogleResponse {
}
export interface RefreshAccessTokenRequest {
"appVersion"?: string;
"platform"?: string;
"refreshToken"?: string;
}
-63
View File
@@ -1,63 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
import type { Reward } from './common.js';
export interface AddBattlePassXpRequest {
"amount"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
"source"?: string;
}
export interface AddBattlePassXpResponse {
"level"?: number;
"leveledUp"?: boolean;
"maxLevel"?: boolean;
"xp"?: number;
}
export interface ClaimBattlePassRewardRequest {
"level"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
"track"?: "free" | "premium";
}
export interface ClaimBattlePassRewardResponse {
"alreadyClaimed"?: boolean;
"error"?: string;
"granted"?: Reward[];
"success"?: boolean;
}
export interface ClaimedTier {
"level"?: number;
"track"?: string;
}
export interface GetBattlePassProgressRequest {
"nodeId"?: string;
"scenarioSlug"?: string;
}
export interface GetBattlePassProgressResponse {
"claimedTiers"?: ClaimedTier[];
"level"?: number;
"premiumOwned"?: boolean;
"xp"?: number;
}
export interface PurchaseBattlePassPremiumRequest {
"idempotencyKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface PurchaseBattlePassPremiumResponse {
"error"?: string;
"success"?: boolean;
}
+5 -5
View File
@@ -1,13 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
export interface CatalogItem {
export interface CatalogCurrency {
"data"?: { [key: string]: unknown };
"icon"?: string;
"name"?: string;
"properties"?: { [key: string]: unknown };
"slug"?: string;
"tags"?: string[];
}
export interface ListCatalogItemsResponse {
"items"?: CatalogItem[];
export interface ListCatalogCurrenciesResponse {
"currencies"?: CatalogCurrency[];
}
+5 -7
View File
@@ -1,16 +1,14 @@
// Code generated by apigen. DO NOT EDIT.
import type { ResourceRowUsage } from './resources.js';
export interface ErrorResponse {
"code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
"code"?: "forbidden" | "row_referenced";
"error"?: string;
"index"?: number;
"path"?: string;
"playerId"?: string;
"requestId"?: string;
}
export interface Reward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
"usages"?: ResourceRowUsage[];
}
+2 -9
View File
@@ -1,15 +1,8 @@
// Code generated by apigen. DO NOT EDIT.
export type RudderErrorCode = "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
export type RudderErrorCode = "forbidden" | "row_referenced";
export const RudderErrorCodes = {
earlyCompletion: "early_completion",
forbidden: "forbidden",
levelNotReached: "level_not_reached",
nodeNotActive: "node_not_active",
objectivesIncomplete: "objectives_incomplete",
runExpired: "run_expired",
runNotActive: "run_not_active",
scenarioNotActive: "scenario_not_active",
unknownRun: "unknown_run",
rowReferenced: "row_referenced",
} as const;
+2 -7
View File
@@ -1,17 +1,12 @@
// Code generated by apigen. DO NOT EDIT.
export * from './auth.js';
export * from './battlepass.js';
export * from './catalog.js';
export * from './common.js';
export * from './inventory.js';
export * from './leaderboards.js';
export * from './modules.js';
export * from './player.js';
export * from './project-storage.js';
export * from './quests.js';
export * from './remote-config.js';
export * from './scenarios.js';
export * from './resources.js';
export * from './storage.js';
export * from './stores.js';
export * from './api.js';
export * from './errors.js';
-14
View File
@@ -1,14 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
export interface GetInventoryResponse {
"items"?: PlayerInventoryItem[];
}
export interface PlayerInventoryItem {
"amount"?: number;
"nameOverride"?: string;
"propertiesOverride"?: { [key: string]: unknown };
"slug"?: string;
"updatedAt"?: string;
}
-19
View File
@@ -1,19 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
export interface GetRankingResponse {
"entries"?: RankEntry[];
"total"?: number;
}
export interface RankEntry {
"playerId"?: string;
"playerName"?: string;
"rank"?: number;
"score"?: number;
}
export interface SubmitScoreRequest {
"score"?: number;
"slug"?: string;
}
+17
View File
@@ -0,0 +1,17 @@
// Code generated by apigen. DO NOT EDIT.
export interface CallModuleFunctionClientResponse {
"result"?: unknown;
}
export interface CallModuleFunctionRequest {
"args"?: unknown;
"dryRun"?: boolean;
}
export interface ModuleErrorResponse {
"code": string;
"error": string;
"requestId"?: string;
}
-44
View File
@@ -1,44 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
import type { Reward } from './common.js';
export interface ClaimQuestRequest {
"questSlug"?: string;
}
export interface ClaimQuestResponse {
"alreadyClaimed"?: boolean;
"error"?: string;
"granted"?: Reward[];
"success"?: boolean;
}
export interface ListQuestsResponse {
"quests"?: Quest[];
}
export interface Quest {
"name"?: string;
"objectives"?: QuestObjectiveProgress[];
"rewards"?: Reward[];
"slug"?: string;
"status"?: "active" | "claimed" | "completed";
}
export interface QuestObjectiveProgress {
"completed"?: boolean;
"current"?: number;
"metric"?: string;
"objectiveId"?: string;
"target"?: number;
}
export interface ReportQuestProgressRequest {
"amount"?: number;
"metric"?: string;
}
export interface ReportQuestProgressResponse {
"completedQuestSlugs"?: string[];
}
-19
View File
@@ -1,19 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
export interface ListRemoteConfigsResponse {
"configs"?: { [key: string]: RemoteConfig };
}
export 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";
}
+34
View File
@@ -0,0 +1,34 @@
// Code generated by apigen. DO NOT EDIT.
export interface CreateResourceRowRequest {
"data"?: { [key: string]: unknown };
"id"?: string;
}
export interface ListResourceRowsResponse {
"items"?: ResourceRow[];
"total"?: number;
}
export interface ResourceRow {
"createdAt"?: string;
"data"?: { [key: string]: unknown };
"id"?: string;
"playerId"?: string;
"rank"?: number;
"updatedAt"?: string;
"version"?: number;
}
export interface ResourceRowUsage {
"definitionSlug"?: string;
"field"?: string;
"moduleSlug"?: string;
"rowId"?: string;
}
export interface UpdateResourceRowRequest {
"data"?: { [key: string]: unknown };
"version"?: number;
}
-47
View File
@@ -1,47 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
export interface HandleScenarioCallbackRequest {
"handle"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface HandleScenarioCallbackResponse {
"effect"?: PendingEffect;
}
export interface ListPendingScenarioEffectsResponse {
"effects": PendingEffect[];
}
export interface PendingEffect {
"data": { [key: string]: unknown };
"nodeId": string;
"runId": string;
"scenarioSlug": string;
"type": string;
"waitDeadline"?: string;
}
export interface TriggerScenarioRequest {
"event"?: string;
}
export interface TriggerScenarioResponse {
"effects": PendingEffect[];
}
export interface UpdateScenarioCounterRequest {
"amount"?: number;
"counterKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface UpdateScenarioCounterResponse {
"completed"?: boolean;
"effect"?: PendingEffect;
}
-55
View File
@@ -1,55 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
export interface ListStoresResponse {
"stores"?: Store[];
"total"?: number;
}
export interface Offer {
"contents"?: OfferContent[];
"createdAt"?: string;
"id"?: string;
"maxPurchases"?: number;
"name"?: string;
"price"?: OfferPrice;
"slug"?: string;
"updatedAt"?: string;
}
export interface OfferContent {
"amount"?: number;
"itemId"?: string;
}
export interface OfferPrice {
"amount"?: number;
"currency"?: string;
}
export interface PurchaseOfferRequest {
"idempotencyKey"?: string;
"offerSlug"?: string;
"storeSlug"?: string;
}
export interface PurchaseOfferResponse {
"error"?: string;
"purchaseId"?: string;
"success"?: boolean;
}
export 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;
}
+13 -49
View File
@@ -16,8 +16,7 @@ export type { RudderErrorCodeLike } from './client/RudderError.js';
export type { TokenStore } from './token/TokenStore.js';
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
// Services — types only; instances live on the client
// (client.auth, client.leaderboards, client.battlePass, client.quests).
// Services — types only; instances live on the client (client.auth).
export type {
AuthService,
AuthState,
@@ -28,10 +27,6 @@ export type {
LoginWithDeviceOptions,
LoginWithGoogleOptions,
} from './auth/AuthService.js';
export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js';
export type { BattlePassService } from './battlepass/BattlePassService.js';
export type { QuestsService } from './quests/QuestsService.js';
export * as QuestMetrics from './quests/QuestMetrics.js';
// Observable state primitives
export { SyncedState } from './state/SyncedState.js';
@@ -40,74 +35,43 @@ export type {
SyncedStatus,
SyncedListener,
} from './state/SyncedState.js';
export { RemoteConfigState } from './state/RemoteConfigState.js';
export type { RemoteConfigShape } from './state/RemoteConfigState.js';
export { ShopHandle, OfferHandle } from './state/shops.js';
export type { BuyOptions } from './state/shops.js';
export type { InventoryItem } from './state/inventory.js';
// Domains — types only; instances live on the client
// (client.player, client.stores, client.remoteConfig, …).
// (client.player, client.modules, client.resources(slug), …).
export type { PlayerDomain } from './domains/PlayerDomain.js';
export type { CatalogDomain } from './domains/CatalogDomain.js';
export type { InventoryDomain } from './domains/InventoryDomain.js';
export type { ConfigDomain } from './domains/ConfigDomain.js';
export type { ModulesDomain } from './domains/ModulesDomain.js';
export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.js';
export type { StorageDomain } from './domains/StorageDomain.js';
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
export type { StoresDomain } from './domains/StoresDomain.js';
// Effects
export type {
ConfigChangedEffect,
Effects,
EffectUnsubscribe,
LeaderboardEffect,
NotificationEffect,
StoreOfferEffect,
WaitEffect,
ScenarioCompletedEffect,
ScenarioFailedEffect,
QuestEffect,
BattlePassEffect,
BattlePassLevelEffect,
} from './effects/EffectsCenter.js';
// Generated wire types — the shapes returned by the observable state and
// handles, so consumers can name them instead of re-declaring mirrors.
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
export type { CatalogItem } from './generated/catalog.js';
export type { PlayerInventoryItem } from './generated/inventory.js';
export type {
Store,
Offer,
OfferContent,
OfferPrice,
PurchaseOfferResponse,
} from './generated/stores.js';
export type { RankEntry } from './generated/leaderboards.js';
export type { CatalogCurrency } from './generated/catalog.js';
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
export type {
ProjectStorageItem,
GetProjectStorageResponse,
ProjectStorageUpdateItem,
} from './generated/project-storage.js';
export type { RemoteConfig } from './generated/remote-config.js';
export type { Reward } from './generated/common.js';
export type {
Quest,
QuestObjectiveProgress,
ClaimQuestResponse,
} from './generated/quests.js';
CallModuleFunctionRequest,
CallModuleFunctionClientResponse,
} from './generated/modules.js';
export type {
ClaimedTier,
AddBattlePassXpRequest,
AddBattlePassXpResponse,
ClaimBattlePassRewardRequest,
ClaimBattlePassRewardResponse,
PurchaseBattlePassPremiumRequest,
PurchaseBattlePassPremiumResponse,
GetBattlePassProgressRequest,
GetBattlePassProgressResponse,
} from './generated/battlepass.js';
export type { RudderErrorCode } from './generated/errors.js';
export { RudderErrorCodes } from './generated/errors.js';
ResourceRow,
ListResourceRowsResponse,
CreateResourceRowRequest,
UpdateResourceRowRequest,
} from './generated/resources.js';
-52
View File
@@ -1,52 +0,0 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { RankEntry } from '../generated/leaderboards.js';
/**
* A cached handle for a specific leaderboard.
*
* Created by `LeaderboardsService.findBySlug()` — subsequent calls with
* the same slug return the same handle, avoiding redundant fetches.
*/
export class LeaderboardHandle {
private entries: RankEntry[] = [];
constructor(
public readonly slug: string,
private readonly ctx: RudderContext,
) {}
getEntries(): readonly RankEntry[] {
return this.entries;
}
async submit(score: number): Promise<void> {
return api.submitScore(this.ctx, { slug: this.slug }, { slug: this.slug, score });
}
async list(limit = 100): Promise<readonly RankEntry[]> {
const response = await api.getRanking(
this.ctx,
{ slug: this.slug },
{ limit: limit > 0 ? limit : undefined },
);
this.entries = response.entries ?? [];
return this.entries;
}
}
export class LeaderboardsService {
private readonly cache = new Map<string, LeaderboardHandle>();
constructor(private readonly ctx: RudderContext) {}
/** Returns a cached handle for the given leaderboard slug. */
findBySlug(slug: string): LeaderboardHandle {
let handle = this.cache.get(slug);
if (!handle) {
handle = new LeaderboardHandle(slug, this.ctx);
this.cache.set(slug, handle);
}
return handle;
}
}
-19
View File
@@ -1,19 +0,0 @@
/**
* QuestMetrics — builders for the quest metric strings known to the platform.
*
* Purchase metrics are reported automatically server-side by the shop
* purchase fan-out, so these helpers exist mainly to name the format
* instead of hardcoding it. Catalog counter slugs are reported via
* {@link QuestsService.reportProgress}. Custom free-text metrics no longer
* progress quests — they no-op at runtime.
*/
/** Metric for purchasing a store offer; auto-reported on purchase. */
export function purchaseOffer(offerSlug: string): string {
return `purchase.offer:${offerSlug}`;
}
/** Metric for purchasing a catalog item; auto-reported on purchase. */
export function purchaseItem(itemId: string): string {
return `purchase.item:${itemId}`;
}
-32
View File
@@ -1,32 +0,0 @@
/**
* QuestsService — call-and-response access to the global quest endpoints.
*
* These are the player's global quests (list + claim), distinct from scenario
* quest nodes (which advance via {@link QuestSession}). Global quests have no
* sync-revision key, so this is a plain service; re-list after a claim.
*/
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { Quest, ClaimQuestResponse } from '../generated/quests.js';
export class QuestsService {
constructor(private readonly ctx: RudderContext) {}
/** Lists the player's quests with per-objective progress and rewards. */
async list(): Promise<Quest[]> {
const response = await api.listQuests(this.ctx);
return response.quests ?? [];
}
/** Claims a completed quest's rewards (idempotent server-side). */
claim(questSlug: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questSlug });
}
/** Reports metric progress; returns ids of quests completed by this report. */
async reportProgress(metric: string, amount: number): Promise<string[]> {
const response = await api.reportQuestProgress(this.ctx, { metric, amount });
return response.completedQuestSlugs ?? [];
}
}
-454
View File
@@ -1,454 +0,0 @@
import type { RudderContext } from '../core/context.js';
import type { ShopHandle, OfferHandle, BuyOptions } from '../state/shops.js';
import type { PurchaseOfferResponse } from '../generated/stores.js';
import { api } from '../generated/api.js';
import type { PendingEffect, TriggerScenarioResponse } from '../generated/scenarios.js';
import { RudderErrorCodes } from '../generated/errors.js';
import {
RudderNetworkError,
RudderHttpError,
} from '../client/RudderError.js';
import type { BattlePassService } from '../battlepass/BattlePassService.js';
export interface ScenarioDomains {
readonly player: { invalidate(): void };
readonly inventory: { invalidate(): void };
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
}
const DEFAULT_PENDING_INTERVAL_MS = 30_000;
const JITTER_RATIO = 0.2;
type ActiveRecord = {
runId: string;
nodeId: string;
scenarioSlug: string;
storePurchased?: boolean;
lastPurchase?: PurchaseOfferResponse;
};
function effectKey(runId: string, nodeId: string): string {
return `${runId}:${nodeId}`;
}
function isTransientHttpError(err: unknown): boolean {
if (err instanceof RudderNetworkError) return true;
if (err instanceof RudderHttpError && err.status >= 500) return true;
return false;
}
function isDroppedRunError(err: unknown): boolean {
if (!(err instanceof RudderHttpError)) return false;
return (
err.code === RudderErrorCodes.unknownRun ||
err.code === RudderErrorCodes.runExpired ||
err.code === RudderErrorCodes.runNotActive
);
}
function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function asObjectives(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === 'object' && !Array.isArray(item),
);
}
function asLevel(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
export class ScenarioService {
private readonly active = new Map<string, ActiveRecord>();
private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly droppedRuns = new Set<string>();
private readonly completedRuns = new Set<string>();
private heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
private running = false;
private polling = false;
private pollQueued = false;
constructor(
private readonly ctx: RudderContext,
private readonly domains: ScenarioDomains,
private readonly battlePass: BattlePassService,
private readonly intervalMs: number = DEFAULT_PENDING_INTERVAL_MS,
) {}
get isRunning(): boolean {
return this.active.size > 0;
}
async send(eventName: string): Promise<TriggerScenarioResponse> {
const response = await api.triggerScenario(this.ctx, { event: eventName });
this.domains.player.invalidate();
this.domains.inventory.invalidate();
this.ingest(response.effects ?? []);
return response;
}
async start(): Promise<void> {
if (this.running) return;
this.running = true;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
await this.pollPending();
this.scheduleHeartbeat();
}
clear(): void {
this.running = false;
this.pollQueued = false;
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
for (const timer of this.waitTimers.values()) {
clearTimeout(timer);
}
this.waitTimers.clear();
this.active.clear();
this.droppedRuns.clear();
this.completedRuns.clear();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
}
private readonly onVisibilityChange = (): void => {
if (this.running && typeof document !== 'undefined' && !document.hidden) {
void this.heartbeatTick();
}
};
private scheduleHeartbeat(): void {
if (!this.running) return;
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
this.heartbeatTimer = setTimeout(() => {
void this.heartbeatTick();
}, this.intervalMs * jitter);
}
private async heartbeatTick(): Promise<void> {
if (!this.running) return;
if (typeof document !== 'undefined' && document.hidden) {
this.scheduleHeartbeat();
return;
}
await this.pollPending();
this.scheduleHeartbeat();
}
private async pollPending(): Promise<void> {
if (this.polling) {
this.pollQueued = true;
return;
}
this.polling = true;
try {
do {
this.pollQueued = false;
try {
const response = await api.listPendingScenarioEffects(this.ctx);
this.reconcile(response.effects ?? []);
} catch {
}
} while (this.pollQueued);
} finally {
this.polling = false;
}
}
private reconcile(effects: PendingEffect[]): void {
const incoming = effects.filter((effect) => !this.droppedRuns.has(effect.runId));
const incomingKeys = new Set(
incoming.map((effect) => effectKey(effect.runId, effect.nodeId)),
);
const before = new Set(this.active.keys());
this.ingest(incoming);
for (const [key, record] of [...this.active.entries()]) {
if (incomingKeys.has(key)) continue;
this.deactivate(key);
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
const after = new Set(this.active.keys());
if (before.size !== after.size || [...before].some((key) => !after.has(key))) {
this.domains.player.invalidate();
this.domains.inventory.invalidate();
}
}
private ingest(effects: PendingEffect[]): void {
for (const effect of effects) {
this.ingestOne(effect);
}
}
private ingestOne(pending: PendingEffect): void {
if (this.droppedRuns.has(pending.runId)) return;
const key = effectKey(pending.runId, pending.nodeId);
if (this.active.has(key)) {
if (pending.waitDeadline && !this.waitTimers.has(key)) {
this.scheduleWait(key, pending.waitDeadline);
}
return;
}
const record: ActiveRecord = {
runId: pending.runId,
nodeId: pending.nodeId,
scenarioSlug: pending.scenarioSlug,
};
this.active.set(key, record);
this.completedRuns.delete(pending.runId);
if (pending.waitDeadline) {
this.scheduleWait(key, pending.waitDeadline);
}
this.dispatch(pending, record);
}
private dispatch(pending: PendingEffect, record: ActiveRecord): void {
switch (pending.type) {
case 'notification':
this.ctx.effects.emitNotification({
title: asString(pending.data.title),
message: asString(pending.data.message),
done: () => this.complete(record, 'output'),
});
break;
case 'store':
this.dispatchStore(pending, record);
break;
case 'wait':
this.ctx.effects.emitWait({
deadlineUtc: pending.waitDeadline
? new Date(pending.waitDeadline)
: new Date(),
});
break;
case 'leaderboard': {
const end = () => this.complete(record, 'onEnd');
const claim = () => this.complete(record, 'onClaim');
this.ctx.effects.emitLeaderboard({
end,
claim,
rewardClaimed: claim,
});
break;
}
case 'quest':
this.ctx.effects.emitQuest({
name: asString(pending.data.name),
objectives: asObjectives(pending.data.objectives),
reportProgress: (objectiveId, amount) =>
this.reportQuestProgress(record, objectiveId, amount),
});
break;
case 'battlepass':
this.dispatchBattlePass(record);
break;
case 'battlepass_level':
this.ctx.effects.emitBattlePassLevel({
level: asLevel(pending.data.levelNumber),
claim: () => this.complete(record, 'onComplete'),
});
break;
default:
console.warn(
`[Rudder] Unsupported scenario node type '${pending.type}' (${pending.nodeId})`,
);
this.dropRun(
record.runId,
record.nodeId,
new Error(`Unsupported scenario node type '${pending.type}'`),
);
break;
}
}
private dispatchStore(pending: PendingEffect, record: ActiveRecord): void {
const slug = asString(pending.data.storeSlug);
this.ctx.effects.emitStoreOffer(
this.domains.stores.getBySlug(slug).then((store) => ({
store,
offers: store.offers,
message: typeof pending.data.message === 'string' ? pending.data.message : undefined,
buy: (offer, options) => this.buyStore(record, offer, options),
dismiss: () => this.dismissStore(record),
})),
);
}
private dispatchBattlePass(record: ActiveRecord): void {
const { scenarioSlug, nodeId, runId } = record;
this.ctx.effects.emitBattlePass({
getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
addXp: (source, amount) =>
this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
claimReward: (level, track) =>
this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
purchasePremium: async () => {
const response = await this.battlePass.purchasePremium({
scenarioSlug,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
if (response.success) {
await this.complete(record, 'onPremiumPurchase');
}
return response;
},
levelUp: () => this.complete(record, 'onLevelUp'),
end: () => this.complete(record, 'onComplete'),
});
}
private async buyStore(
record: ActiveRecord,
offer: OfferHandle,
options?: BuyOptions,
): Promise<PurchaseOfferResponse> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) {
return { success: false, error: 'store session already resolved' };
}
if (!record.storePurchased) {
const purchase = await offer.buy(options);
if (!purchase.success) return purchase;
record.storePurchased = true;
record.lastPurchase = purchase;
}
await this.complete(record, 'onPurchase');
return record.lastPurchase ?? { success: true };
}
private async dismissStore(record: ActiveRecord): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (record.storePurchased) return;
await this.complete(record, 'onDecline');
}
private async reportQuestProgress(
record: ActiveRecord,
objectiveId: string,
amount = 1,
): Promise<void> {
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
counterKey: objectiveId,
amount,
runId: record.runId,
});
if (!response?.completed) return;
this.domains.player.invalidate();
this.domains.inventory.invalidate();
const key = effectKey(record.runId, record.nodeId);
if (this.active.has(key)) {
this.deactivate(key);
}
if (response.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch {
}
}
private async complete(record: ActiveRecord, handle: string): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (this.droppedRuns.has(record.runId)) return;
try {
const response = await api.handleScenarioCallback(this.ctx, {
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
handle,
runId: record.runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (!this.active.has(key)) return;
this.deactivate(key);
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch (err) {
if (isTransientHttpError(err)) return;
if (isDroppedRunError(err)) {
const error = err instanceof Error ? err : new Error(String(err));
this.dropRun(record.runId, record.nodeId, error);
throw err;
}
throw err;
}
}
private scheduleWait(key: string, waitDeadline: string): void {
const deadline = Date.parse(waitDeadline);
if (Number.isNaN(deadline)) return;
const remaining = Math.max(0, deadline - Date.now());
const existing = this.waitTimers.get(key);
if (existing) clearTimeout(existing);
this.waitTimers.set(
key,
setTimeout(() => {
this.waitTimers.delete(key);
void this.pollPending();
}, remaining),
);
}
private deactivate(key: string): void {
const timer = this.waitTimers.get(key);
if (timer) {
clearTimeout(timer);
this.waitTimers.delete(key);
}
this.active.delete(key);
}
private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId);
let scenarioSlug = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
this.deactivate(key);
}
console.warn(
`[Rudder] Scenario run ${runId} failed at node ${nodeId}: ${error.message}`,
);
this.ctx.effects.emitScenarioFailed({
runId,
scenarioSlug,
nodeId,
error,
});
}
private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
if (this.droppedRuns.has(runId)) return;
if (this.completedRuns.has(runId)) return;
for (const record of this.active.values()) {
if (record.runId === runId) return;
}
this.completedRuns.add(runId);
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
}
}
-54
View File
@@ -1,54 +0,0 @@
import type { RemoteConfig } from '../generated/remote-config.js';
import { SyncedState } from './SyncedState.js';
export type RemoteConfigShape = Record<string, unknown>;
type RemoteConfigKey<TConfig extends RemoteConfigShape> = Extract<keyof TConfig, string>;
/**
* RemoteConfigState — typed remote configuration as an observable entity.
*
* `get()` parses values synchronously from the loaded snapshot based on their
* declared `valueType` and returns the default value while not loaded.
*/
export class RemoteConfigState<
TConfig extends RemoteConfigShape = RemoteConfigShape,
> extends SyncedState<Map<string, RemoteConfig>> {
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey): TConfig[TKey] | undefined;
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey, defaultValue: TConfig[TKey]): TConfig[TKey];
get<TKey extends RemoteConfigKey<TConfig>>(
key: TKey,
defaultValue?: TConfig[TKey],
): TConfig[TKey] | undefined {
const config = this.data?.get(key);
if (!config || config.value == null) return defaultValue;
return parseValue(config.value, config.valueType, defaultValue);
}
}
function parseValue<T>(
value: string,
valueType: string | undefined,
defaultValue: T | undefined,
): T | undefined {
try {
switch ((valueType ?? '').toLowerCase()) {
case 'int':
case 'integer':
return Number.parseInt(value, 10) as unknown as T;
case 'float':
case 'double':
case 'number':
return Number.parseFloat(value) as unknown as T;
case 'bool':
case 'boolean':
return (value === 'true') as unknown as T;
case 'json':
case 'object':
return JSON.parse(value) as T;
default:
return value as unknown as T;
}
} catch {
return defaultValue;
}
}
-29
View File
@@ -1,29 +0,0 @@
import type { CatalogItem } from '../generated/catalog.js';
import type { PlayerInventoryItem } from '../generated/inventory.js';
/** A fully-resolved owned item: player inventory row merged with its catalog entry. */
export interface InventoryItem {
slug: string;
amount: number;
name: string;
properties: Record<string, unknown>;
tags: string[];
}
/** @internal Merges owned inventory rows with their catalog entries. */
export function mergeInventory(
owned: PlayerInventoryItem[],
catalog: Map<string, CatalogItem>,
): InventoryItem[] {
return owned.map((item) => {
const slug = item.slug ?? '';
const entry = catalog.get(slug);
return {
slug,
amount: item.amount ?? 0,
name: item.nameOverride || entry?.name || slug,
properties: { ...(entry?.properties ?? {}), ...(item.propertiesOverride ?? {}) },
tags: entry?.tags ?? [],
};
});
}
-77
View File
@@ -1,77 +0,0 @@
import type {
Offer,
OfferContent,
OfferPrice,
Store,
PurchaseOfferResponse,
} from '../generated/stores.js';
export interface BuyOptions {
idempotencyKey?: string;
}
/**
* Wire-level purchase executor provided by the stores domain
* (`StoresDomain.purchase`).
*/
export type PurchaseOffer = (
storeSlug: string,
offerSlug: string,
options?: BuyOptions,
) => Promise<PurchaseOfferResponse>;
export class OfferHandle {
public readonly id: string;
public readonly slug: string;
public readonly name?: string;
public readonly price?: OfferPrice;
public readonly contents: OfferContent[];
public readonly maxPurchases?: number;
constructor(
private readonly purchase: PurchaseOffer,
private readonly storeSlug: string,
private readonly offer: Offer,
) {
if (!offer.id) {
throw new Error('Rudder Stores: offer.id is required');
}
if (!offer.slug) {
throw new Error('Rudder Stores: offer.slug is required');
}
this.id = offer.id;
this.slug = offer.slug;
this.name = offer.name;
this.price = offer.price;
this.contents = offer.contents ?? [];
this.maxPurchases = offer.maxPurchases;
}
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
return this.purchase(this.storeSlug, this.slug, options);
}
}
export class ShopHandle {
public readonly slug: string;
public readonly name?: string;
public readonly description?: string;
public readonly data?: { [key: string]: unknown };
public readonly offers: OfferHandle[];
constructor(
purchase: PurchaseOffer,
store: Store,
) {
if (!store.slug) {
throw new Error('Rudder Stores: store.slug is required');
}
this.slug = store.slug;
this.name = store.name;
this.description = store.description;
this.data = store.data;
this.offers = (store.offers ?? []).map(
(offer) => new OfferHandle(purchase, this.slug, offer),
);
}
}
+7 -1
View File
@@ -19,6 +19,8 @@ export interface RequestContext {
baseUrl: string;
tokenStore: TokenStore;
requestTimeoutMs?: number;
/** Sent with `platform: 'web'` on token refresh; omitted when not set. */
appVersion?: string;
/** Single-flight refresh state shared by all requests of one client. */
refreshState?: { pending?: Promise<boolean> };
/** Called when the refresh flow fails — lets the client stop its runtime. */
@@ -168,7 +170,11 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken } satisfies RefreshAccessTokenRequest),
body: JSON.stringify({
refreshToken,
platform: 'web',
appVersion: ctx.appVersion,
} satisfies RefreshAccessTokenRequest),
signal: controller.signal,
});
if (!response.ok) return false;
+41 -115
View File
@@ -6,9 +6,30 @@ function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
function stubKernelFetch(
extra?: (url: string) => Response | undefined,
): ReturnType<typeof vi.fn> {
return vi.fn().mockImplementation((url: string) => {
const custom = extra?.(url);
if (custom) return Promise.resolve(custom);
if (String(url).includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
}));
}
if (String(url).includes('/sdk/v1/catalog')) {
return Promise.resolve(jsonResponse({ currencies: [] }));
}
if (String(url).includes('/sdk/v1/sync')) {
return Promise.resolve(jsonResponse({}));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
}
describe('AuthService', () => {
beforeEach(() => {
// Mock crypto.randomUUID for deterministic device ID.
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
@@ -17,20 +38,11 @@ describe('AuthService', () => {
it('loginWithDevice sends correct request body', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
}));
});
const fetchMock = stubKernelFetch();
vi.stubGlobal('fetch', fetchMock);
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
expect(fetchMock).toHaveBeenCalledTimes(9);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/sdk/v1/authorization/device');
const body = JSON.parse(init.body);
@@ -40,29 +52,24 @@ describe('AuthService', () => {
expect(body.language).toBe('en');
expect(response.accessToken).toBe('access-token-123');
expect(response.refreshToken).toBe('refresh-token-456');
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
'/sdk/v1/authorization/device',
'/sdk/v1/remote-configs',
'/sdk/v1/player/information',
'/sdk/v1/stores',
'/sdk/v1/catalog',
'/sdk/v1/scenarios/pending',
'/sdk/v1/scenarios/trigger',
'/sdk/v1/player/information',
'/sdk/v1/sync',
]);
const paths = fetchMock.mock.calls.map(([callUrl]) => new URL(String(callUrl)).pathname);
expect(paths[0]).toBe('/sdk/v1/authorization/device');
expect(paths).toContain('/sdk/v1/player/information');
expect(paths).toContain('/sdk/v1/catalog');
expect(paths).toContain('/sdk/v1/sync');
expect(paths).not.toContain('/sdk/v1/remote-configs');
expect(paths).not.toContain('/sdk/v1/stores');
expect(paths).not.toContain('/sdk/v1/scenarios/pending');
expect(paths).not.toContain('/sdk/v1/scenarios/trigger');
});
it('loginWithDevice saves tokens on success', async () => {
const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
vi.stubGlobal('fetch', stubKernelFetch((url) => {
if (url.includes('/sdk/v1/authorization/device')) {
return jsonResponse({ accessToken: 'at', refreshToken: 'rt' });
}
return Promise.resolve(jsonResponse({
accessToken: 'at',
refreshToken: 'rt',
}));
return undefined;
}));
await client.auth.loginWithDevice();
@@ -94,23 +101,16 @@ describe('AuthService', () => {
it('loginWithDevice sends token in Authorization header after login', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
const fetchMock = stubKernelFetch((url) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at2',
refreshToken: 'rt2',
}));
return jsonResponse({ accessToken: 'at2', refreshToken: 'rt2' });
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
return undefined;
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
// Subsequent authenticated calls should include the token.
const response = await client.player.reload();
expect(response).toEqual({ player: null, wallets: [] });
const playerCall = fetchMock.mock.calls.find(([url]) =>
@@ -120,71 +120,9 @@ describe('AuthService', () => {
expect(headers['Authorization']).toBe('Bearer at2');
});
it('loginWithDevice only fires player_login from the client runtime', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at-new',
refreshToken: 'rt-new',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(jsonResponse({ plans: [] }));
}
if (url.includes('/sdk/v1/stores')) {
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
const triggerBodies = fetchMock.mock.calls
.filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger'))
.map(([, init]) => JSON.parse(init.body));
expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']);
});
it('loginWithDevice isolates post-login scenario trigger failures', async () => {
const client = createTestClient();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at',
refreshToken: 'rt',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' }));
}
if (url.includes('/sdk/v1/stores')) {
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
}));
await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' });
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('loginWithDevice defaults to region global and language en', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
});
const fetchMock = stubKernelFetch();
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
@@ -203,21 +141,9 @@ describe('auth state', () => {
localStorage.clear();
});
function stubLoginFlow(): void {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(jsonResponse({ plans: [] }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
}));
}
it('onAuthStateChange fires immediately, then on login and logout', async () => {
const client = createTestClient();
stubLoginFlow();
vi.stubGlobal('fetch', stubKernelFetch());
const states: string[] = [];
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
+28 -29
View File
@@ -14,20 +14,17 @@ describe('public API surface', () => {
});
it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
// Device id helper — internal.
expect('getOrCreateDeviceId' in sdk).toBe(false);
// Domain classes — exported as types only.
for (const name of [
'PlayerDomain',
'CatalogDomain',
'InventoryDomain',
'ConfigDomain',
'StorageDomain',
'StoresDomain',
'ProjectStorageDomain',
'ModulesDomain',
'ResourcesDomain',
]) {
expect(name in sdk).toBe(false);
}
// Service constructors — exported as types only.
for (const name of [
'AuthService',
'LeaderboardsService',
@@ -48,43 +45,45 @@ describe('public API surface', () => {
'RudderHttpError',
'RudderAuthError',
'SDK_ERROR_INVALID_OPTIONS',
'RudderErrorCodes',
'createDefaultTokenStore',
'createLocalStorageTokenStore',
'SyncedState',
'RemoteConfigState',
'ShopHandle',
'OfferHandle',
]) {
expect(name in sdk, name).toBe(true);
}
expect('RemoteConfigState' in sdk).toBe(false);
expect('ShopHandle' in sdk).toBe(false);
expect('OfferHandle' in sdk).toBe(false);
expect('QuestMetrics' in sdk).toBe(false);
});
it('exposes effects without a public scenarios service on the client', () => {
it('exposes kernel members and no module-specific namespaces on the client', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { loginEvent: null },
});
expect(client.auth).toBeDefined();
expect(client.player).toBeDefined();
expect(client.catalog).toBeDefined();
expect(client.storage).toBeDefined();
expect(client.projectStorage).toBeDefined();
expect(client.modules).toBeDefined();
expect(typeof client.modules.call).toBe('function');
expect(typeof client.resources).toBe('function');
expect(client.effects).toBeDefined();
expect('battlePass' in client).toBe(false);
expect('quests' in client).toBe(false);
expect('leaderboards' in client).toBe(false);
expect('stores' in client).toBe(false);
expect('inventory' in client).toBe(false);
expect('remoteConfig' in client).toBe(false);
expect('scenarios' in client).toBe(false);
});
it('uses the canonical glossary property names on the client', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { loginEvent: null },
});
expect(client.remoteConfig).toBeDefined();
expect('config' in client).toBe(false);
expect(client.battlePass).toBeDefined();
expect('battlepass' in client).toBe(false);
expect(typeof client.effects.onBattlePass).toBe('function');
expect(typeof client.effects.onBattlePassLevel).toBe('function');
expect('onBattlepass' in client.effects).toBe(false);
expect('onBattlepassLevel' in client.effects).toBe(false);
expect('onBattlePass' in client.effects).toBe(false);
expect('onStoreOffer' in client.effects).toBe(false);
expect('onQuest' in client.effects).toBe(false);
expect('onLeaderboard' in client.effects).toBe(false);
expect('onConfigChanged' in client.effects).toBe(false);
});
});
+41 -57
View File
@@ -135,83 +135,67 @@ describe('transport error mapping', () => {
});
});
describe('lazy scenario runtime', () => {
beforeEach(() => {
localStorage.clear();
});
describe('modules and resources', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not create the scenario runtime until first use', async () => {
it('modules.call posts to /sdk/v1/modules/:module/:fn', async () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: { loginEvent: null },
tokenStore: createFakeTokenStore(),
});
const internals = client as unknown as {
scenarioRuntime: unknown;
ensureScenarioRuntime(): Promise<unknown>;
};
expect(internals.scenarioRuntime).toBeNull();
await internals.ensureScenarioRuntime();
expect(internals.scenarioRuntime).not.toBeNull();
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ result: { ok: true }, log: [] }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const result = await client.modules.call('quests', 'list', { limit: 10 });
expect(result).toEqual({ result: { ok: true }, log: [] });
const [url, init] = fetchMock.mock.calls[0];
expect(new URL(String(url)).pathname).toBe('/sdk/v1/modules/quests/list');
expect(JSON.parse(String(init.body))).toEqual({ args: { limit: 10 } });
});
it('delivers effects to handlers subscribed before the runtime is ready', async () => {
it('resources(slug) lists and mutates rows', async () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: {},
tokenStore: createFakeTokenStore(),
});
const notifications: string[] = [];
client.effects.onNotification((effect) => {
notifications.push(effect.message);
return effect.done();
});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const row = { id: 'row-1', data: { name: 'Sword' }, version: 1 };
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = new URL(url).pathname;
if (path === '/sdk/v1/authorization/device') {
return Promise.resolve(new Response(
JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }),
{ status: 200 },
));
const method = (init?.method ?? 'GET').toUpperCase();
if (method === 'GET' && path === '/sdk/v1/resources/items') {
return Promise.resolve(new Response(JSON.stringify({ items: [row], total: 1 }), { status: 200 }));
}
if (path === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({
effects: [{
runId: 'run-1',
scenarioSlug: 'scenario-1',
nodeId: 'start',
type: 'notification',
data: { message: 'Welcome!' },
}],
}), { status: 200 }));
if (method === 'GET' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
}
if (path === '/sdk/v1/scenarios/pending') {
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 }));
if (method === 'POST' && path === '/sdk/v1/resources/items') {
return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
}
if (path === '/sdk/v1/remote-configs') {
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
if (method === 'PATCH' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(JSON.stringify({ ...row, version: 2 }), { status: 200 }));
}
if (path === '/sdk/v1/stores') {
return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 }));
if (method === 'DELETE' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(null, { status: 204 }));
}
if (path === '/sdk/v1/catalog') {
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 }));
}
if (path === '/sdk/v1/sync') {
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
}
void init;
return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 }));
}));
return Promise.resolve(new Response('unhandled', { status: 500 }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(notifications).toEqual(['Welcome!']));
client.dispose();
const handle = client.resources('items');
expect(await handle.list()).toEqual({ items: [row], total: 1 });
expect(await handle.get('row-1')).toEqual(row);
expect(await handle.create({ data: { name: 'Sword' } })).toEqual(row);
expect(await handle.update('row-1', { data: { name: 'Sword' }, version: 1 })).toEqual({
...row,
version: 2,
});
expect(await handle.delete('row-1')).toBeUndefined();
});
});
-416
View File
@@ -1,416 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ScenarioService } from '../src/scenario/ScenarioService.js';
import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js';
import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
import { effect } from './helpers/plan.js';
import { RudderHttpError } from '../src/client/RudderError.js';
import type { NotificationEffect, StoreOfferEffect, WaitEffect } from '../src/index.js';
import type { PendingEffect } from '../src/generated/scenarios.js';
async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-key',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
return { client, scenarios: await getScenarioRuntime(client) };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status });
}
function stubFetch(handler: (path: string, method: string, body: unknown) => Response | Promise<Response>): void {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const parsed = new URL(url);
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : undefined;
return Promise.resolve(handler(parsed.pathname, method, body));
}));
}
describe('ScenarioService', () => {
beforeEach(() => {
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('send (trigger)', () => {
it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
client.effects.onNotification(onNotification);
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({
effects: [effect('notification', { title: 'Hi', message: 'Welcome' })],
});
}
return jsonResponse({});
});
const response = await scenarios.send('level_complete');
expect(response.effects).toHaveLength(1);
expect(scenarios.isRunning).toBe(true);
expect(onNotification).toHaveBeenCalledOnce();
expect(onNotification.mock.calls[0][0].title).toBe('Hi');
expect(onNotification.mock.calls[0][0].message).toBe('Welcome');
});
});
describe('effect emission', () => {
it('notification node fires onNotification', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
client.effects.onNotification(onNotif);
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
});
it('store node fires onStoreOffer', async () => {
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
client.effects.onStoreOffer(onStore);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({
effects: [effect('store', { storeSlug: 'starter', message: 'Buy!' })],
});
}
if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] });
}
return jsonResponse({});
});
await scenarios.send('test');
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
const offer = onStore.mock.calls[0][0] as StoreOfferEffect;
expect(offer.message).toBe('Buy!');
expect(offer.store.slug).toBe('starter');
});
it('wait node fires onWait', async () => {
const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn();
client.effects.onWait(onWait);
const deadline = new Date(Date.now() + 60_000).toISOString();
stubFetch(() => jsonResponse({
effects: [effect('wait', {}, { waitDeadline: deadline })],
}));
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
expect(onWait.mock.calls[0][0].deadlineUtc).toBeInstanceOf(Date);
});
it('leaderboard node fires onLeaderboard', async () => {
const { client, scenarios } = await createClientWithScenario();
const onLb = vi.fn();
client.effects.onLeaderboard(onLb);
stubFetch(() => jsonResponse({ effects: [effect('leaderboard')] }));
await scenarios.send('test');
expect(onLb).toHaveBeenCalledOnce();
});
it('quest node dispatches onQuest', async () => {
const { client, scenarios } = await createClientWithScenario();
const onQuest = vi.fn();
client.effects.onQuest(onQuest);
stubFetch(() => jsonResponse({
effects: [effect('quest', { name: 'Daily', objectives: [{ objectiveId: 'kills', target: 10 }] })],
}));
await scenarios.send('test');
expect(onQuest).toHaveBeenCalledOnce();
expect(onQuest.mock.calls[0][0].name).toBe('Daily');
expect(scenarios.isRunning).toBe(true);
});
it('battlepass node dispatches onBattlePass', async () => {
const { client, scenarios } = await createClientWithScenario();
const onBp = vi.fn();
client.effects.onBattlePass(onBp);
stubFetch(() => jsonResponse({ effects: [effect('battlepass', { premiumPrice: 100 })] }));
await scenarios.send('test');
expect(onBp).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(true);
});
it('battlepass_level node dispatches onBattlePassLevel', async () => {
const { client, scenarios } = await createClientWithScenario();
const onLevel = vi.fn();
client.effects.onBattlePassLevel(onLevel);
stubFetch(() => jsonResponse({
effects: [effect('battlepass_level', { levelNumber: 3 })],
}));
await scenarios.send('test');
expect(onLevel).toHaveBeenCalledOnce();
expect(onLevel.mock.calls[0][0].level).toBe(3);
});
it('unsupported node type fails the run and surfaces onScenarioFailed', async () => {
const { client, scenarios } = await createClientWithScenario();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const onFailed = vi.fn();
client.effects.onScenarioFailed(onFailed);
stubFetch(() => jsonResponse({ effects: [effect('unknown_type')] }));
await scenarios.send('test');
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Unsupported scenario node type'),
);
expect(onFailed).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(false);
warn.mockRestore();
});
});
describe('callback continuation', () => {
it('completing an effect posts callback and emits the next effect', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
client.effects.onNotification(onNotif);
const first = effect('notification', { message: 'one' }, { nodeId: 'n1' });
const second = effect('notification', { message: 'two' }, { nodeId: 'n2' });
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [first] });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ effect: second });
}
return jsonResponse({});
});
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
const session = onNotif.mock.calls[0][0] as NotificationEffect;
await session.done();
expect(onNotif).toHaveBeenCalledTimes(2);
expect(onNotif.mock.calls[1][0].message).toBe('two');
const fetchMock = vi.mocked(fetch);
const callbackCall = fetchMock.mock.calls.find((call) => {
const url = String(call[0]);
return url.includes('/sdk/v1/scenarios/callback');
});
expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({
scenarioSlug: 'scenario-1',
nodeId: 'n1',
handle: 'output',
runId: 'run-1',
});
});
it('completing the last effect emits onScenarioCompleted', async () => {
const { client, scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onNotif = vi.fn();
client.effects.onScenarioCompleted(onCompleted);
client.effects.onNotification(onNotif);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('notification')] });
}
return jsonResponse({});
});
await scenarios.send('test');
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
expect(onCompleted).toHaveBeenCalledOnce();
expect(onCompleted.mock.calls[0][0]).toEqual({
runId: 'run-1',
scenarioSlug: 'scenario-1',
});
expect(scenarios.isRunning).toBe(false);
});
});
describe('pending dedup', () => {
it('does not re-emit an effect with the same runId and nodeId', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
client.effects.onNotification(onNotification);
const pending = effect('notification', { message: 'once' });
stubFetch(() => jsonResponse({ effects: [pending] }));
await scenarios.send('first-event');
await scenarios.send('second-event');
expect(onNotification).toHaveBeenCalledTimes(1);
expect(scenarios.isRunning).toBe(true);
});
});
describe('waitDeadline timer', () => {
it('polls pending at waitDeadline and emits the next effect', async () => {
vi.useFakeTimers();
const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn();
const onNotif = vi.fn();
client.effects.onWait(onWait);
client.effects.onNotification(onNotif);
const deadline = new Date(Date.now() + 5_000).toISOString();
const wait = effect('wait', {}, { waitDeadline: deadline, nodeId: 'wait-1' });
const next = effect('notification', { message: 'after wait' }, { nodeId: 'n2' });
let pending: PendingEffect[] = [wait];
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [wait] });
}
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
return jsonResponse({ effects: pending });
}
return jsonResponse({});
});
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
expect(onNotif).not.toHaveBeenCalled();
pending = [next];
await vi.advanceTimersByTimeAsync(5_000);
expect(onNotif).toHaveBeenCalledOnce();
expect(onNotif.mock.calls[0][0].message).toBe('after wait');
const session = onWait.mock.calls[0][0] as WaitEffect;
expect(session.deadlineUtc.toISOString()).toBe(deadline);
});
});
describe('expired-run drop', () => {
it('drops the run on unknown_run and does not retry it', async () => {
const { client, scenarios } = await createClientWithScenario();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const onFailed = vi.fn();
const onNotif = vi.fn();
client.effects.onScenarioFailed(onFailed);
client.effects.onNotification(onNotif);
const pending = effect('notification', { message: 'hello' });
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [pending] });
}
if (path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ code: 'unknown_run', error: 'gone' }, 410);
}
if (path === '/sdk/v1/scenarios/pending') {
return jsonResponse({ effects: [pending] });
}
return jsonResponse({});
});
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
await expect((onNotif.mock.calls[0][0] as NotificationEffect).done())
.rejects.toBeInstanceOf(RudderHttpError);
expect(onFailed).toHaveBeenCalledOnce();
expect(onFailed.mock.calls[0][0].runId).toBe('run-1');
expect(scenarios.isRunning).toBe(false);
await scenarios.start();
expect(onNotif).toHaveBeenCalledTimes(1);
warn.mockRestore();
client.dispose();
});
it('leaves the effect active on a transient callback error', async () => {
const { client, scenarios } = await createClientWithScenario();
const onFailed = vi.fn();
const onNotif = vi.fn();
client.effects.onScenarioFailed(onFailed);
client.effects.onNotification(onNotif);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('notification')] });
}
if (path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ error: 'boom' }, 500);
}
return jsonResponse({});
});
await scenarios.send('test');
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
expect(onFailed).not.toHaveBeenCalled();
expect(scenarios.isRunning).toBe(true);
});
});
describe('store session', () => {
it('buy() purchases the selected offer and completes with onPurchase', async () => {
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
client.effects.onStoreOffer(onStore);
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
}
if (method === 'GET' && path === '/sdk/v1/stores/starter') {
return jsonResponse({
name: 'Starter',
slug: 'starter',
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
});
}
if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack-1/purchase') {
return jsonResponse({ success: true, purchaseId: 'purchase-1' });
}
return jsonResponse({});
});
await scenarios.send('test');
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
const session = onStore.mock.calls[0][0] as StoreOfferEffect;
const purchase = await session.buy(session.offers[0]);
expect(purchase.success).toBe(true);
const duplicate = await session.buy(session.offers[0]);
expect(duplicate.success).toBe(false);
});
it('dismiss() completes with onDecline', async () => {
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
client.effects.onStoreOffer(onStore);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
}
if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1', slug: 'pack-1' }] });
}
return jsonResponse({});
});
await scenarios.send('test');
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
await (onStore.mock.calls[0][0] as StoreOfferEffect).dismiss();
const fetchMock = vi.mocked(fetch);
const callbackCall = fetchMock.mock.calls.find((call) =>
String(call[0]).includes('/sdk/v1/scenarios/callback'),
);
expect(JSON.parse(callbackCall![1]!.body as string).handle).toBe('onDecline');
});
});
describe('clear', () => {
it('clear removes all runs', async () => {
const { client, scenarios } = await createClientWithScenario();
client.effects.onNotification(vi.fn());
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
await scenarios.send('test');
expect(scenarios.isRunning).toBe(true);
scenarios.clear();
expect(scenarios.isRunning).toBe(false);
});
});
});
+7 -15
View File
@@ -2,16 +2,11 @@
// reach the admin API for wallet grants / player inspection.
import { RudderClient } from '../../../src/client/RudderClient.js';
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
import type { TokenStore } from '../../../src/token/TokenStore.js';
import { createAdminClient, type AdminClient } from './adminClient.js';
import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js';
type RuntimeOptions = {
loginEvent?: string | null;
};
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
export function loadArtifact(): SeedArtifact {
const file = readArtifact();
@@ -44,25 +39,22 @@ export function createInMemoryTokenStore(): TokenStore {
};
}
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
/** A client with no auth yet (in-memory tokens). */
export function makeProdClient(
artifact: SeedArtifact = loadArtifact(),
runtime: RuntimeOptions = { loginEvent: null },
): RudderClient<TConfig> {
return new RudderClient<TConfig>({
): RudderClient {
return new RudderClient({
baseUrl: artifact.baseUrl,
projectKey: artifact.projectKey,
tokenStore: createInMemoryTokenStore(),
runtime,
});
}
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>(
export async function freshPlayer(
artifact: SeedArtifact = loadArtifact(),
runtime?: RuntimeOptions,
): Promise<RudderClient<TConfig>> {
const client = makeProdClient<TConfig>(artifact, runtime);
): Promise<RudderClient> {
const client = makeProdClient(artifact);
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
return client;
}
+4 -288
View File
@@ -1,108 +1,20 @@
// Production seed: registers an operator, creates a project, configures staging
// content (counter, item, remote configs, leaderboard, store with free+paid offers, a
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
//
// The scenario flow uses the server engine's node types, `data` keys and handle names —
// the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
// and .../fsm), so what we store is what the server runs.
// Production seed: registers an operator and creates a project. Creating a
// project provisions staging + prod environments; the prod SDK key is what
// the runtime SDK sends as projectKey.
import { createAdminClient, type AdminClient } from './adminClient.js';
import { createAdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
interface ReleaseResponse {
id: string;
status: string;
version: number;
}
interface QuestResponse {
id: string;
slug: string;
name: string;
status: string;
objectives?: Array<{ id: string; metric: string; target: number }>;
rewards?: Array<{ itemId?: string; currency?: string; amount: number }>;
}
interface StoreResponse {
id: string;
name: string;
slug: string;
offers?: Array<{
id: string;
slug: string;
name: string;
contents?: Array<{ itemId: string; amount: number }>;
}>;
}
function buildFlow(scenarioSlug: string) {
return {
nodes: [
{ id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } },
{ id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } },
{
id: 'rc_1',
type: 'remote_config_override',
data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] },
},
{ id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } },
{ id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } },
{ id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } },
],
edges: [
{ id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' },
{ id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' },
{ id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' },
{ id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' },
{ id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' },
],
};
}
async function pollRelease(
api: AdminClient,
projPath: string,
env: string,
releaseId: string,
log: (m: string) => void,
): Promise<void> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const releases = await api.get<ReleaseResponse[]>(
`${projPath}/releases?environment=${env}&limit=20&offset=0`,
);
const rel = releases.find((r) => r.id === releaseId);
if (rel) {
if (rel.status === 'completed') {
log(`release ${releaseId} completed (v${rel.version})`);
return;
}
if (rel.status === 'failed') {
throw new Error(`release ${releaseId} failed`);
}
}
await sleep(1500);
}
throw new Error(`release ${releaseId} did not complete within 60s`);
}
export async function runSeed(
baseUrl: string,
log: (m: string) => void = () => {},
): Promise<SeedArtifact> {
const api = createAdminClient(baseUrl);
const ts = Date.now();
const authoringEnv = 'staging';
const runtimeEnv = 'prod';
const q = `?environment=${authoringEnv}`;
const prodQ = `?environment=${runtimeEnv}`;
const adminEmail = `e2e+${ts}@rudder.build`;
const password = `Passw0rd!e2e-${ts}`;
// 1. Register operator (open registration; no approval gate at the API layer).
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
'/platform/v1/authorization/register',
{ email: adminEmail, password, fullName: 'E2E Bot' },
@@ -111,8 +23,6 @@ export async function runSeed(
api.setToken(auth.accessToken);
log(`registered ${adminEmail}`);
// 2. Project. Creating one provisions the staging + prod environments, each with
// its own SDK key — that key is what the runtime SDK sends as projectKey.
const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
name: `e2e-${ts}`,
});
@@ -126,168 +36,6 @@ export async function runSeed(
}
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
// 3. Soft currency counter — offer prices are validated against counter slugs, and
// the purchase debit resolves the currency through the catalog counter cache.
const currency = 'soft';
await api.post(`${projPath}/counters${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Soft Currency',
slug: currency,
kind: 'currency',
});
// 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Gold Coin',
slug: 'gold-coin',
});
// 5. Remote configs — one per value type. RCs are live per-env flags served
// straight from the cache and are not copied by a promote, so they go directly
// into the runtime env.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
{ key: 'feature_x', value: 'true', valueType: 'bool' },
{ key: 'welcome_text', value: 'hi', valueType: 'string' },
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
];
for (const rc of remoteConfigs) {
await api.post(`${projPath}/remote-configs${prodQ}`, {
projectId: project.id,
environment: runtimeEnv,
...rc,
});
}
log(`created ${remoteConfigs.length} remote configs`);
// 6. Leaderboard.
await api.post(`${projPath}/leaderboards${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Weekly Score',
slug: 'weekly-score',
metric: 'score',
resetPeriod: 'weekly',
sortingOrder: 'desc',
maxEntries: 100,
});
// 7. Store with a free offer and a paid (currency) offer with a purchase limit.
// Stores and offers carry an authored slug that survives promotes.
const storeSlug = 'starter-shop';
const paidOfferSlug = 'gold-pack';
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: storeSlug,
name: 'Starter Shop',
description: 'E2E shop',
offers: [
{
slug: 'welcome-gift',
name: 'Welcome Gift',
contents: [{ itemId: item.id, amount: 10 }],
price: { currency, amount: 0 },
},
{
slug: paidOfferSlug,
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency, amount: 100 },
maxPurchases: 2,
},
],
});
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
const questName = 'Buy Gold Pack Quest';
const questObjectiveId = 'buy-gold-pack';
const questTarget = 1;
const questSlug = 'buy-gold-pack-quest';
const questMetric = `purchase.offer:${paidOfferSlug}`;
const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: questSlug,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
});
log(`quest ${quest.id} staged for metric ${questMetric}`);
// 9. Scenario (must be live now: startAt <= now <= endAt).
const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenarioEvent = 'session_start';
const scenarioSlug = 'onboarding';
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: scenarioSlug,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
endAt,
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
projectId: project.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves the prod snapshot).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id,
fromEnvironment: authoringEnv,
toEnvironment: runtimeEnv,
});
await pollRelease(api, projPath, runtimeEnv, release.id, log);
// Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.slug === storeSlug);
if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
}
const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
if (!prodPaidOffer?.slug) {
throw new Error('promoted paid offer did not return a slug');
}
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
throw new Error('promoted paid offer did not return a reward item id');
}
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
const prodQuest = prodQuests.find((qq) => qq.slug === questSlug);
if (!prodQuest?.slug) {
throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`);
}
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) {
throw new Error(`promoted quest ${questSlug} did not return metric ${questObjectiveId}`);
}
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
if (!prodQuestRewardItemId) {
throw new Error(`promoted quest ${questSlug} did not return reward item id`);
}
const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
`${projPath}/scenarios${prodQ}`,
);
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
if (!prodScenario?.slug) {
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
}
return {
baseUrl,
projectId: project.id,
@@ -296,38 +44,6 @@ export async function runSeed(
adminEmail,
environment: runtimeEnv,
seededAt: new Date(ts).toISOString(),
releaseId: release.id,
store: {
name: 'Starter Shop',
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferSlug: prodPaidOffer.slug,
paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
slug: prodQuest.slug,
name: questName,
objectiveId: questObjectiveId,
metric: prodQuestMetric,
target: questTarget,
rewardItemId: prodQuestRewardItemId,
rewardAmount: questRewardAmount,
},
leaderboard: { slug: 'weekly-score', metric: 'score' },
item: { id: prodRewardItemId, name: item.name },
remoteConfigs: {
maxEnergy: 100,
spawnRate: 1.5,
featureX: true,
welcomeText: 'hi',
shopLayout: { cols: 3 },
spawnRateOverride: 3.0,
},
scenario: { slug: prodScenario.slug, event: scenarioEvent },
};
}
-33
View File
@@ -10,37 +10,4 @@ export interface SeedArtifact {
adminEmail: string;
environment: string;
seededAt: string;
releaseId: string;
store: {
name: string;
slug: string;
freeOfferName: string;
paidOfferName: string;
paidOfferSlug: string;
paidPrice: { currency: string; amount: number };
paidMaxPurchases: number;
grantedItemName: string;
paidGrantAmount: number;
};
quest: {
slug: string;
name: string;
objectiveId: string;
metric: string;
target: number;
rewardItemId: string;
rewardAmount: number;
};
leaderboard: { slug: string; metric: string };
item: { id: string; name: string };
remoteConfigs: {
maxEnergy: number;
spawnRate: number;
featureX: boolean;
welcomeText: string;
shopLayout: Record<string, unknown>;
/** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number;
};
scenario: { slug: string; event: string };
}
+1 -1
View File
@@ -20,6 +20,6 @@ describe('e2e-prod: health', () => {
expect(res.ok).toBe(true);
const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
expect(body.status).toBe('ready');
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok' });
});
});
-30
View File
@@ -1,30 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
describe('e2e-prod: leaderboards', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('submits a score and sees it in the ranking', async () => {
const client = await freshPlayer(artifact);
const me = (await client.player.reload()).player?.id;
expect(me).toBeTruthy();
const lb = client.leaderboards.findBySlug(artifact.leaderboard.slug);
const score = 4242;
await lb.submit(score);
const entries = await withReadRetry(
() => lb.list(50),
(list) => list.some((e) => e.playerId === me),
);
const mine = entries.find((e) => e.playerId === me);
expect(mine).toBeDefined();
// int64 fields (score, rank) serialize as JSON strings via protojson — coerce to number.
expect(Number(mine?.score)).toBe(score);
expect(Number(mine?.rank ?? 0)).toBeGreaterThanOrEqual(1);
});
});
-94
View File
@@ -1,94 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.js';
// Exercises the (newly implemented) wallet + inventory + purchase backend end-to-end:
// admin grant -> debit on buy -> inventory credit -> idempotency -> max_purchases -> insufficient funds.
interface AdminWalletDetails {
currencyCode: string;
balance: number;
}
interface AdminInventoryItem {
slug: string;
amount: number;
}
interface AdminPlayerDetails {
wallets?: AdminWalletDetails[];
inventory?: AdminInventoryItem[];
}
describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
async function resolvePaidOffer(client: RudderClient): Promise<OfferHandle> {
const stores = await withReadRetry(
() => client.stores.reload(),
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
);
const store = stores.find((s) => s.name === artifact.store.name)!;
return store.offers.find((o) => o.name === artifact.store.paidOfferName)!;
}
async function balance(client: RudderClient, currency: string): Promise<number> {
const profile = await client.player.reload();
// int64 fields serialize as JSON strings via protojson — coerce to number.
return Number(profile.wallets?.find((w) => w.currency === currency)?.balance ?? 0);
}
it('grants currency, debits on buy, credits inventory, is idempotent, and enforces the purchase limit', async () => {
const client = await freshPlayer(artifact);
const playerId = (await client.player.reload()).player!.id!;
const api = adminApi(artifact);
const currency = artifact.store.paidPrice.currency;
const price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client);
// 1. Grant currency via the admin endpoint.
await grantCurrency(artifact, playerId, currency, 1000);
const granted = await withReadRetry(
() => balance(client, currency),
(b) => b === 1000,
);
expect(granted).toBe(1000);
// 2. First paid buy: success + wallet debited.
const buy1 = await offer.buy();
expect(buy1.success).toBe(true);
expect(buy1.purchaseId).toBeTruthy();
expect(await balance(client, currency)).toBe(1000 - price);
// 3. Inventory credited (verified via admin player details).
const details = await api.get<AdminPlayerDetails>(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`,
);
const inv = details.inventory?.find((i) => i.slug === artifact.item.id);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
// 4. Idempotency: the same key twice charges once.
const key = crypto.randomUUID();
const a = await offer.buy({ idempotencyKey: key });
const b = await offer.buy({ idempotencyKey: key });
expect(a.success).toBe(true);
expect(b.success).toBe(true);
expect(await balance(client, currency)).toBe(1000 - price * 2);
// 5. max_purchases (2) reached -> a new distinct buy fails (no charge).
const overLimit = await offer.buy();
expect(overLimit.success).toBe(false);
expect(overLimit.error ?? '').toContain('limit');
expect(await balance(client, currency)).toBe(1000 - price * 2);
});
it('rejects a paid purchase with insufficient funds', async () => {
const poor = await freshPlayer(artifact);
const offer = await resolvePaidOffer(poor);
const res = await offer.buy();
expect(res.success).toBe(false);
expect(res.error ?? '').toContain('insufficient');
});
});
-33
View File
@@ -1,33 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
interface GameRemoteConfig extends Record<string, unknown> {
max_energy: number;
spawn_rate: number;
feature_x: boolean;
welcome_text: string;
shop_layout: Record<string, unknown>;
}
describe('e2e-prod: remoteConfig', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('loads typed configs matching the seeded values', async () => {
const rc = artifact.remoteConfigs;
const client = await withReadRetry(
() => freshPlayer<GameRemoteConfig>(artifact),
(candidate) => candidate.remoteConfig.get('max_energy', 0) === rc.maxEnergy,
);
expect(client.remoteConfig.status).toBe('ready');
expect(client.remoteConfig.get('max_energy', 0)).toBe(rc.maxEnergy);
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(rc.spawnRate);
expect(client.remoteConfig.get('feature_x', false)).toBe(rc.featureX);
expect(client.remoteConfig.get('welcome_text', '')).toBe(rc.welcomeText);
expect(client.remoteConfig.get('shop_layout', {} as Record<string, unknown>)).toEqual(rc.shopLayout);
});
});
-60
View File
@@ -1,60 +0,0 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { grantCurrency, loadArtifact, makeProdClient } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.js';
describe('e2e-prod: scenarios', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('runs onboarding: notification -> rc override -> store -> boundary callback -> notification', async () => {
const notifications: NotificationEffect[] = [];
let storeOffer: StoreOfferEffect | undefined;
let runCompleted = false;
const client = makeProdClient(artifact, {
loginEvent: artifact.scenario.event,
});
client.effects.onNotification((effect) => { notifications.push(effect); });
client.effects.onStoreOffer((effect) => {
storeOffer = effect;
});
const runtime = await getScenarioRuntime(client);
client.effects.onScenarioCompleted(() => {
runCompleted = true;
});
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
const playerId = (await client.player.reload()).player!.id!;
await grantCurrency(
artifact,
playerId,
artifact.store.paidPrice.currency,
artifact.store.paidPrice.amount,
);
// First notification dispatched.
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done();
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
await client.remoteConfig.reload();
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
artifact.remoteConfigs.spawnRateOverride,
);
// Buy crosses the server boundary (POST /sdk/v1/scenarios/callback) -> second notification.
const offer = storeOffer!.offers.find((o) => o.name === artifact.store.paidOfferName)!;
await storeOffer!.buy(offer);
await vi.waitFor(() => expect(notifications.length).toBe(2), { timeout: 15_000 });
await notifications[1].done();
await vi.waitFor(() => expect(runtime.isRunning).toBe(false), { timeout: 10_000 });
expect(runCompleted).toBe(true);
});
});
-37
View File
@@ -1,37 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
describe('e2e-prod: stores', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('lists the seeded store with its free + paid offers', async () => {
const client = await freshPlayer(artifact);
const stores = await withReadRetry(
() => client.stores.reload(),
(r) => r.length > 0,
);
const store = stores.find((s) => s.name === artifact.store.name);
expect(store).toBeDefined();
const offerNames = store!.offers.map((o) => o.name);
expect(offerNames).toContain(artifact.store.freeOfferName);
expect(offerNames).toContain(artifact.store.paidOfferName);
});
it('buys the free offer successfully', async () => {
const client = await freshPlayer(artifact);
const stores = await withReadRetry(
() => client.stores.reload(),
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
);
const store = stores.find((s) => s.name === artifact.store.name)!;
const free = store.offers.find((o) => o.name === artifact.store.freeOfferName)!;
const res = await free.buy();
expect(res.success).toBe(true);
expect(res.purchaseId).toBeTruthy();
});
});
-126
View File
@@ -1,126 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { effect } from '../helpers/plan.js';
import type { StoreOfferEffect } from '../../src/index.js';
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
});
}
describe('E2E: boundary nodes (server-side continuation)', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
stubDeterministicUuid();
gateway = createFakeGateway({
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}],
});
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reward',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onPurchase', reward);
const messages: string[] = [];
const completed: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); });
client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioSlug); });
const token = (await client.auth.loginWithDevice()).accessToken;
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]);
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
expect(callback?.body).toEqual({
scenarioSlug: 'offer_flow',
nodeId: 'offer',
handle: 'onPurchase',
runId: 'offer_flow-run',
});
expect(callback?.authToken).toBe(token);
expect(messages).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('Maybe next time!');
expect(completed).toEqual([]);
});
it('decline posts callback and continues with the server-supplied next effect', async () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'consolation',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onDecline', consolation);
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss();
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(true);
expect(messages).toEqual(['Maybe next time!']);
});
it('a failing callback does not fail or crash the run', async () => {
const offerEffect = effect('store', { storeSlug: 'starter' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallbackError('offer', 'onPurchase', 500);
const failures: unknown[] = [];
let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client);
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onScenarioFailed((e) => { failures.push(e); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
expect(failures).toHaveLength(0);
expect(runtime.isRunning).toBe(true);
});
});
-173
View File
@@ -1,173 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { effect } from '../helpers/plan.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
const MINUTE = 60_000;
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
});
}
function offerScenario(now: number) {
const waitIntro = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const waitReminder = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_reminder',
waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
});
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
return { waitIntro, offer, waitReminder, reminder, thanks };
}
describe('E2E: player offer journey', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
gateway = createFakeGateway({
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}],
});
gateway.install();
client = makeClient();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('authenticates the device and persists tokens for later calls', async () => {
const res = await client.auth.loginWithDevice({ region: 'eu', language: 'en' });
expect(res.accessToken).toBeTruthy();
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device');
expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
expect(trigger?.authToken).toBe(res.accessToken);
});
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
const notifications: NotificationEffect[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
await client.auth.loginWithDevice();
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
await offer!.dismiss();
expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_000);
expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Your offer is still available!');
});
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
let offer: StoreOfferEffect | undefined;
const notifications: NotificationEffect[] = [];
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
const accessToken = (await client.auth.loginWithDevice()).accessToken;
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
const selectedOffer = offer!.offers[0];
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
expect(purchase.success).toBe(true);
expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerSlug: 'pack-1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]);
expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Thanks for your purchase!');
});
it('declining does NOT take the purchase branch (handles are isolated)', async () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice();
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss();
await vi.advanceTimersByTimeAsync(MINUTE);
expect(messages).toEqual(['Your offer is still available!']);
expect(messages).not.toContain('Thanks for your purchase!');
expect(gateway.purchases).toHaveLength(0);
});
});
-73
View File
@@ -1,73 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { effect } from '../helpers/plan.js';
import type { StoreOfferEffect, WaitEffect } from '../../src/index.js';
const MINUTE = 60_000;
describe('E2E: pending effects resume after a new login', () => {
let gateway: FakeGateway;
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
}
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
gateway = createFakeGateway({
stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] }],
});
const now = Date.now();
const wait = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('session_start', wait);
gateway.onCallback('wait_intro', 'onComplete', offer);
gateway.install();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('a wait started before reload resumes from GET pending after login', async () => {
const client1 = makeClient();
await client1.auth.loginWithDevice();
const runtime1 = await getScenarioRuntime(client1);
await runtime1.send('session_start');
expect(runtime1.isRunning).toBe(true);
const client2 = makeClient();
const resumedWaits: WaitEffect[] = [];
let offer: StoreOfferEffect | undefined;
client2.effects.onWait((s) => { resumedWaits.push(s); });
client2.effects.onStoreOffer((s) => { offer = s; });
await client2.auth.loginWithDevice();
expect(resumedWaits).toHaveLength(1);
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
});
});
-78
View File
@@ -1,78 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js';
interface GameRemoteConfig extends Record<string, unknown> {
max_energy: number;
drop_rate: number;
pvp_enabled: boolean;
welcome_text: string;
boss: { hp: number; name: string };
legacy: string;
missing_declared: string;
}
function cfg(key: string, value: string, valueType: NonNullable<RemoteConfig['valueType']>, active = true): RemoteConfig {
return { key, value, valueType, active };
}
function makeClient(): RudderClient<GameRemoteConfig> {
return new RudderClient<GameRemoteConfig>({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
}
describe('E2E: remote config', () => {
let gateway: FakeGateway;
let client: RudderClient<GameRemoteConfig>;
beforeEach(() => {
stubDeterministicUuid();
gateway = createFakeGateway({
remoteConfigs: {
max_energy: cfg('max_energy', '50', 'int'),
drop_rate: cfg('drop_rate', '0.25', 'float'),
pvp_enabled: cfg('pvp_enabled', 'true', 'bool'),
welcome_text: cfg('welcome_text', 'Hello!', 'string'),
boss: cfg('boss', '{"hp":1000,"name":"Drake"}', 'json'),
legacy: cfg('legacy', 'old', 'string', false), // inactive — must be excluded
},
});
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('loads configs and reads them back with type coercion', async () => {
await client.auth.loginWithDevice();
expect(client.remoteConfig.status).toBe('ready');
expect(client.remoteConfig.get('max_energy', 0)).toBe(50);
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.25);
expect(client.remoteConfig.get('pvp_enabled', false)).toBe(true);
expect(client.remoteConfig.get('welcome_text', '')).toBe('Hello!');
expect(client.remoteConfig.get('boss', { hp: 0, name: '' })).toEqual({
hp: 1000,
name: 'Drake',
});
});
it('inactive configs are not exposed; missing keys fall back to default', async () => {
await client.auth.loginWithDevice();
expect(client.remoteConfig.get('legacy', 'DEFAULT')).toBe('DEFAULT');
expect(client.remoteConfig.get('missing_declared', 'fallback')).toBe('fallback');
});
it('get() before login returns the default (no throw)', () => {
expect(client.remoteConfig.status).toBe('idle');
expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
});
});
-3
View File
@@ -8,7 +8,6 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
}
@@ -46,13 +45,11 @@ describe('E2E: player storage', () => {
{ id: 'b', type: 'prefs', data: '{}' },
]);
// v0.2 fetches the whole storage; filtering by type is client-side.
const all = await client.storage.reload();
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
expect(prefs).toHaveLength(1);
expect(prefs[0].id).toBe('b');
// The read went out as a storage fetch with limit as a query param, not a path segment.
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
expect(getReq?.query.get('limit')).toBe('100');
});
+63 -142
View File
@@ -1,12 +1,9 @@
import { vi } from 'vitest';
import type { PendingEffect } from '../../src/generated/scenarios.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js';
import type { Store } from '../../src/generated/stores.js';
import type { StorageItem } from '../../src/generated/storage.js';
import type {
ClaimQuestResponse,
ListQuestsResponse,
} from '../../src/generated/quests.js';
import type { ResourceRow } from '../../src/generated/resources.js';
import type { CallModuleFunctionClientResponse } from '../../src/generated/modules.js';
import type { CatalogCurrency } from '../../src/generated/catalog.js';
import type { ProjectStorageItem } from '../../src/generated/project-storage.js';
export interface RecordedRequest {
method: string;
@@ -17,28 +14,17 @@ export interface RecordedRequest {
}
export interface FakeGatewayState {
scenarios: Map<string, PendingEffect[]>;
callbacks: Map<string, PendingEffect | null>;
callbackErrors: Map<string, { status: number; code?: string }>;
pendingEffects: PendingEffect[];
counterCompleted: boolean;
counterEffect?: PendingEffect;
remoteConfigs: Record<string, RemoteConfig>;
stores: Store[];
quests: ListQuestsResponse;
questClaims: Map<string, ClaimQuestResponse>;
storage: Map<string, StorageItem>;
projectStorage: Map<string, ProjectStorageItem>;
currencies: CatalogCurrency[];
resources: Map<string, Map<string, ResourceRow>>;
moduleCalls: CallModuleFunctionClientResponse;
}
export interface FakeGateway {
state: FakeGatewayState;
recorded: RecordedRequest[];
purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
questClaims: Array<{ questSlug: string; authToken: string | null }>;
install(): void;
onEvent(event: string, ...effects: PendingEffect[]): void;
onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
onCallbackError(nodeId: string, handle: string, status?: number, code?: string): void;
}
function json(body: unknown, status = 200): Response {
@@ -50,52 +36,16 @@ function json(body: unknown, status = 200): Response {
const noContent = (): Response => new Response(null, { status: 204 });
function replaceRun(pending: PendingEffect[], next: PendingEffect[]): PendingEffect[] {
const runIds = new Set(next.map((effect) => effect.runId));
return [...pending.filter((effect) => !runIds.has(effect.runId)), ...next];
}
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = {
scenarios: seed?.scenarios ?? new Map(),
callbacks: seed?.callbacks ?? new Map(),
callbackErrors: seed?.callbackErrors ?? new Map(),
pendingEffects: seed?.pendingEffects ?? [],
counterCompleted: seed?.counterCompleted ?? false,
counterEffect: seed?.counterEffect,
remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] },
questClaims: seed?.questClaims ?? new Map(),
storage: seed?.storage ?? new Map(),
projectStorage: seed?.projectStorage ?? new Map(),
currencies: seed?.currencies ?? [],
resources: seed?.resources ?? new Map(),
moduleCalls: seed?.moduleCalls ?? { result: {} },
};
const recorded: RecordedRequest[] = [];
const purchases: FakeGateway['purchases'] = [];
const questClaims: FakeGateway['questClaims'] = [];
function advanceExpiredWaits(): PendingEffect[] {
const now = Date.now();
const next: PendingEffect[] = [];
for (const effect of state.pendingEffects) {
if (
effect.type === 'wait' &&
effect.waitDeadline &&
Date.parse(effect.waitDeadline) <= now
) {
const continued = state.callbacks.get(`${effect.nodeId}:onComplete`);
if (continued === undefined) {
next.push(effect);
} else if (continued !== null) {
next.push(continued);
}
} else {
next.push(effect);
}
}
state.pendingEffects = next;
return next;
}
async function handle(req: RecordedRequest): Promise<Response> {
const { method, path, query, body } = req;
@@ -117,85 +67,54 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
}
if (method === 'GET' && path === '/sdk/v1/catalog') {
return json({ items: [] });
return json({ currencies: state.currencies });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
const event = (body as { event?: string }).event ?? '';
const effects = state.scenarios.get(event) ?? [];
state.pendingEffects = replaceRun(state.pendingEffects, effects);
return json({ effects });
}
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
return json({ effects: advanceExpiredWaits() });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
const b = body as { nodeId?: string; handle?: string; runId?: string };
const key = `${b.nodeId}:${b.handle}`;
const err = state.callbackErrors.get(key);
if (err) {
return json({ code: err.code ?? 'callback failed', error: 'callback failed' }, err.status);
}
const next = state.callbacks.get(key);
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
if (next) {
state.pendingEffects = replaceRun(state.pendingEffects, [next]);
return json({ effect: next });
}
return json({});
}
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
if (state.counterCompleted && state.counterEffect) {
state.pendingEffects = replaceRun(state.pendingEffects, [state.counterEffect]);
} else if (state.counterCompleted) {
const b = body as { nodeId?: string; runId?: string };
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
}
return json({ completed: state.counterCompleted, effect: state.counterEffect });
const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/);
if (method === 'POST' && moduleMatch) {
return json(state.moduleCalls);
}
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
return json({ configs: state.remoteConfigs });
const resourceItemMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)\/([^/]+)$/);
const resourceListMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)$/);
if (resourceItemMatch) {
const slug = decodeURIComponent(resourceItemMatch[1]);
const id = decodeURIComponent(resourceItemMatch[2]);
const rows = state.resources.get(slug) ?? new Map();
if (method === 'GET') {
const row = rows.get(id);
return row ? json(row) : json({ error: 'not found' }, 404);
}
if (method === 'GET' && path.startsWith('/sdk/v1/remote-configs/')) {
const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length));
const cfg = state.remoteConfigs[key];
return cfg ? json(cfg) : json({ error: 'not found' }, 404);
if (method === 'PATCH') {
const existing = rows.get(id);
if (!existing) return json({ error: 'not found' }, 404);
const next = { ...existing, ...(body as ResourceRow), id };
rows.set(id, next);
state.resources.set(slug, rows);
return json(next);
}
if (method === 'GET' && path === '/sdk/v1/stores') {
return json({ stores: state.stores, total: state.stores.length });
if (method === 'DELETE') {
rows.delete(id);
state.resources.set(slug, rows);
return noContent();
}
const purchaseMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)\/offers\/([^/]+)\/purchase$/);
if (method === 'POST' && purchaseMatch) {
const b = body as { idempotencyKey?: string };
purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]),
offerSlug: decodeURIComponent(purchaseMatch[2]),
idempotencyKey: b.idempotencyKey ?? '',
authToken: req.authToken,
});
return json({ success: true, purchaseId: `purchase-${purchases.length}` });
}
const storeMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)$/);
if (method === 'GET' && storeMatch) {
const slug = decodeURIComponent(storeMatch[1]);
const store = state.stores.find((s) => s.slug === slug);
return store ? json(store) : json({ error: 'not found' }, 404);
if (resourceListMatch) {
const slug = decodeURIComponent(resourceListMatch[1]);
const rows = state.resources.get(slug) ?? new Map();
if (method === 'GET') {
return json({ items: [...rows.values()], total: rows.size });
}
if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests);
if (method === 'POST') {
const created: ResourceRow = {
id: (body as ResourceRow).id ?? `row-${rows.size + 1}`,
data: (body as ResourceRow).data,
version: 1,
};
rows.set(created.id!, created);
state.resources.set(slug, rows);
return json(created);
}
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questSlug?: string };
const questSlug = b.questSlug ?? '';
questClaims.push({ questSlug, authToken: req.authToken });
return json(state.questClaims.get(questSlug) ?? { success: false, error: 'not found' });
}
if (path === '/sdk/v1/storage') {
@@ -222,6 +141,19 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
}
}
if (path === '/sdk/v1/project-storage') {
if (method === 'GET') {
return json({ items: [...state.projectStorage.values()], nextCursor: '' });
}
if (method === 'PUT') {
const items = (body as { items?: ProjectStorageItem[] }).items ?? [];
for (const item of items) {
if (item.id) state.projectStorage.set(item.id, item);
}
return noContent();
}
}
return json({ error: `unhandled route: ${method} ${path}` }, 500);
}
@@ -248,17 +180,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return {
state,
recorded,
purchases,
questClaims,
install,
onEvent(event, ...effects) {
state.scenarios.set(event, effects);
},
onCallback(nodeId, handle, next) {
state.callbacks.set(`${nodeId}:${handle}`, next);
},
onCallbackError(nodeId, handle, status = 500, code) {
state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
},
};
}
-15
View File
@@ -1,15 +0,0 @@
export function stubDeterministicUuid(): void {
let n = 0;
const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto);
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
...existing,
randomUUID: () => {
n += 1;
const hex = n.toString(16).padStart(12, '0');
return `00000000-0000-4000-a000-${hex}` as `${string}-${string}-${string}-${string}-${string}`;
},
},
});
}
-16
View File
@@ -1,16 +0,0 @@
import type { PendingEffect } from '../../src/generated/scenarios.js';
export function effect(
type: string,
data: Record<string, unknown> = {},
overrides: Partial<PendingEffect> = {},
): PendingEffect {
return {
runId: 'run-1',
scenarioSlug: 'scenario-1',
nodeId: `${type}-1`,
type,
data,
...overrides,
};
}
+1 -2
View File
@@ -6,8 +6,7 @@ export default defineConfig({
target: 'es2022',
dts: true,
clean: true,
// Code-split the lazily-imported scenario client out of the initial chunk.
splitting: true,
splitting: false,
treeshake: true,
platform: 'browser',
outDir: 'dist',