8 Commits

Author SHA1 Message Date
edmand46 8863ef80e2 Release 1.0.0
CI / publish (push) Successful in 9s
CI / check (push) Successful in 14s
2026-09-04 15:36:37 +03:00
edmand46 1491b5e357 Server-side scenario execution: thin effects client replaces local engine
- engine/ (DagWalker, sessions, IndexedDbPlanStore) deleted; server owns the graph
- trigger/callback/counter carry PendingEffect; GET /sdk/v1/scenarios/pending polled
  (30s jittered heartbeat paused on hidden tab + wait-deadline timers)
- client.effects public API unchanged (done/buy/dismiss/end/claim/battlepass/quest)
- planStateStore removed; loginEvent kept; reconcile() makes server the source of truth
- generated models regenerated; skills/README/CHANGELOG updated
2026-09-04 14:08:48 +03:00
edmand46 ecbbf93951 Regenerate player models with avatarUrl
CI / check (push) Successful in 1m4s
CI / publish (push) Has been skipped
2026-09-04 12:40:54 +03:00
edmand46 8753239fbd Add agent skill (SKILL.md + per-domain reference)
CI / check (push) Successful in 55s
CI / publish (push) Has been skipped
2026-08-29 11:46:20 +03:00
edmand46 04e3565412 0.6.0: LeaderboardSession.claim, rank_not_eligible is recoverable
CI / publish (push) Successful in 27s
CI / check (push) Successful in 59s
onClaim replaces onRewardClaimed. rewardClaimed() is a deprecated alias removed in the next version.
2026-08-29 11:33:01 +03:00
edmand46 19a296ab9a ci: publish with NPM_TOKEN PAT (job GITEA_TOKEN cannot write org packages)
CI / publish (push) Successful in 29s
CI / check (push) Successful in 58s
2026-08-28 18:42:59 +03:00
edmand46 a72989402e 0.5.1: custom quest metrics no-op, doc fix
CI / publish (push) Failing after 31s
CI / check (push) Successful in 1m5s
2026-08-28 17:38:02 +03:00
edmand46 3556c0b035 ci: grant packages:write to publish job (GITEA_TOKEN 401 on npm publish) 2026-08-28 17:38:02 +03:00
45 changed files with 1905 additions and 2005 deletions
+4 -1
View File
@@ -35,6 +35,9 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
needs: check
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -55,4 +58,4 @@ jobs:
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITEA_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## 1.0.0
- Scenario execution moved server-side. The SDK no longer walks a local DAG
or persists plan state to IndexedDB.
- Removed `RudderClientOptions.runtime.planStateStore` and the
`PlanStateStore` type.
- `GET /sdk/v1/scenarios/pending` is polled on login, on a ~30s heartbeat,
and at each effect `waitDeadline`. Completions POST
`/sdk/v1/scenarios/callback`; the response may carry the next
`PendingEffect`.
- Scenario `remote_config_override` nodes no longer emit `onConfigChanged`
(the server applies the override). The `ConfigChangedEffect` type and
`onConfigChanged` subscription remain.
## 0.6.0
- `LeaderboardSession.claim()` / effect `claim()` completes the leaderboard
node with handle `onClaim`. The server matches live rank to an authored
place and continues from that place (Grant Reward and/or In-App Message).
- `rewardClaimed()` is a deprecated alias for `claim()` and will be removed
in the next SDK version.
- `rank_not_eligible` on Claim leaves the session open (retry Claim or End);
it no longer fails the run.
## 0.5.1
- `QuestMetrics` / `reportProgress`: custom free-text metrics no longer
progress quests. Report a released catalog counter slug instead.
`purchaseOffer` / `purchaseItem` helpers are unchanged (server-side shop
fan-out).
## 0.5.0
- `client.auth.loginWithCustom({ customData, region?, language?, nickname? })`
+5 -2
View File
@@ -115,8 +115,11 @@ The scenario runtime is not exposed directly; scenario nodes surface through
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
- `onScenarioCompleted`, `onScenarioFailed`
The scenario engine (and its IndexedDB persistence) loads lazily on first
login — a client that only reads domains never pulls it into the page.
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`).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@rudder/js-sdk",
"version": "0.5.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rudder/js-sdk",
"version": "0.5.0",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^26.1.1",
"jsdom": "^29.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@rudder/js-sdk",
"version": "0.5.0",
"version": "1.0.0",
"publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
},
+136
View File
@@ -0,0 +1,136 @@
---
name: rudder-web-sdk
description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/custom auth, observable domains (player, inventory, catalog, stores, remote config, storage), leaderboards, quests, battle pass, scenario effects, and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears.
---
# Rudder Web SDK (@rudder/js-sdk)
Browser-facing player SDK for the Rudder LiveOps platform. Source of truth:
`liveops-web-sdk/src` (generated wire types in `src/generated` come from the
gateway's apigen — never edit them by hand).
## Install
Published to a private registry, not npmjs. Point the `@rudder` scope at it in
`.npmrc` (anonymous read, no token):
```
@rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/
```
```bash
npm install @rudder/js-sdk
```
ESM + CJS + `.d.ts`; `type: module`; zero runtime dependencies.
## Init
```ts
import { RudderClient } from '@rudder/js-sdk';
const client = new RudderClient({
baseUrl: 'https://api.rudder.build', // required
projectKey: 'your-project-key', // required
// tokenStore?: TokenStore — default: localStorage, in-memory fallback
// requestTimeoutMs?: number — default 10000
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
// onEffectError?: (error) => void — default console.error
// runtime?: { loginEvent? } — advanced, see reference/scenarios.md
});
```
`client` is generic: `RudderClient<TConfig extends Record<string, unknown>>`
types `client.remoteConfig.get()`. Missing `baseUrl`/`projectKey` throws
`RudderError` with `code: 'sdk/invalid-options'` from the constructor.
Call `client.dispose()` on unmount/HMR to stop the sync poll, wait timers, and
drop cached state.
## Auth essentials
```ts
await client.auth.loginWithDevice({ region, language, nickname }); // device id auto-generated/persisted
await client.auth.loginWithCustom({ customData, region, language, nickname });
client.auth.logout();
client.auth.isAuthenticated; // boolean
client.auth.onAuthStateChange(cb); // fires immediately, returns unsubscribe
```
Tokens are saved to the `TokenStore` automatically. The transport injects
`Authorization: Bearer <token>`, retries GETs, and single-flight refreshes on
401; a failed refresh clears tokens and emits `signed-out`. Details:
[reference/auth.md](reference/auth.md).
## Client surface (all domains that exist)
Every member below is a property on `RudderClient`. There is no UGC, wallet
service, or standalone economy service in this SDK — wallets live on
`client.player.data.wallets`, and purchases go through `client.stores`.
| Area | Access | Kind |
|---|---|---|
| Auth | `client.auth` | service |
| Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` |
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` |
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerId, opts?)` | observable `SyncedState<ShopHandle[]>` |
| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation |
| Leaderboards | `client.leaderboards.findBySlug(slug)` | service → cached handle |
| Battle pass | `client.battlePass` | plain service (no live sync) |
| Quests | `client.quests` | plain service (no live sync) |
| Scenario effects | `client.effects.on*` | event subscriptions |
## Observable domains — the shared pattern
`player`, `catalog`, `inventory`, `remoteConfig`, `storage`,
`projectStorage`, and `stores` all extend `SyncedState<T>`:
- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`,
`.error?: Error`
- `onChange(cb: (snapshot: SyncedSnapshot<T>) => void): () => void` — fires
immediately with the current snapshot; maps onto React `useSyncExternalStore`
- `load(): Promise<T>` — deduplicated, no-op when ready
- `reload(): Promise<T>` — forced refetch
`remoteConfig`, `player`, `stores`, and `catalog` are warmed once at login;
`inventory`, `storage`, and `projectStorage` load on first use. All observable
domains are then kept fresh by a revision poll every 30 s (±20% jitter);
polling pauses while the tab is hidden. Mutations (purchases, storage writes,
scenario callbacks) invalidate affected domains immediately. Call `reload()`
when you need a freshness guarantee.
`leaderboards`, `battlePass`, and `quests` are NOT observable — refetch
explicitly after mutations.
## Errors
All SDK errors extend `RudderError` (optional machine-readable `code`):
- `RudderNetworkError` — fetch failed / timeout (`error.cause` holds the original)
- `RudderHttpError` — non-2xx, carries `status`, `statusText`, `body`, `code`
- `RudderAuthError extends RudderHttpError` — 401 after failed refresh; tokens
already cleared, `signed-out` already emitted
- `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`)
— constructor validation
Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`):
`early_completion`, `forbidden`, `level_not_reached`, `node_not_active`,
`objectives_incomplete`, `run_expired`, `run_not_active`,
`scenario_not_active`, `unknown_run`. Compare with
`error.code === RudderErrorCodes.runExpired`.
## Reference files
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
- [reference/inventory.md](reference/inventory.md) — inventory domain, merged `InventoryItem`, catalog
- [reference/stores.md](reference/stores.md) — `ShopHandle`/`OfferHandle`, purchases, idempotency
- [reference/remote-config.md](reference/remote-config.md) — typed `get()`, value types, overrides
- [reference/storage.md](reference/storage.md) — player + project storage, limits
- [reference/leaderboards.md](reference/leaderboards.md) — handles, submit, list
- [reference/quests.md](reference/quests.md) — list/claim/reportProgress, `QuestMetrics`
- [reference/battlepass.md](reference/battlepass.md) — progress, XP, claims, premium
- [reference/scenarios.md](reference/scenarios.md) — effects, scenario runtime, wait/quest/battlepass nodes
+106
View File
@@ -0,0 +1,106 @@
# Auth — `client.auth`
`AuthService` (source: `src/auth/AuthService.ts`). Player authentication:
device ID login (primary for game clients), custom webhook login, logout, and
auth state observation.
## Methods
```ts
loginWithDevice(options?: LoginWithDeviceOptions): Promise<LoginViaDeviceResponse>
loginWithCustom(options: LoginWithCustomOptions): Promise<LoginViaCustomResponse>
logout(): void
get isAuthenticated(): boolean
onAuthStateChange(listener: AuthStateListener): () => void
```
### `LoginWithDeviceOptions`
```ts
{
region?: string; // default 'global'
language?: string; // default 'en'
nickname?: string; // omitted from the request when not set
}
```
The device ID is auto-generated on first call and persisted in localStorage
(`src/device/DeviceId.ts`). The request also carries the client's
`projectKey`.
### `LoginWithCustomOptions`
```ts
{
customData: Record<string, unknown>; // required — forwarded to the project's custom auth webhook
region?: string; // default 'global'
language?: string; // default 'en'
nickname?: string;
}
```
### Login responses
```ts
interface LoginViaDeviceResponse { accessToken?: string; refreshToken?: string }
interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string }
```
On success both tokens are saved to the client's `TokenStore`, the runtime
starts (domains warmed, scenario engine restored, `player_login` event fired),
and the state flips to `'signed-in'`.
## Auth state
```ts
type AuthState = 'signed-in' | 'signed-out';
type AuthStateListener = (state: AuthState) => void;
```
- `isAuthenticated` is `true` while an access token is present in the token store.
- `onAuthStateChange` fires the listener **immediately** with the current state
and returns an unsubscribe function.
- `logout()` clears tokens, stops the runtime (sync poll, scenario runs,
cached domain data), and emits `'signed-out'`.
## Token refresh (automatic, transport level)
Source: `src/transport/request.ts`.
- Every request injects `Authorization: Bearer <accessToken>` when a token exists.
- On 401 the transport does a single-flight refresh against
`POST /sdk/v1/authorization/refresh` (concurrent 401s share one refresh) and
retries the original request once.
- If refresh fails, tokens are cleared, `onAuthStateChange` listeners get
`'signed-out'`, and the request throws `RudderAuthError`.
## TokenStore
```ts
interface TokenStore {
getAccessToken(): string | null;
getRefreshToken(): string | null;
saveTokens(accessToken: string, refreshToken: string): void;
clear(): void;
}
```
Factories (exported from the package root):
- `createDefaultTokenStore()` — localStorage, with a silent in-memory fallback
where localStorage is unavailable (SSR, private mode). This is the default
when `tokenStore` is omitted from `RudderClientOptions`.
- `createLocalStorageTokenStore()` — keys `rudder_access_token` /
`rudder_refresh_token`.
Provide a custom `TokenStore` via `RudderClientOptions.tokenStore` for other
backends (sessionStorage, cookies).
## Errors
- Constructor: missing `baseUrl`/`projectKey``RudderError`,
`code: 'sdk/invalid-options'`.
- Login failure → `RudderHttpError` (e.g. unknown project key) or
`RudderNetworkError`.
- Any later request with an expired session → `RudderAuthError` (after the
refresh attempt above fails).
@@ -0,0 +1,84 @@
# Battle pass — `client.battlePass`
`BattlePassService` (source: `src/battlepass/BattlePassService.ts`).
Call-and-response access to battle pass endpoints. Battle pass state is tied to
a **scenario battle pass node**, so calls carry `scenarioId` + `nodeId` (plus
`runId` for mutating calls). NOT observable — re-fetch progress explicitly
after a mutation.
In most games you do not call this service directly: a scenario battle pass
node surfaces through `client.effects.onBattlePass` with a session object that
wraps these calls (see `reference/scenarios.md`). Use the service directly when
you already know the scenario/node/run identifiers.
## Methods
```ts
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse>
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
```
## Request / response types
```ts
interface AddBattlePassXpRequest {
amount?: number;
nodeId?: string;
runId?: string;
scenarioId?: string;
source?: string; // configured XP source
}
interface AddBattlePassXpResponse {
level?: number;
leveledUp?: boolean;
maxLevel?: boolean;
xp?: number;
}
interface ClaimBattlePassRewardRequest {
level?: number;
nodeId?: string;
runId?: string;
scenarioId?: string;
track?: 'free' | 'premium';
}
interface ClaimBattlePassRewardResponse {
alreadyClaimed?: boolean;
error?: string;
granted?: Reward[]; // { amount?, currency?, itemId? }
success?: boolean;
}
interface GetBattlePassProgressResponse {
claimedTiers?: ClaimedTier[]; // { level?, track? }
level?: number;
premiumOwned?: boolean;
xp?: number;
}
interface PurchaseBattlePassPremiumRequest {
idempotencyKey?: string;
nodeId?: string;
runId?: string;
scenarioId?: string;
}
interface PurchaseBattlePassPremiumResponse {
error?: string;
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 `scenarioId` /
`nodeId` / `runId` and posts scenario callbacks for you.
@@ -0,0 +1,56 @@
# Inventory + catalog — `client.inventory`, `client.catalog`
## Inventory
`InventoryDomain` (source: `src/domains/InventoryDomain.ts`) — owned items
merged with their catalog entries. Synced under revision key `inventory`;
also refreshed whenever the catalog changes.
```ts
client.inventory.data // InventoryItem[] | undefined
client.inventory.onChange(cb);
await client.inventory.load() / .reload();
```
### `InventoryItem` (merged view, source: `src/state/inventory.ts`)
```ts
interface InventoryItem {
slug: string; // '' when the wire item has no slug
amount: number; // 0 when absent on the wire
name: string; // nameOverride || catalog name || slug
properties: Record<string, unknown>; // catalog properties + propertiesOverride (override wins)
tags: string[]; // from the catalog entry
}
```
The raw wire shape is `PlayerInventoryItem`
(`slug?, amount?, nameOverride?, propertiesOverride?, updatedAt?`) — you rarely
need it; the domain hands out the merged `InventoryItem`.
## Catalog
`CatalogDomain` (source: `src/domains/CatalogDomain.ts`) — the item catalog
keyed by slug. Synced under revision key `catalog`.
```ts
client.catalog.data // Map<string, CatalogItem> | undefined
interface CatalogItem {
name?: string;
properties?: { [key: string]: unknown };
slug?: string;
tags?: string[];
}
```
Items without a `slug` are skipped when the map is built.
## Behavior notes
- The catalog is warmed at login; inventory loads on first use, waits on the
catalog load, and merges, so `client.inventory.data` always has catalog
fields filled in.
- Invalidated after store purchases and scenario callbacks.
- Inventory has no client-side mutations — items change via purchases, quest /
battle pass rewards, and scenario nodes (all server-side).
@@ -0,0 +1,50 @@
# Leaderboards — `client.leaderboards`
`LeaderboardsService` (source: `src/leaderboards/LeaderboardsService.ts`).
Plain call-and-response service, NOT observable — refetch explicitly.
## Surface
```ts
const board = client.leaderboards.findBySlug('weekly-kills'); // cached handle
await board.submit(score);
const entries = await board.list(limit?);
board.getEntries();
```
## `LeaderboardHandle`
```ts
class LeaderboardHandle {
readonly slug: string;
getEntries(): readonly RankEntry[]; // last fetched list, [] initially
submit(score: number): Promise<void>;
list(limit = 100): Promise<readonly RankEntry[]>; // fetches and caches
}
```
- `findBySlug(slug)` caches handles per slug — repeated calls return the same
instance (and its cached entries).
- `list(limit)`: `limit <= 0` sends no limit to the server; default is 100.
- `submit` does not update the cached entries; call `list()` afterwards to see
the effect.
## Types
```ts
interface RankEntry {
playerId?: string;
playerName?: string;
rank?: number;
score?: number;
}
```
## Notes
- No player-around-me or metadata endpoints are exposed by this SDK — submit
and top-N list only.
- Scenario `leaderboard` nodes surface through
`client.effects.onLeaderboard` (`end()`, `rewardClaimed()`) — see
`reference/scenarios.md`.
+53
View File
@@ -0,0 +1,53 @@
# Player profile + wallets — `client.player`
`PlayerDomain` (source: `src/domains/PlayerDomain.ts`) — the observable player
profile: identity plus currency wallets. Synced under revision key `profile`.
There is no separate wallet service in this SDK. Wallet balances are read from
the profile and are credited/debited server-side (purchases, quest/battle pass
rewards, scenario nodes).
## Surface
`client.player` is a `SyncedState<PlayerProfile>`:
```ts
client.player.data // PlayerProfile | undefined
client.player.status // 'idle' | 'loading' | 'ready' | 'error'
client.player.onChange((snapshot) => { /* fires immediately */ });
await client.player.load();
await client.player.reload(); // force refetch
```
## Types
```ts
interface PlayerProfile {
player?: Player;
wallets?: Wallet[];
}
interface Player {
createdAt?: string;
id?: string;
language?: string;
nickname?: string;
projectId?: string;
region?: string;
}
interface Wallet {
balance?: number;
currency?: string; // currency code configured in the dashboard
}
```
## Behavior notes
- Warmed automatically at login (one of the four parallel warm loads).
- Invalidated (refetched) after every successful store purchase and after
scenario server callbacks — subscribers see fresh balances without waiting
for the revision poll.
- All fields are optional on the wire (`?`); code defensively.
- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK
README "React recipe").
+75
View File
@@ -0,0 +1,75 @@
# Quests — `client.quests`
`QuestsService` (source: `src/quests/QuestsService.ts`). The player's **global**
quests — list, claim, report metric progress. Plain call-and-response service,
NOT observable (no sync revision key): re-list after a claim or report.
Distinct from scenario quest nodes, which advance through
`client.effects.onQuest` (see `reference/scenarios.md`).
## Methods
```ts
list(): Promise<Quest[]>
claim(questId: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // ids of quests completed by this report
```
## Types
```ts
interface Quest {
id?: string;
name?: string;
objectives?: QuestObjectiveProgress[];
rewards?: Reward[];
status?: 'active' | 'claimed' | 'completed';
}
interface QuestObjectiveProgress {
completed?: boolean;
current?: number;
metric?: string;
objectiveId?: string;
target?: number;
}
interface ClaimQuestResponse {
alreadyClaimed?: boolean;
error?: string;
granted?: Reward[]; // { amount?, currency?, itemId? }
success?: boolean;
}
```
## Usage notes
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.id) {
const res = await client.quests.claim(quest.id);
// res.success / res.alreadyClaimed / res.granted
}
}
const completedIds = await client.quests.reportProgress('kills', 1);
```
- `claim` is idempotent server-side; check `success` / `alreadyClaimed` /
`error` in the response rather than relying on exceptions.
- Objective completion is judged server-side from metric reports.
## `QuestMetrics` helpers
Exported as a namespace: `import { QuestMetrics } from '@rudder/js-sdk'`
(source: `src/quests/QuestMetrics.ts`).
```ts
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
Purchase metrics are reported automatically server-side by the store purchase
fan-out — these helpers exist so quest configs and client code name the format
consistently. Catalog counter slugs are reported via `reportProgress`. Custom
free-text metrics no longer progress quests — they no-op at runtime.
@@ -0,0 +1,63 @@
# Remote config — `client.remoteConfig`
`ConfigDomain<TConfig>` (source: `src/domains/ConfigDomain.ts`, base class
`src/state/RemoteConfigState.ts`) — typed remote configuration as an
observable entity. Synced under revision key `config`.
## Typed access
```ts
interface GameConfig extends Record<string, unknown> {
player_speed: number;
feature_x: boolean;
}
const client = new RudderClient<GameConfig>({ baseUrl, projectKey });
client.remoteConfig.get('player_speed', 200); // number
client.remoteConfig.get('feature_x'); // boolean | undefined
```
`get()` overloads:
```ts
get(key): TConfig[key] | undefined;
get(key, defaultValue: TConfig[key]): TConfig[key];
```
- Values are parsed synchronously from the loaded snapshot according to the
config's server-declared `valueType`.
- Returns the default value (or `undefined`) while not loaded, for unknown
keys, and when parsing fails.
- Parsing rules: `int`/`integer``parseInt`; `float`/`double`/`number`
`parseFloat`; `bool`/`boolean``value === 'true'`; `json`/`object`
`JSON.parse`; anything else → raw string.
## Observable
```ts
client.remoteConfig.data // Map<string, RemoteConfig> | undefined
client.remoteConfig.onChange(cb); // fires immediately
await client.remoteConfig.reload();
```
```ts
interface RemoteConfig {
active?: boolean; createdAt?: string; description?: string;
environment?: string; id?: string; key?: string; projectId?: string;
updatedAt?: string; value?: string;
valueType?: 'bool' | 'float' | 'int' | 'json' | 'string';
}
```
Only configs that are not explicitly `active: false` enter the map (the server
already filters inactive configs out and may omit the flag).
## Behavior notes
- Warmed at login.
- Scenario `remote_config_override` nodes 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`.
@@ -0,0 +1,166 @@
# 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 `scenarioId`/`nodeId`/`runId`
already bound — prefer them over calling the service manually.
### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier)
```ts
{ readonly level: number; claim(): Promise<void> }
```
### Run lifecycle effects
```ts
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioId: string }
interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioId: string; readonly nodeId: string;
readonly error: Error;
}
```
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
`{scenarioId, 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`).
@@ -0,0 +1,69 @@
# Storage — `client.storage` + `client.projectStorage`
Two key/value stores: per-player (`StorageDomain`, revision key `storage`) and
project-wide shared (`ProjectStorageDomain`, revision key `projectStorage`).
Sources: `src/domains/StorageDomain.ts`, `src/domains/ProjectStorageDomain.ts`.
## Player storage
```ts
client.storage.data // GetStorageResponse | undefined
await client.storage.save(items);
await client.storage.delete(type);
```
```ts
interface GetStorageResponse {
items?: StorageItem[];
nextCursor?: string;
}
interface StorageItem {
data?: string; // opaque payload, JSON-stringify yourself if needed
id?: string;
type?: string; // the storage "collection" key
}
```
- `save(items: StorageItem[]): Promise<void>` — upserts items, then
invalidates the domain so subscribers refetch.
- `delete(type: string): Promise<void>` — deletes **all** player storage items
of that type, then invalidates.
## Project storage
```ts
client.projectStorage.data // GetProjectStorageResponse | undefined
await client.projectStorage.save(items);
```
```ts
interface GetProjectStorageResponse {
items?: ProjectStorageItem[];
nextCursor?: string;
}
interface ProjectStorageItem {
data?: string;
expiresAt?: string;
id?: string;
readPermission?: 'public' | 'serverOnly';
size?: number;
type?: string;
updatedAt?: string;
version?: number;
writePermission?: 'public' | 'serverOnly';
}
interface ProjectStorageUpdateItem { data?: string; type?: string }
```
- `save(items: ProjectStorageUpdateItem[]): Promise<void>` — upserts, then
invalidates. Writing is only possible for items whose `writePermission` is
`public`; `serverOnly` items are read-only for clients.
- The SDK exposes no client-side project-storage delete.
## Limits and notes
- Both domains load with `{ limit: 100 }` — the observable snapshot holds at
most 100 items and `nextCursor` pagination is not surfaced by the domain.
- `data` is a raw string on the wire; serialize/deserialize JSON yourself.
- Both are observable (`SyncedState`) and warmed by the revision poll only when
in use; mutations self-invalidate.
+80
View File
@@ -0,0 +1,80 @@
# Stores / purchases — `client.stores`
`StoresDomain` (source: `src/domains/StoresDomain.ts`, handles in
`src/state/shops.ts`). The observable store list plus the purchase executor.
Synced under revision key `stores`. This is the SDK's economy surface — there
is no separate economy service.
## Surface
```ts
client.stores.data // ShopHandle[] | undefined
client.stores.onChange(cb);
await client.stores.load() / .reload();
// Direct purchase (bypassing handles):
await client.stores.purchase(storeSlug, offerId, options?);
```
## Handles
```ts
class ShopHandle {
readonly slug: string;
readonly name?: string;
readonly description?: string;
readonly data?: { [key: string]: unknown };
readonly offers: OfferHandle[];
}
class OfferHandle {
readonly id: string;
readonly name?: string;
readonly price?: OfferPrice; // { amount?: number; currency?: string }
readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[]
readonly maxPurchases?: number;
buy(options?: BuyOptions): Promise<PurchaseOfferResponse>;
}
interface BuyOptions { idempotencyKey?: string }
interface PurchaseOfferResponse {
error?: string;
purchaseId?: string;
success?: boolean;
}
```
## Purchase semantics
- `stores.purchase(storeSlug, offerId, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the slug/id.
- When `options.idempotencyKey` is omitted the SDK generates
`crypto.randomUUID()` per call — safe retries require passing your own key.
- On success the executor invalidates `player`, `inventory`, and `stores`, so
subscribers observe fresh wallet/inventory/store data immediately.
- Check `response.success` / `response.error` — a failed purchase is a resolved
response, not necessarily a thrown error.
- Purchase metrics (`purchase.offer:<offerId>`, `purchase.item:<itemId>`) are
reported to quests automatically server-side — do not report them manually
(see `reference/quests.md`).
## Wire types
```ts
interface Store {
createdAt?: string; data?: { [key: string]: unknown }; description?: string;
environment?: string; id?: string; name?: string; offers?: Offer[];
projectId?: string; scenarioId?: string; slug?: string; status?: string;
updatedAt?: string;
}
interface Offer {
contents?: OfferContent[]; createdAt?: string; id?: string;
maxPurchases?: number; name?: string; price?: OfferPrice; updatedAt?: string;
}
```
`ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store
without `slug` / an offer without `id` — in practice the domain only builds
handles from server data that has both.
+12 -41
View File
@@ -9,8 +9,8 @@
* client (which keeps the module graph acyclic). Cross-domain wiring (the
* inventory↔catalog link, purchase invalidation) is done here at construction.
*
* The scenario runtime (engine + plan persistence) is loaded lazily on first
* use — a client that never logs in never pays for the scenario engine.
* The scenario effects client is loaded lazily on first use — a client that
* never logs in never pays for the scenario machinery.
*/
import type {
@@ -26,7 +26,6 @@ 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 type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
import { SyncEngine } from '../state/SyncEngine.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
@@ -38,7 +37,6 @@ import { StorageDomain } from '../domains/StorageDomain.js';
import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
import { StoresDomain } from '../domains/StoresDomain.js';
/** A domain the client can bulk-invalidate (re-login) or reset (logout). */
type ManagedDomain = { invalidate(): void; reset(): void };
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
@@ -50,7 +48,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
public readonly quests: QuestsService;
public readonly effects: Effects;
// Observable domains: subscribe via `onChange`, read `.data`, or `reload()`.
public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain;
public readonly inventory: InventoryDomain;
@@ -60,8 +57,8 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
public readonly stores: StoresDomain;
private readonly effectsCenter: EffectsCenter;
private scenarioRuntime: ScenarioService<TConfig> | null = null;
private scenarioRuntimePromise: Promise<ScenarioService<TConfig>> | null = null;
private scenarioRuntime: ScenarioService | null = null;
private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
private readonly syncEngine: SyncEngine;
private readonly loginEvent: string | null;
private readonly ctx: RudderContext;
@@ -137,11 +134,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
);
}
/**
* Tears down all background work: stops the sync poll (and its
* visibilitychange listener), cancels scenario wait timers, and drops cached
* state. Call when disposing the client (e.g. on unmount / HMR) to avoid leaks.
*/
dispose(): void {
this.syncEngine.stop();
this.scenarioRuntime?.clear();
@@ -157,8 +149,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.invalidateAll();
}
this.runtimeStarted = true;
// These four warms are independent — fetch them concurrently so login
// doesn't pay a serial round-trip per entity.
await Promise.all([
this.remoteConfig.load(),
this.player.load(),
@@ -166,51 +156,34 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.catalog.load(),
]);
const runtime = await runtimePromise;
await runtime.restore();
await runtime.start();
if (this.loginEvent) {
await this.sendRuntimeEvent(runtime, this.loginEvent);
}
this.syncEngine.start();
}
/**
* Loads the scenario engine and plan persistence on first use and caches the
* runtime. Dynamic imports keep the engine out of the initial module graph,
* so a thin client never pulls the scenario machinery.
*/
private ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> {
private ensureScenarioRuntime(): Promise<ScenarioService> {
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
return this.scenarioRuntimePromise;
}
private async createScenarioRuntime(): Promise<ScenarioService<TConfig>> {
private async createScenarioRuntime(): Promise<ScenarioService> {
const { ScenarioService } = await import('../scenario/ScenarioService.js');
const configured = this.options.runtime?.planStateStore;
let planStore: PlanStateStore | null;
if (configured !== undefined) {
planStore = configured;
} else {
const { createIndexedDbPlanStateStore } = await import(
'../scenario/engine/IndexedDbPlanStore.js'
);
planStore = createIndexedDbPlanStateStore();
}
this.scenarioRuntime = new ScenarioService<TConfig>(
this.scenarioRuntime = new ScenarioService(
this.ctx,
{
player: this.player,
inventory: this.inventory,
config: this.remoteConfig,
stores: this.stores,
},
this.battlePass,
planStore,
);
return this.scenarioRuntime;
}
private async sendRuntimeEvent(
runtime: ScenarioService<TConfig>,
runtime: ScenarioService,
event: string,
): Promise<void> {
try {
@@ -240,12 +213,10 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
];
}
/** Re-login: refetch every domain that is in use. */
private invalidateAll(): void {
for (const domain of this.managedDomains) domain.invalidate();
}
/** Logout: drop all cached data. */
private resetAll(): void {
for (const domain of this.managedDomains) domain.reset();
}
@@ -254,14 +225,14 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
/**
* 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 engine on first call.
* Lazily loads the scenario effects client on first call.
*
* @internal
*/
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
client: RudderClient<TConfig>,
): Promise<ScenarioService<TConfig>> {
): Promise<ScenarioService> {
return (
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> }
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
).ensureScenarioRuntime();
}
+1 -17
View File
@@ -1,22 +1,6 @@
import type { TokenStore } from '../token/TokenStore.js';
import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
/**
* Advanced runtime knobs. Mainly for tests and non-browser hosts; browser
* consumers can ignore these and take the defaults.
*/
export interface RudderRuntimeOptions {
/**
* Scenario plan persistence backend. `undefined` uses the default
* IndexedDB store (created lazily with the scenario runtime); pass `null`
* to disable persistence entirely.
*/
planStateStore?: PlanStateStore | null;
/**
* Scenario event fired automatically after login. `undefined` uses
* `'player_login'`; pass `null` to fire nothing.
*/
loginEvent?: string | null;
}
@@ -46,7 +30,7 @@ export interface RudderClientOptions {
*/
onEffectError?: (error: unknown) => void;
/** Advanced runtime knobs (plan persistence, login event). */
/** Advanced runtime knobs (login event). */
runtime?: RudderRuntimeOptions;
}
+21 -79
View File
@@ -1,15 +1,5 @@
import type { PurchaseOfferResponse } from '../generated/stores.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js';
import type {
BattlePassLevelSession,
BattlePassSession,
LeaderboardSession,
NotificationSession,
QuestSession,
StoreSession,
WaitSession,
} from '../scenario/engine/sessions.js';
import type { PlanRun, ScenarioRunFailedEvent } from '../scenario/engine/types.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
@@ -35,6 +25,8 @@ export interface StoreOfferEffect {
export interface LeaderboardEffect {
end(): Promise<void>;
claim(): Promise<void>;
/** @deprecated Use {@link LeaderboardEffect.claim}. Removed in the next SDK version. */
rewardClaimed(): Promise<void>;
}
@@ -42,22 +34,15 @@ export interface ConfigChangedEffect {
readonly key: string;
}
/** A scenario wait node became active; the run resumes at `deadlineUtc`. */
export interface WaitEffect {
readonly deadlineUtc: Date;
}
/** A scenario run reached a terminal end successfully. */
export interface ScenarioCompletedEffect {
readonly runId: string;
readonly scenarioId: string;
}
/**
* A scenario run failed at a node (transport gave up, or the node type is not
* supported by this SDK). Surfaced so the game can react instead of the failure
* being swallowed into a console warning.
*/
export interface ScenarioFailedEffect {
readonly runId: string;
readonly scenarioId: string;
@@ -65,17 +50,12 @@ export interface ScenarioFailedEffect {
readonly error: Error;
}
/**
* A scenario quest node became active. Report objective progress; the node
* auto-completes server-side once every objective is satisfied.
*/
export interface QuestEffect {
readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>;
}
/** A scenario battle pass node became active. */
export interface BattlePassEffect {
getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
@@ -85,7 +65,6 @@ export interface BattlePassEffect {
end(): Promise<void>;
}
/** A scenario battlepass_level node became active (a single claimable tier). */
export interface BattlePassLevelEffect {
readonly level: number;
claim(): Promise<void>;
@@ -164,35 +143,20 @@ export class EffectsCenter implements Effects {
}
/** @internal */
emitNotification(session: NotificationSession): void {
this.emit(this.notificationHandlers, {
title: session.title,
message: session.message,
done: () => session.complete(),
});
emitNotification(effect: NotificationEffect): void {
this.emit(this.notificationHandlers, effect);
}
/** @internal */
emitStoreOffer(session: StoreSession): void {
session.getStore()
.then((store) => {
this.emit(this.storeOfferHandlers, {
store,
offers: store.offers,
message: session.get('message', undefined as string | undefined),
buy: (offer, options) => session.buy(offer, options),
dismiss: () => session.decline(),
});
})
emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
Promise.resolve(effect)
.then((value) => this.emit(this.storeOfferHandlers, value))
.catch((error) => this.onError(error));
}
/** @internal */
emitLeaderboard(session: LeaderboardSession): void {
this.emit(this.leaderboardHandlers, {
end: () => session.end(),
rewardClaimed: () => session.rewardClaimed(),
});
emitLeaderboard(effect: LeaderboardEffect): void {
this.emit(this.leaderboardHandlers, effect);
}
/** @internal */
@@ -201,55 +165,33 @@ export class EffectsCenter implements Effects {
}
/** @internal */
emitWait(session: WaitSession): void {
this.emit(this.waitHandlers, { deadlineUtc: session.deadlineUtc });
emitWait(effect: WaitEffect): void {
this.emit(this.waitHandlers, effect);
}
/** @internal */
emitScenarioCompleted(run: PlanRun): void {
this.emit(this.scenarioCompletedHandlers, {
runId: run.runId,
scenarioId: run.scenarioId,
});
emitScenarioCompleted(effect: ScenarioCompletedEffect): void {
this.emit(this.scenarioCompletedHandlers, effect);
}
/** @internal */
emitScenarioFailed(event: ScenarioRunFailedEvent): void {
this.emit(this.scenarioFailedHandlers, {
runId: event.run.runId,
scenarioId: event.run.scenarioId,
nodeId: event.nodeId,
error: event.error,
});
emitScenarioFailed(effect: ScenarioFailedEffect): void {
this.emit(this.scenarioFailedHandlers, effect);
}
/** @internal */
emitQuest(session: QuestSession): void {
this.emit(this.questHandlers, {
name: session.name,
objectives: session.objectives,
reportProgress: (objectiveId, amount) => session.reportProgress(objectiveId, amount),
});
emitQuest(effect: QuestEffect): void {
this.emit(this.questHandlers, effect);
}
/** @internal */
emitBattlePass(session: BattlePassSession): void {
this.emit(this.battlePassHandlers, {
getProgress: () => session.getProgress(),
addXp: (source, amount) => session.addXp(source, amount),
claimReward: (level, track) => session.claimReward(level, track),
purchasePremium: () => session.purchasePremium(),
levelUp: () => session.levelUp(),
end: () => session.end(),
});
emitBattlePass(effect: BattlePassEffect): void {
this.emit(this.battlePassHandlers, effect);
}
/** @internal */
emitBattlePassLevel(session: BattlePassLevelSession): void {
this.emit(this.battlePassLevelHandlers, {
level: session.level,
claim: () => session.claim(),
});
emitBattlePassLevel(effect: BattlePassLevelEffect): void {
this.emit(this.battlePassLevelHandlers, effect);
}
private addHandler<TEffect>(
+6 -7
View File
@@ -9,7 +9,7 @@ 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 { GetScenarioRunRequest, GetScenarioRunResponse, HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
import type { HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, ListPendingScenarioEffectsResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
@@ -100,12 +100,6 @@ export const api = {
): Promise<{ [key: string]: number }> =>
t.request<{ [key: string]: number }>('GET', '/sdk/v1/sync'),
getScenarioRun: (
t: Transport,
body: GetScenarioRunRequest,
): Promise<GetScenarioRunResponse> =>
t.request<GetScenarioRunResponse>('POST', '/sdk/v1/scenarios/run', body),
getStorage: (
t: Transport,
query?: { types?: string; limit?: number; cursor?: string },
@@ -129,6 +123,11 @@ export const api = {
): Promise<ListCatalogItemsResponse> =>
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
listPendingScenarioEffects: (
t: Transport,
): Promise<ListPendingScenarioEffectsResponse> =>
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
listQuests: (
t: Transport,
): Promise<ListQuestsResponse> =>
+1 -3
View File
@@ -1,6 +1,6 @@
// Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan, Reward } from './common.js';
import type { Reward } from './common.js';
export interface AddBattlePassXpRequest {
"amount"?: number;
@@ -14,7 +14,6 @@ export interface AddBattlePassXpResponse {
"level"?: number;
"leveledUp"?: boolean;
"maxLevel"?: boolean;
"plan"?: ExecutionPlan;
"xp"?: number;
}
@@ -59,7 +58,6 @@ export interface PurchaseBattlePassPremiumRequest {
export interface PurchaseBattlePassPremiumResponse {
"error"?: string;
"plan"?: ExecutionPlan;
"success"?: boolean;
}
-36
View File
@@ -1,15 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
export interface BoundaryNode {
"callbackUrl"?: string;
"enforcement"?: "client" | "server";
"enteredAt"?: string;
"nodeId"?: string;
"sourceHandle"?: string;
"sourceNodeId"?: string;
"waitDeadline"?: string;
}
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";
"error"?: string;
@@ -18,32 +8,6 @@ export interface ErrorResponse {
"requestId"?: string;
}
export interface ExecutionPlan {
"boundaryNodes"?: BoundaryNode[];
"context"?: { [key: string]: unknown };
"edges"?: PlanEdge[];
"nodes"?: ExecutionPlanNode[];
"planId"?: string;
"runId"?: string;
"scenarioId"?: string;
"startNodeId"?: string;
"userId"?: string;
}
export interface ExecutionPlanNode {
"data"?: { [key: string]: unknown };
"id"?: string;
"type"?: string;
}
export interface PlanEdge {
"id"?: string;
"source"?: string;
"sourceHandle"?: string;
"target"?: string;
"targetHandle"?: string;
}
export interface Reward {
"amount"?: number;
"currency"?: string;
+1
View File
@@ -1,6 +1,7 @@
// Code generated by apigen. DO NOT EDIT.
export interface Player {
"avatarUrl"?: string;
"createdAt"?: string;
"id"?: string;
"language"?: string;
+16 -15
View File
@@ -1,17 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan } from './common.js';
export interface GetScenarioRunRequest {
"runId"?: string;
}
export interface GetScenarioRunResponse {
"plan"?: ExecutionPlan;
"runId"?: string;
"status"?: "active" | "completed" | "expired";
}
export interface HandleScenarioCallbackRequest {
"handle"?: string;
"nodeId"?: string;
@@ -20,7 +8,20 @@ export interface HandleScenarioCallbackRequest {
}
export interface HandleScenarioCallbackResponse {
"plan"?: ExecutionPlan;
"effect"?: PendingEffect;
}
export interface ListPendingScenarioEffectsResponse {
"effects": PendingEffect[];
}
export interface PendingEffect {
"data": { [key: string]: unknown };
"nodeId": string;
"runId": string;
"scenarioId": string;
"type": string;
"waitDeadline"?: string;
}
export interface TriggerScenarioRequest {
@@ -28,7 +29,7 @@ export interface TriggerScenarioRequest {
}
export interface TriggerScenarioResponse {
"plans"?: ExecutionPlan[];
"effects": PendingEffect[];
}
export interface UpdateScenarioCounterRequest {
@@ -41,6 +42,6 @@ export interface UpdateScenarioCounterRequest {
export interface UpdateScenarioCounterResponse {
"completed"?: boolean;
"plan"?: ExecutionPlan;
"effect"?: PendingEffect;
}
+3 -2
View File
@@ -3,8 +3,9 @@
*
* 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. Any other metric is a custom string reported via
* {@link QuestsService.reportProgress}.
* 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. */
+352 -495
View File
@@ -1,595 +1,452 @@
import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape, RemoteConfigState } from '../state/RemoteConfigState.js';
import type { ShopHandle } from '../state/shops.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 { TriggerScenarioResponse } from '../generated/scenarios.js';
import type { ExecutionPlan } from '../generated/common.js';
import type { PlanStateStore } from './engine/IndexedDbPlanStore.js';
/**
* The subset of domains the scenario runtime drives: it invalidates the player
* and inventory after server mutations, patches remote config for override
* nodes, and resolves stores for store nodes.
*/
export interface ScenarioDomains {
readonly player: { invalidate(): void };
readonly inventory: { invalidate(): void };
readonly config: RemoteConfigState;
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
}
import type { PendingEffect, TriggerScenarioResponse } from '../generated/scenarios.js';
import { RudderErrorCodes } from '../generated/errors.js';
import {
RudderNetworkError,
RudderHttpError,
} from '../client/RudderError.js';
import {
findNode,
matchingEdges,
matchingBoundaryNodes,
completedHandleKey,
durationToMs,
} from './engine/DagWalker.js';
import { RuntimeRun, PlanRun, type ActiveNodeState, type ScenarioRunFailedEvent } from './engine/types.js';
import {
ScenarioNodeContext,
NotificationSession,
WaitSession,
StoreSession,
LeaderboardSession,
QuestSession,
BattlePassSession,
BattlePassLevelSession,
} from './engine/sessions.js';
import type { BattlePassService } from '../battlepass/BattlePassService.js';
/**
* Error thrown when a boundary HTTP call fails with a transient error
* (network failure or server 5xx). The caller should NOT advance the run;
* the node stays active and the handle stays pending for retry on reconnect.
*/
class TransientBoundaryError extends Error {
constructor(public readonly inner: unknown) {
super('Transient boundary error');
this.name = 'TransientBoundaryError';
}
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;
scenarioId: string;
storePurchased?: boolean;
lastPurchase?: PurchaseOfferResponse;
};
function effectKey(runId: string, nodeId: string): string {
return `${runId}:${nodeId}`;
}
/** Returns true for network / 5xx errors that may succeed on retry. */
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
);
}
export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
private readonly runs = new Map<string, RuntimeRun>();
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 planStore: PlanStateStore | null,
private readonly intervalMs: number = DEFAULT_PENDING_INTERVAL_MS,
) {}
// ---- Public Events (subscribe to receive node dispatches) ----
onNotification?: (session: NotificationSession) => void;
onWait?: (session: WaitSession) => void;
onStore?: (session: StoreSession) => void;
onLeaderboard?: (session: LeaderboardSession) => void;
onRunCompleted?: (run: PlanRun) => void;
onRunFailed?: (event: ScenarioRunFailedEvent) => void;
onCompleted?: () => void;
get isRunning(): boolean {
return this.runs.size > 0;
return this.active.size > 0;
}
get activeRuns(): readonly PlanRun[] {
return [...this.runs.values()].map((r) => this.toPlanRun(r));
}
// ---- Public API ----
/** Triggers scenarios by event name. Returns all started plans. */
async send(eventName: string): Promise<TriggerScenarioResponse> {
const response = await api.triggerScenario(this.ctx, { event: eventName });
this.domains.player.invalidate();
this.domains.inventory.invalidate();
this.startPlans(response.plans ?? []);
this.ingest(response.effects ?? []);
return response;
}
/** Completes the current active node with the given handle. */
async respond(handle: string): Promise<void> {
for (const run of this.runs.values()) {
for (const nodeId of run.activeNodes.keys()) {
await this.completeNodeAsync(run.runId, nodeId, handle);
return;
}
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();
}
/**
* Restores persisted scenario state from IndexedDB.
* Call once after constructing the client.
*/
async restore(): Promise<void> {
const store = this.planStore;
if (!store) return;
try {
await store.load();
const json = store.state;
if (!json) return;
const persisted = JSON.parse(json) as { runs: Array<{
runId: string;
plan: ExecutionPlan;
activeNodes: ActiveNodeState[];
completedHandles: string[];
}> };
for (const saved of persisted.runs ?? []) {
let plan = saved.plan;
let activeNodes = saved.activeNodes;
let completedHandles = saved.completedHandles;
try {
const response = await api.getScenarioRun(this.ctx, { runId: saved.runId });
const status: string | undefined = response?.status;
if (status === 'unknown_run' || status === 'expired') {
continue;
}
if (response?.plan) {
plan = response.plan;
activeNodes = [];
completedHandles = [];
}
} catch {
// Network/server error — fall back to persisted local state.
}
const run = new RuntimeRun(saved.runId, plan);
for (const handle of completedHandles ?? []) {
run.completedHandles.add(handle);
}
this.runs.set(run.runId, run);
if (activeNodes.length > 0) {
for (const nodeState of activeNodes) {
run.activeNodes.set(nodeState.nodeId, nodeState);
this.dispatchActiveNode(run, nodeState, true);
}
} else if (plan?.nodes?.length) {
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0];
if (startNode?.id) {
this.activateNode(run, startNode.id);
}
}
}
await this.persist();
} catch {
// Corrupted state — clear and continue.
this.clear();
}
}
/** Clears all active runs and persisted state. */
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.runs.clear();
this.persist().catch(() => {});
}
// ---- Internal: Plan lifecycle ----
private startPlans(plans: ExecutionPlan[]): void {
for (const plan of plans ?? []) {
this.startPlan(plan);
this.active.clear();
this.droppedRuns.clear();
this.completedRuns.clear();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
}
private startPlan(plan: ExecutionPlan): void {
if (!plan.nodes?.length) return;
if (plan.boundaryNodes?.length && !plan.runId) {
throw new Error('ExecutionPlan has boundaryNodes but missing runId');
private readonly onVisibilityChange = (): void => {
if (this.running && typeof document !== 'undefined' && !document.hidden) {
void this.heartbeatTick();
}
// Dedup: server returned a plan with a runId already active — skip without
// restarting wait timers or re-dispatching node sessions.
if (plan.runId && this.runs.has(plan.runId)) return;
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0];
if (!startNode?.id) return;
const runId = plan.runId ?? crypto.randomUUID().replace(/-/g, '');
const run = new RuntimeRun(runId, plan);
this.runs.set(run.runId, run);
this.activateNode(run, startNode.id);
this.persist().catch(() => {});
};
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 activateNode(
run: RuntimeRun,
nodeId: string,
restoredState?: ActiveNodeState,
): void {
const node = findNode(run.plan, nodeId);
if (!node?.id) return;
const state = restoredState ?? { nodeId };
run.activeNodes.set(nodeId, state);
this.dispatchActiveNode(run, state, restoredState !== undefined);
private async heartbeatTick(): Promise<void> {
if (!this.running) return;
if (typeof document !== 'undefined' && document.hidden) {
this.scheduleHeartbeat();
return;
}
await this.pollPending();
this.scheduleHeartbeat();
}
// ---- Internal: Node Dispatch ----
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 dispatchActiveNode(
run: RuntimeRun,
state: ActiveNodeState,
restored: boolean,
): void {
const node = findNode(run.plan, state.nodeId);
if (!node) {
run.activeNodes.delete(state.nodeId);
this.checkRunCompleted(run);
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.scenarioId);
}
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 ctx = new ScenarioNodeContext(
this.toPlanRun(run),
node,
(rid, nid, h) => this.completeNodeInternal(rid, nid, h, true),
(rid, nid, ck, amt) => this.updateProgressInternal(rid, nid, ck, amt),
);
switch (node.type) {
case 'wait':
this.dispatchWait(run, state, ctx);
break;
case 'remote_config_override':
this.dispatchRemoteConfigOverride(run, state, ctx);
break;
case 'notification':
{
const session = new NotificationSession(ctx);
this.ctx.effects.emitNotification(session);
this.onNotification?.(session);
const record: ActiveRecord = {
runId: pending.runId,
nodeId: pending.nodeId,
scenarioId: pending.scenarioId,
};
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':
{
const session = new StoreSession(ctx, this.domains.stores);
this.ctx.effects.emitStoreOffer(session);
this.onStore?.(session);
}
this.dispatchStore(pending, record);
break;
case 'leaderboard':
{
const session = new LeaderboardSession(ctx);
this.ctx.effects.emitLeaderboard(session);
this.onLeaderboard?.(session);
}
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(new QuestSession(ctx));
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.ctx.effects.emitBattlePass(new BattlePassSession(ctx, this.battlePass));
this.dispatchBattlePass(record);
break;
case 'battlepass_level':
this.ctx.effects.emitBattlePassLevel(new BattlePassLevelSession(ctx));
this.ctx.effects.emitBattlePassLevel({
level: asLevel(pending.data.levelNumber),
claim: () => this.complete(record, 'onComplete'),
});
break;
default:
// Unsupported node type — fail the run (surfaced via onScenarioFailed)
// instead of leaving it stalled on a node no handler will complete.
console.warn(
`[Rudder] Unsupported scenario node type '${node.type}' (${node.id})`,
`[Rudder] Unsupported scenario node type '${pending.type}' (${pending.nodeId})`,
);
this.failRun(
run,
state.nodeId,
new Error(`Unsupported scenario node type '${node.type}'`),
this.dropRun(
record.runId,
record.nodeId,
new Error(`Unsupported scenario node type '${pending.type}'`),
);
break;
}
}
private dispatchWait(
run: RuntimeRun,
state: ActiveNodeState,
ctx: ScenarioNodeContext,
): void {
// Prefer server-provided waitDeadline from the plan boundary over local calculation.
// The server stamps waitDeadline on server-enforced wait boundaries (see StampBoundaries).
if (!state.waitDeadlineUtc) {
const boundary = (run.plan.boundaryNodes ?? []).find(
b => b.sourceNodeId === state.nodeId && b.waitDeadline,
);
if (boundary?.waitDeadline) {
state.waitDeadlineUtc = boundary.waitDeadline;
} else {
const data = ctx.data;
const duration = (data.duration as number) ?? 0;
const unit = (data.unit as string) ?? 'seconds';
const ms = durationToMs(duration, unit);
state.waitDeadlineUtc = new Date(Date.now() + ms).toISOString();
}
this.persist().catch(() => {});
}
const deadline = new Date(state.waitDeadlineUtc).getTime();
const session = new WaitSession(ctx, new Date(deadline));
this.onWait?.(session);
this.ctx.effects.emitWait(session);
const remaining = deadline - Date.now();
if (remaining <= 0) {
this.completeNode(run.runId, state.nodeId, 'onComplete');
} else {
const timerKey = `${run.runId}:${state.nodeId}`;
clearTimeout(this.waitTimers.get(timerKey));
this.waitTimers.set(
timerKey,
setTimeout(() => {
this.completeNode(run.runId, state.nodeId, 'onComplete');
this.waitTimers.delete(timerKey);
}, remaining),
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 dispatchRemoteConfigOverride(
run: RuntimeRun,
state: ActiveNodeState,
ctx: ScenarioNodeContext,
): void {
const patches = ctx.data.patches as Array<{
path?: string;
valueType?: string;
value?: string;
}> | undefined;
if (patches) {
for (const patch of patches) {
if (patch.path) {
this.domains.config.applyOverride(
patch.path,
patch.value ?? '',
patch.valueType ?? 'json',
);
this.ctx.effects.emitConfigChanged(patch.path);
}
}
}
this.completeNode(run.runId, state.nodeId, 'output');
}
// ---- Internal: Node completion ----
/** Synchronous fire-and-forget completion. */
private completeNode(runId: string, nodeId: string, handle: string): void {
this.completeNodeInternal(runId, nodeId, handle, true).catch(() => {});
}
/** Async completion — called by sessions. */
async completeNodeAsync(
runId: string,
nodeId: string,
handle: string,
): Promise<void> {
return this.completeNodeInternal(runId, nodeId, handle, true);
}
private async completeNodeInternal(
runId: string,
nodeId: string,
handle: string,
continueOnBoundary: boolean,
): Promise<void> {
const run = this.runs.get(runId);
if (!run) return;
const key = completedHandleKey(nodeId, handle);
if (run.completedHandles.has(key)) return; // idempotent
// Mark this transition as in flight so that a transiently-empty
// activeNodes (e.g. between this node and an auto-completing successor)
// does not cause the run to be reported complete prematurely.
run.pendingTransitions++;
try {
let boundaryContinued = false;
if (continueOnBoundary) {
try {
boundaryContinued = await this.continueBoundary(run, nodeId, handle);
} catch (err: unknown) {
if (err instanceof TransientBoundaryError) {
// Transient error — don't advance the run.
// Node stays active, handle stays pending for retry on reconnect.
run.pendingTransitions--;
await this.persist();
return;
}
throw err; // terminal — handled by outer catch
}
}
// Only now — after the server has confirmed — mark the handle and node.
run.completedHandles.add(key);
run.activeNodes.delete(nodeId);
await this.persist();
if (!boundaryContinued) {
for (const edge of matchingEdges(run.plan, nodeId, handle)) {
if (edge.target) {
this.activateNode(run, edge.target);
}
}
}
run.pendingTransitions--;
this.checkRunCompleted(run);
await this.persist();
} catch (err) {
run.pendingTransitions--;
this.failRun(
run,
private dispatchBattlePass(record: ActiveRecord): void {
const { scenarioId, nodeId, runId } = record;
this.ctx.effects.emitBattlePass({
getProgress: () => this.battlePass.getProgress(scenarioId, nodeId),
addXp: (source, amount) =>
this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }),
claimReward: (level, track) =>
this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }),
purchasePremium: async () => {
const response = await this.battlePass.purchasePremium({
scenarioId,
nodeId,
err instanceof Error ? err : new Error(String(err)),
);
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 continueBoundary(
run: RuntimeRun,
nodeId: string,
handle: string,
): Promise<boolean> {
const boundaries = [...matchingBoundaryNodes(run.plan, nodeId, handle)];
if (boundaries.length === 0) return false;
for (const boundary of boundaries) {
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, {
scenarioId: record.scenarioId,
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.scenarioId);
}
} 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, {
scenarioId: run.plan.scenarioId,
nodeId: boundary.sourceNodeId,
handle: boundary.sourceHandle,
runId: run.runId,
scenarioId: record.scenarioId,
nodeId: record.nodeId,
handle,
runId: record.runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (response?.plan) {
this.startPlan(response.plan);
if (!this.active.has(key)) return;
this.deactivate(key);
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioId);
}
} catch (err: unknown) {
// Boundary call failed — try to reconcile with server.
let reconciled = false;
try {
const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId });
const reconcileStatus: string | undefined = reconcile?.status;
if (reconcileStatus === 'unknown_run' || reconcileStatus === 'expired') {
this.runs.delete(run.runId);
await this.persist();
reconciled = true;
} else if (reconcile?.plan) {
const newRun = new RuntimeRun(run.runId, reconcile.plan);
this.runs.set(run.runId, newRun);
const startNode = findNode(reconcile.plan, reconcile.plan.startNodeId) ?? reconcile.plan.nodes?.[0];
if (startNode?.id) {
this.activateNode(newRun, startNode.id);
}
reconciled = true;
}
} catch {
// Reconciliation also failed.
}
if (!reconciled) {
// Reconcile did not resolve — distinguish transient from terminal.
if (isTransientHttpError(err)) {
throw new TransientBoundaryError(err);
}
// Terminal error (typed error code from the server) — let the caller failRun.
} 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;
}
// If reconciled, the boundary was handled (run corrected or removed).
// Fall through to continue to the next boundary.
}
}
return true;
}
// ---- Internal: Counter progress ----
async updateProgressInternal(
runId: string,
nodeId: string,
counterKey: string,
amount: number,
): Promise<void> {
const run = this.runs.get(runId);
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioId: run?.plan.scenarioId ?? '',
nodeId,
counterKey,
amount,
runId,
});
// The server reports objective completion; it no longer returns a plan from the
// counter endpoint. On completion, cross the quest's onComplete boundary (which
// grants rewards + advances the run) — idempotent if the consumer also calls complete().
if (response?.completed) {
await this.completeNodeInternal(runId, nodeId, 'onComplete', true);
}
} catch {
// Counter update failure does not fail the run.
throw err;
}
}
// ---- Internal: Run lifecycle ----
private checkRunCompleted(run: RuntimeRun): void {
if (run.activeNodes.size > 0) return;
// A node transition is still settling (e.g. an auto-completing
// remote_config_override node is about to activate its successor).
// Wait for it to finish before declaring the run complete.
if (run.pendingTransitions > 0) return;
if (!this.runs.has(run.runId)) return; // already completed/removed
this.runs.delete(run.runId);
const planRun = this.toPlanRun(run);
this.onRunCompleted?.(planRun);
this.onCompleted?.();
this.ctx.effects.emitScenarioCompleted(planRun);
this.persist().catch(() => {});
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 failRun(
run: RuntimeRun,
nodeId: string,
error: Error,
): void {
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 scenarioId = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioId) scenarioId = record.scenarioId;
this.deactivate(key);
}
console.warn(
`[Rudder] Scenario run ${run.runId} failed at node ${nodeId}: ${error.message}`,
`[Rudder] Scenario run ${runId} failed at node ${nodeId}: ${error.message}`,
);
this.runs.delete(run.runId);
const event: ScenarioRunFailedEvent = { run: this.toPlanRun(run), nodeId, error };
this.onRunFailed?.(event);
this.ctx.effects.emitScenarioFailed(event);
this.persist().catch(() => {});
}
// ---- Internal: Helpers ----
private toPlanRun(run: RuntimeRun): PlanRun {
return new PlanRun(
run.runId,
run.plan.planId ?? '',
run.plan.scenarioId ?? '',
run.plan.userId ?? '',
[...run.activeNodes.keys()],
run.plan,
);
}
private async persist(): Promise<void> {
const store = this.planStore;
if (!store) return;
const state = JSON.stringify({
runs: [...this.runs.values()].map((run) => ({
runId: run.runId,
plan: run.plan,
activeNodes: [...run.activeNodes.values()],
completedHandles: [...run.completedHandles],
})),
this.ctx.effects.emitScenarioFailed({
runId,
scenarioId,
nodeId,
error,
});
store.state = state;
try {
await store.save(state);
} catch {
// Best-effort persistence.
}
private emitCompletedIfIdle(runId: string, scenarioId: 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, scenarioId });
}
}
-74
View File
@@ -1,74 +0,0 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../generated/common.js';
/** Finds a node by ID within a plan (linear scan of the nodes array). */
export function findNode(
plan: ExecutionPlan,
nodeId: string | undefined,
): ExecutionPlanNode | undefined {
if (!nodeId || !plan.nodes) return undefined;
return plan.nodes.find((n) => n.id === nodeId);
}
/** Yields all edges whose source node and sourceHandle match. */
export function* matchingEdges(
plan: ExecutionPlan,
sourceNodeId: string,
sourceHandle: string,
): Generator<PlanEdge> {
for (const edge of plan.edges ?? []) {
if (
edge.source === sourceNodeId &&
(edge.sourceHandle ?? '') === (sourceHandle ?? '')
) {
yield edge;
}
}
}
/** Yields all boundary nodes matching the completed node + handle. */
export function* matchingBoundaryNodes(
plan: ExecutionPlan,
sourceNodeId: string,
sourceHandle: string,
): Generator<BoundaryNode> {
for (const boundary of plan.boundaryNodes ?? []) {
if (
boundary.sourceNodeId === sourceNodeId &&
(boundary.sourceHandle ?? '') === (sourceHandle ?? '')
) {
yield boundary;
}
}
}
/** Returns a deduplication key for completed node+handle pairs. */
export function completedHandleKey(nodeId: string, handle: string): string {
return `${nodeId}:${handle ?? ''}`;
}
/** Converts wait duration+unit to milliseconds. Unit required; defaults to seconds when unit is unrecognized. */
export function durationToMs(duration: number, unit: string): number {
switch ((unit ?? 'seconds').toLowerCase()) {
case 'days':
case 'day':
case 'd':
return duration * 86_400_000;
case 'hours':
case 'hour':
case 'hr':
case 'h':
return duration * 3_600_000;
case 'minutes':
case 'minute':
case 'min':
case 'm':
return duration * 60_000;
case 'seconds':
case 'second':
case 'sec':
case 's':
return duration * 1000;
default:
return duration * 1000; // default: seconds
}
}
-94
View File
@@ -1,94 +0,0 @@
/**
* IndexedDB-backed plan state store for persisting scenario execution state.
*
* The C# SDK uses a synchronous `IPlanStateStore { string State { get; set; } }`.
* IndexedDB is inherently async, so we provide an async `load()` / `save()` API
* alongside a synchronous `state` property for immediate reads after load.
*/
export interface PlanStateStore {
/** Current serialized state (available after `load()` or `save()`). */
state: string | null;
/** Loads persisted state from IndexedDB. Call once on SDK initialization. */
load(): Promise<void>;
/** Persists the current state to IndexedDB. */
save(state: string | null): Promise<void>;
}
const DB_NAME = 'RudderPlanState';
const STORE_NAME = 'state';
const KEY = 'active_runs';
const DB_VERSION = 1;
export function createIndexedDbPlanStateStore(): PlanStateStore {
let dbPromise: Promise<IDBDatabase> | null = null;
let cachedState: string | null = null;
function getDb(): Promise<IDBDatabase> {
if (!dbPromise) {
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return dbPromise;
}
return {
get state(): string | null {
return cachedState;
},
set state(value: string | null) {
cachedState = value;
// Best-effort async write — does not block the setter.
getDb()
.then((db) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
if (value === null) {
tx.objectStore(STORE_NAME).delete(KEY);
} else {
tx.objectStore(STORE_NAME).put(value, KEY);
}
})
.catch(() => {
// IndexedDB write failed — state is still in memory.
});
},
async load(): Promise<void> {
const db = await getDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(KEY);
req.onsuccess = () => {
cachedState = (req.result as string) ?? null;
resolve();
};
req.onerror = () => reject(req.error);
});
},
async save(state: string | null): Promise<void> {
cachedState = state;
const db = await getDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const req =
state === null
? tx.objectStore(STORE_NAME).delete(KEY)
: tx.objectStore(STORE_NAME).put(state, KEY);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
};
}
-342
View File
@@ -1,342 +0,0 @@
import type { ExecutionPlanNode } from '../../generated/common.js';
import type { PlanRun } from './types.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../../state/shops.js';
import type { PurchaseOfferResponse } from '../../generated/stores.js';
import type { BattlePassService } from '../../battlepass/BattlePassService.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
GetBattlePassProgressResponse,
PurchaseBattlePassPremiumResponse,
} from '../../generated/battlepass.js';
/** Resolves a store handle by slug — satisfied by the stores domain. */
interface StoreResolver {
getBySlug(slug: string): Promise<ShopHandle>;
}
// ---- ScenarioNodeContext ----
/**
* Context object passed to each session.
* Wraps the current run, plan node, and raw node data.
* The session calls `complete(handle)` to advance the DAG.
*/
export class ScenarioNodeContext {
constructor(
public readonly run: PlanRun,
public readonly node: ExecutionPlanNode,
private readonly onComplete: (runId: string, nodeId: string, handle: string) => Promise<void>,
private readonly onProgress: (runId: string, nodeId: string, counterKey: string, amount: number) => Promise<void>,
) {}
/** Extracts a typed value from node data. */
get<T>(key: string, defaultValue: T): T {
const data = this.node.data as Record<string, unknown> | undefined;
const value = data?.[key];
if (value === undefined || value === null) return defaultValue;
return value as unknown as T;
}
/** Full node data as a typed record. */
get data(): Record<string, unknown> {
return (this.node.data as Record<string, unknown>) ?? {};
}
/** Completes the current node with the given output handle. */
async complete(handle: string): Promise<void> {
await this.onComplete(this.run.runId, this.node.id!, handle);
}
/** Updates counter progress for server-managed scenario nodes. */
async updateProgress(counterKey: string, amount: number): Promise<void> {
await this.onProgress(this.run.runId, this.node.id!, counterKey, amount);
}
}
// ---- NotificationSession ----
export class NotificationSession {
public readonly title: string;
public readonly message: string;
constructor(private readonly context: ScenarioNodeContext) {
this.title = context.get('title', '');
this.message = context.get('message', '');
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async complete(): Promise<void> {
await this.context.complete('output');
}
}
// ---- WaitSession ----
export class WaitSession {
constructor(
private readonly context: ScenarioNodeContext,
public readonly deadlineUtc: Date,
) {}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
}
// ---- StoreSession ----
export class StoreSession {
private resolved = false;
constructor(
private readonly context: ScenarioNodeContext,
private readonly stores: StoreResolver,
) {}
get isResolved(): boolean {
return this.resolved;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async getStore(): Promise<ShopHandle> {
return this.stores.getBySlug(this.get('storeSlug', ''));
}
async buy(
offer: OfferHandle,
options?: BuyOptions,
): Promise<PurchaseOfferResponse> {
if (this.resolved) {
return { success: false, error: 'store session already resolved' };
}
const purchase = await offer.buy(options);
if (purchase.success) {
this.resolved = true;
await this.context.complete('onPurchase');
}
return purchase;
}
async decline(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onDecline');
}
}
// ---- LeaderboardSession ----
export class LeaderboardSession {
private resolved = false;
constructor(private readonly context: ScenarioNodeContext) {}
get isResolved(): boolean {
return this.resolved;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async end(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onEnd');
}
async rewardClaimed(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onRewardClaimed');
}
}
// ---- QuestSession ----
/**
* A scenario quest node. The game reports objective progress; the server
* auto-completes the node (crossing `onComplete`, which grants rewards and
* advances the run) once every objective is satisfied.
*/
export class QuestSession {
constructor(private readonly context: ScenarioNodeContext) {}
get name(): string {
return this.context.get('name', '');
}
get objectives(): Array<Record<string, unknown>> {
return this.context.get('objectives', [] as Array<Record<string, unknown>>);
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
/**
* Reports progress toward an objective. When the reported counter completes
* every objective, the server signals completion and the node crosses
* `onComplete` automatically.
*/
async reportProgress(objectiveId: string, amount = 1): Promise<void> {
await this.context.updateProgress(objectiveId, amount);
}
}
// ---- BattlePassSession ----
/**
* A scenario battlepass node. Exposes the battlepass operations (xp, premium,
* progress) bound to this node's scenario/node/run ids, plus explicit boundary
* crossings (`onLevelUp`, `onPremiumPurchase`) the game drives from its UI. The
* server validates each crossing (e.g. onLevelUp requires the level be reached).
*/
export class BattlePassSession {
constructor(
private readonly context: ScenarioNodeContext,
private readonly battlePass: BattlePassService,
) {}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
private ids(): { scenarioId: string; nodeId: string; runId: string } {
return {
scenarioId: this.context.run.scenarioId,
nodeId: this.context.node.id!,
runId: this.context.run.runId,
};
}
/** Current xp/level/premium/claimed-tiers for this node. */
getProgress(): Promise<GetBattlePassProgressResponse> {
const { scenarioId, nodeId } = this.ids();
return this.battlePass.getProgress(scenarioId, nodeId);
}
/** Credits XP from a configured source. */
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse> {
const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId });
}
/** Claims a tier reward at a reached level. */
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse> {
const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId });
}
/** Purchases premium, then crosses `onPremiumPurchase` on success. */
async purchasePremium(): Promise<PurchaseBattlePassPremiumResponse> {
const { scenarioId, nodeId, runId } = this.ids();
const response = await this.battlePass.purchasePremium({
scenarioId,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
if (response.success) {
await this.context.complete('onPremiumPurchase');
}
return response;
}
/** Crosses `onLevelUp` (server validates the node's level was reached). */
async levelUp(): Promise<void> {
await this.context.complete('onLevelUp');
}
/** Ends the battlepass node via `onComplete`. */
async end(): Promise<void> {
await this.context.complete('onComplete');
}
}
// ---- BattlePassLevelSession ----
/**
* A scenario battlepass_level node — a single claimable tier. `claim()` crosses
* `onComplete`, which the server accepts only once the player has reached the
* node's configured level.
*/
export class BattlePassLevelSession {
constructor(private readonly context: ScenarioNodeContext) {}
get level(): number {
return this.context.get('levelNumber', 0);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
/** Claims this tier; crosses `onComplete` (server checks level reached). */
async claim(): Promise<void> {
await this.context.complete('onComplete');
}
}
-63
View File
@@ -1,63 +0,0 @@
import type { ExecutionPlan } from '../../generated/common.js';
/**
* Active node state within a running scenario plan.
* For wait nodes, this includes the deadline.
*/
export interface ActiveNodeState {
nodeId: string;
waitDeadlineUtc?: string; // ISO 8601 string, set for wait nodes
}
/**
* A running scenario plan instance — tracks active nodes and completed handles.
*/
export class RuntimeRun {
public readonly activeNodes = new Map<string, ActiveNodeState>();
public readonly completedHandles = new Set<string>();
/**
* Number of node transitions currently in flight for this run.
* A transition begins when a node starts completing and ends once its
* successors have been activated. While > 0 the run must not be considered
* complete, even if `activeNodes` is transiently empty (e.g. an
* auto-completing remote_config_override node between two client nodes).
*/
public pendingTransitions = 0;
constructor(
public readonly runId: string,
public readonly plan: ExecutionPlan,
) {}
}
/** Serialization format for a persisted run. */
export interface PersistedRun {
runId: string;
plan: ExecutionPlan;
activeNodes: ActiveNodeState[];
completedHandles: string[];
}
/** Top-level serialization format for all active runs. */
export interface PersistedState {
runs: PersistedRun[];
}
/** Read-only snapshot of a running plan, surfaced in events. */
export class PlanRun {
constructor(
public readonly runId: string,
public readonly planId: string,
public readonly scenarioId: string,
public readonly userId: string,
public readonly activeNodeIds: readonly string[],
public readonly plan: ExecutionPlan,
) {}
}
/** Failure event payload. */
export interface ScenarioRunFailedEvent {
run: PlanRun;
nodeId: string;
error: Error;
}
+2 -3
View File
@@ -30,7 +30,7 @@ describe('AuthService', () => {
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
expect(fetchMock).toHaveBeenCalledTimes(8);
expect(fetchMock).toHaveBeenCalledTimes(9);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/sdk/v1/authorization/device');
const body = JSON.parse(init.body);
@@ -46,10 +46,9 @@ describe('AuthService', () => {
'/sdk/v1/player/information',
'/sdk/v1/stores',
'/sdk/v1/catalog',
'/sdk/v1/scenarios/pending',
'/sdk/v1/scenarios/trigger',
// The login event invalidates the warmed profile → refetch.
'/sdk/v1/player/information',
// Sync engine baseline poll, fired right after login.
'/sdk/v1/sync',
]);
});
+2 -2
View File
@@ -64,7 +64,7 @@ describe('public API surface', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null },
runtime: { loginEvent: null },
});
expect(client.effects).toBeDefined();
@@ -75,7 +75,7 @@ describe('public API surface', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null },
runtime: { loginEvent: null },
});
expect(client.remoteConfig).toBeDefined();
+10 -10
View File
@@ -148,7 +148,7 @@ describe('lazy scenario runtime', () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: { planStateStore: null, loginEvent: null },
runtime: { loginEvent: null },
});
const internals = client as unknown as {
scenarioRuntime: unknown;
@@ -163,7 +163,7 @@ describe('lazy scenario runtime', () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: { planStateStore: null }, // default loginEvent: player_login
runtime: {},
});
const notifications: string[] = [];
@@ -182,18 +182,18 @@ describe('lazy scenario runtime', () => {
}
if (path === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({
plans: [{
planId: 'plan-1',
scenarioId: 'scenario-1',
userId: 'user-1',
startNodeId: 'start',
effects: [{
runId: 'run-1',
nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }],
edges: [],
boundaryNodes: [],
scenarioId: 'scenario-1',
nodeId: 'start',
type: 'notification',
data: { message: 'Welcome!' },
}],
}), { status: 200 }));
}
if (path === '/sdk/v1/scenarios/pending') {
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 }));
}
if (path === '/sdk/v1/remote-configs') {
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
}
+255 -367
View File
@@ -2,43 +2,34 @@ 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 type { ExecutionPlan, ExecutionPlanNode, PlanEdge } from '../src/generated/common.js';
import { NotificationSession, StoreSession, WaitSession, LeaderboardSession } from '../src/scenario/engine/sessions.js';
/** Builds a simple execution plan with the given nodes and edges. */
function makePlan(overrides?: Partial<ExecutionPlan>): ExecutionPlan {
return {
planId: 'plan-1',
scenarioId: 'scenario-1',
userId: 'user-1',
startNodeId: 'start',
runId: 'run-1',
nodes: [],
edges: [],
boundaryNodes: [],
context: undefined,
...overrides,
};
}
function makeNode(id: string, type: string, data?: Record<string, unknown>): ExecutionPlanNode {
return { id, type, data: data as ExecutionPlanNode['data'] };
}
function makeEdge(source: string, sourceHandle: string, target: string): PlanEdge {
return { id: `edge-${source}-${target}`, source, sourceHandle, target };
}
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: { planStateStore: null, loginEvent: null }, // Disable IndexedDB for tests
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', {
@@ -46,119 +37,100 @@ describe('ScenarioService', () => {
});
});
describe('send (trigger)', () => {
it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => {
const { client, scenarios } = await createClientWithScenario();
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
describe('send (trigger)', () => {
it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
scenarios.onNotification = onNotification;
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.plans).toHaveLength(1);
expect(response.effects).toHaveLength(1);
expect(scenarios.isRunning).toBe(true);
expect(onNotification).toHaveBeenCalledOnce();
expect(onNotification.mock.calls[0][0]).toBeInstanceOf(NotificationSession);
expect(onNotification.mock.calls[0][0].title).toBe('Hi');
expect(onNotification.mock.calls[0][0].message).toBe('Welcome');
});
});
describe('node dispatch', () => {
describe('effect emission', () => {
it('notification node fires onNotification', async () => {
const { scenarios } = await createClientWithScenario();
const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
client.effects.onNotification(onNotif);
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
});
it('store node fires onStore', async () => {
const { scenarios } = await createClientWithScenario();
it('store node fires onStoreOffer', async () => {
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
scenarios.onStore = onStore;
const plan = makePlan({
nodes: [makeNode('start', 'store')],
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', name: 'Pack' }] });
}
return jsonResponse({});
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onStore).toHaveBeenCalledOnce();
expect(onStore.mock.calls[0][0]).toBeInstanceOf(StoreSession);
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 { scenarios } = await createClientWithScenario();
const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn();
scenarios.onWait = onWait;
const plan = makePlan({
nodes: [makeNode('start', 'wait', { duration: 5, unit: 'minutes' })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
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]).toBeInstanceOf(WaitSession);
expect(onWait.mock.calls[0][0].deadlineUtc).toBeInstanceOf(Date);
});
it('leaderboard node fires onLeaderboard', async () => {
const { scenarios } = await createClientWithScenario();
const { client, scenarios } = await createClientWithScenario();
const onLb = vi.fn();
scenarios.onLeaderboard = onLb;
const plan = makePlan({
nodes: [makeNode('start', 'leaderboard')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
client.effects.onLeaderboard(onLb);
stubFetch(() => jsonResponse({ effects: [effect('leaderboard')] }));
await scenarios.send('test');
expect(onLb).toHaveBeenCalledOnce();
expect(onLb.mock.calls[0][0]).toBeInstanceOf(LeaderboardSession);
});
it('quest node dispatches onQuest instead of stalling', async () => {
it('quest node dispatches onQuest', async () => {
const { client, scenarios } = await createClientWithScenario();
const onQuest = vi.fn();
client.effects.onQuest(onQuest);
const plan = makePlan({
nodes: [
makeNode('start', 'quest', {
name: 'Daily',
objectives: [{ objectiveId: 'kills', target: 10 }],
}),
],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
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');
// Active and waiting for progress — not stalled, not failed.
expect(scenarios.isRunning).toBe(true);
});
@@ -166,14 +138,7 @@ describe('ScenarioService', () => {
const { client, scenarios } = await createClientWithScenario();
const onBp = vi.fn();
client.effects.onBattlePass(onBp);
const plan = makePlan({
nodes: [makeNode('start', 'battlepass', { premiumPrice: 100 })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
stubFetch(() => jsonResponse({ effects: [effect('battlepass', { premiumPrice: 100 })] }));
await scenarios.send('test');
expect(onBp).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(true);
@@ -183,346 +148,269 @@ describe('ScenarioService', () => {
const { client, scenarios } = await createClientWithScenario();
const onLevel = vi.fn();
client.effects.onBattlePassLevel(onLevel);
const plan = makePlan({
nodes: [makeNode('start', 'battlepass_level', { levelNumber: 3 })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
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('remote_config_override node applies patches internally and auto-completes', async () => {
const { client, scenarios } = await createClientWithScenario();
const plan = makePlan({
nodes: [
makeNode('start', 'remote_config_override', {
patches: [
{ path: 'difficulty', valueType: 'string', value: 'hard' },
],
}),
],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(client.remoteConfig.get('difficulty', '')).toBe('hard');
// RemoteConfigOverride auto-completes asynchronously — wait for the promise.
await new Promise((r) => setTimeout(r, 50));
expect(scenarios.isRunning).toBe(false);
});
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);
const plan = makePlan({
nodes: [makeNode('start', 'unknown_type')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
stubFetch(() => jsonResponse({ effects: [effect('unknown_type')] }));
await scenarios.send('test');
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Unsupported scenario node type'),
);
// The run must NOT stall: it fails and the game is notified.
expect(onFailed).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(false);
warn.mockRestore();
});
});
describe('DAG traversal', () => {
it('completing a node follows matching edges', async () => {
const { scenarios } = await createClientWithScenario();
describe('callback continuation', () => {
it('completing an effect posts callback and emits the next effect', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
client.effects.onNotification(onNotif);
const first = effect('notification', { message: 'one' }, { nodeId: 'n1' });
const second = effect('notification', { message: 'two' }, { nodeId: 'n2' });
// start(notif) → complete("output") → node2(notif)
const plan = makePlan({
nodes: [
makeNode('start', 'notification'),
makeNode('node2', 'notification'),
],
edges: [makeEdge('start', 'output', 'node2')],
});
let callCount = 0;
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [first] });
}
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
}));
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ effect: second });
}
return jsonResponse({});
});
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce(); // start node dispatched
// Complete the notification node with handle "output"
const session = onNotif.mock.calls[0][0] as NotificationSession;
await session.complete();
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({
scenarioId: 'scenario-1',
nodeId: 'n1',
handle: 'output',
runId: 'run-1',
});
});
it('completing a node with already-completed handle is idempotent', async () => {
const { scenarios } = await createClientWithScenario();
it('completing the last effect emits onScenarioCompleted', async () => {
const { client, scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
const plan = makePlan({
nodes: [
makeNode('start', 'notification'),
makeNode('node2', 'notification'),
],
edges: [makeEdge('start', 'output', 'node2')],
client.effects.onScenarioCompleted(onCompleted);
client.effects.onNotification(onNotif);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('notification')] });
}
return jsonResponse({});
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(scenarios.activeRuns).toHaveLength(1);
const session = onNotif.mock.calls[0][0] as NotificationSession;
await session.complete();
// node2 should now be active
expect(scenarios.activeRuns).toHaveLength(1);
// Complete node2
const session2 = onNotif.mock.calls[1][0] as NotificationSession;
await session2.complete();
// Run should be completed
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
expect(onCompleted).toHaveBeenCalledOnce();
expect(onCompleted.mock.calls[0][0]).toEqual({
runId: 'run-1',
scenarioId: 'scenario-1',
});
expect(scenarios.isRunning).toBe(false);
});
it('run completion fires onRunCompleted and onCompleted', async () => {
const { scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onRunCompleted = vi.fn();
scenarios.onCompleted = onCompleted;
scenarios.onRunCompleted = onRunCompleted;
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
const session = (scenarios as unknown as { onNotification?: (s: NotificationSession) => void }).onNotification?.(
// Use the mock calls to get the session
vi.mocked(vi.fn()).mock.calls[0]?.[0] as NotificationSession,
);
// We need to get the session from the mock spy
// Actually let's trigger via respond()
scenarios.respond('output');
// Fire-and-forget — wait a tick
await new Promise((r) => setTimeout(r, 10));
expect(onRunCompleted).toHaveBeenCalled();
expect(onCompleted).toHaveBeenCalled();
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('wait nodes', () => {
it('sets a deadline and fires onWait', async () => {
const { scenarios } = await createClientWithScenario();
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();
scenarios.onWait = onWait;
const onNotif = vi.fn();
client.effects.onWait(onWait);
client.effects.onNotification(onNotif);
const plan = makePlan({
nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })],
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({});
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
const session = onWait.mock.calls[0][0] as WaitSession;
expect(session.deadlineUtc).toBeInstanceOf(Date);
// Deadline should be ~10 minutes from now.
const diff = session.deadlineUtc.getTime() - Date.now();
expect(diff).toBeGreaterThan(9 * 60 * 1000);
expect(diff).toBeLessThan(11 * 60 * 1000);
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);
});
});
it('completes immediately if deadline has already passed', async () => {
const { scenarios } = await createClientWithScenario();
const onWait = vi.fn();
const onCompleted = vi.fn();
scenarios.onWait = onWait;
scenarios.onCompleted = onCompleted;
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' });
// Duration of 0 should result in an immediate completion.
const plan = makePlan({
nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })],
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({});
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
// Wait for the setTimeout(0) to fire.
await new Promise((r) => setTimeout(r, 50));
expect(onCompleted).toHaveBeenCalled();
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 { scenarios } = await createClientWithScenario();
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
scenarios.onStore = onStore;
client.effects.onStoreOffer(onStore);
const plan = makePlan({
nodes: [makeNode('start', 'store', { storeSlug: 'starter' })],
});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const parsed = new URL(url);
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
}
if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') {
return Promise.resolve(new Response(JSON.stringify({
if (method === 'GET' && path === '/sdk/v1/stores/starter') {
return jsonResponse({
name: 'Starter',
slug: 'starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
}), { status: 200 }));
});
}
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/stores/starter/offers/pack_1/purchase') {
return Promise.resolve(new Response(JSON.stringify({ success: true, purchaseId: 'purchase-1' }), { status: 200 }));
if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack_1/purchase') {
return jsonResponse({ success: true, purchaseId: 'purchase-1' });
}
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
}));
return jsonResponse({});
});
await scenarios.send('test');
const session = onStore.mock.calls[0][0] as StoreSession;
const onCompleted = vi.fn();
scenarios.onCompleted = onCompleted;
const store = await session.getStore();
const purchase = await session.buy(store.offers[0]);
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);
expect(session.isResolved).toBe(true);
// Second call should be a no-op.
const duplicate = await session.buy(store.offers[0]);
const duplicate = await session.buy(session.offers[0]);
expect(duplicate.success).toBe(false);
});
it('decline() completes with onDecline', async () => {
const { scenarios } = await createClientWithScenario();
it('dismiss() completes with onDecline', async () => {
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
scenarios.onStore = onStore;
const plan = makePlan({
nodes: [makeNode('start', 'store')],
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' }] });
}
return jsonResponse({});
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
const session = onStore.mock.calls[0][0] as StoreSession;
await session.decline();
expect(session.isResolved).toBe(true);
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('respond / clear', () => {
it('respond completes the first active node', async () => {
const { scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
const plan = makePlan({
nodes: [
makeNode('start', 'notification'),
makeNode('node2', 'notification'),
],
edges: [makeEdge('start', 'output', 'node2')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
// respond() completes the first active node with the given handle.
scenarios.respond('output');
// Wait for async completion.
await new Promise((r) => setTimeout(r, 50));
// node2 should now be active (second notification dispatched).
expect(scenarios.isRunning).toBe(true);
});
describe('clear', () => {
it('clear removes all runs', async () => {
const { scenarios } = await createClientWithScenario();
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
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);
});
});
describe('runId filtering', () => {
it('rejects plans with duplicate runId', async () => {
const { scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
scenarios.onNotification = onNotification;
const plan = makePlan({
runId: 'run-1',
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })),
));
// First send creates a run
await scenarios.send('first-event');
expect(scenarios.activeRuns).toHaveLength(1);
expect(onNotification).toHaveBeenCalledTimes(1);
// Second send with same runId is rejected by startPlan dedup
await scenarios.send('second-event');
expect(scenarios.activeRuns).toHaveLength(1);
expect(onNotification).toHaveBeenCalledTimes(1);
});
});
});
+1 -6
View File
@@ -9,11 +9,6 @@ import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js';
type RuntimeOptions = {
planStateStore?: {
state: string | null;
load(): Promise<void>;
save(state: string | null): Promise<void>;
} | null;
loginEvent?: string | null;
};
@@ -52,7 +47,7 @@ export function createInMemoryTokenStore(): TokenStore {
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
artifact: SeedArtifact = loadArtifact(),
runtime: RuntimeOptions = { planStateStore: null, loginEvent: null },
runtime: RuntimeOptions = { loginEvent: null },
): RudderClient<TConfig> {
return new RudderClient<TConfig>({
baseUrl: artifact.baseUrl,
+3 -4
View File
@@ -16,7 +16,6 @@ describe('e2e-prod: scenarios', () => {
let runCompleted = false;
const client = makeProdClient(artifact, {
planStateStore: null,
loginEvent: artifact.scenario.event,
});
@@ -25,9 +24,9 @@ describe('e2e-prod: scenarios', () => {
storeOffer = effect;
});
const runtime = await getScenarioRuntime(client);
runtime.onRunCompleted = () => {
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.
@@ -45,8 +44,8 @@ describe('e2e-prod: scenarios', () => {
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done();
// remote_config_override applies, then the store offer surfaces.
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
await client.remoteConfig.reload();
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
artifact.remoteConfigs.spawnRateOverride,
);
+50 -83
View File
@@ -3,44 +3,17 @@ import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import { effect } from '../helpers/plan.js';
import type { StoreOfferEffect } from '../../src/index.js';
import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js';
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null },
});
}
/**
* An offer whose `onPurchase` continuation lives server-side (boundary node):
* the client doesn't hold the post-purchase DAG — it must call back so the
* server can validate the transaction and decide what happens next.
*
* `onDecline`, by contrast, is a plain local edge handled entirely on-device.
*/
function offerWithBoundary() {
return plan('offer_flow')
.runId('offer_flow-run')
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy the starter pack!' })
.node('consolation', 'notification', { message: 'Maybe next time!' })
.edge('offer', 'onDecline', 'consolation')
.boundary('offer', 'onPurchase') // server owns what comes after a purchase
.build();
}
/** What the server returns from the callback — a fresh plan = a new run. */
function rewardContinuation() {
return plan('reward_flow')
.runId('reward_flow-run')
.node('reward', 'notification', { message: 'Reward granted: 500 gems!' })
.build();
}
describe('E2E: boundary nodes (server-side continuation)', () => {
let gateway: FakeGateway;
let client: RudderClient;
@@ -61,99 +34,93 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
afterEach(() => vi.unstubAllGlobals());
it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
gateway.onEvent('player_login', offerWithBoundary());
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reward',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onPurchase', reward);
const messages: string[] = [];
const completed: PlanRun[] = [];
const completed: string[] = [];
let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client);
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
runtime.onRunCompleted = (r) => completed.push(r);
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); });
client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioId); });
const token = (await client.auth.loginWithDevice()).accessToken;
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]);
// The callback hit the gateway with the boundary's source node/handle + token.
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' });
expect(callback?.body).toEqual({
scenarioId: 'offer_flow',
nodeId: 'offer',
handle: 'onPurchase',
runId: 'offer_flow-run',
});
expect(callback?.authToken).toBe(token);
// The server-supplied continuation ran (as a new run), not the local edge.
expect(messages).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('Maybe next time!');
// The original run finished; the continuation ('reward_flow') is now active.
expect(completed.map((r) => r.scenarioId)).toContain('offer_flow');
expect(runtime.activeRuns.map((r) => r.scenarioId)).toEqual(['reward_flow']);
expect(completed).toEqual([]);
});
it('decline stays local → no callback, local edge is followed', async () => {
gateway.onEvent('player_login', offerWithBoundary());
it('decline posts callback and continues with the server-supplied next effect', async () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioId: '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((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
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(false);
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(true);
expect(messages).toEqual(['Maybe next time!']);
});
it('a boundary takes precedence over a local edge on the SAME handle', async () => {
// `offer` has BOTH a boundary AND a local edge on onPurchase.
const conflicting = plan('offer_flow')
.runId('offer_flow-conflict')
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy!' })
.node('local_next', 'notification', { message: 'LOCAL branch' })
.edge('offer', 'onPurchase', 'local_next')
.boundary('offer', 'onPurchase')
.build();
gateway.onEvent('player_login', conflicting);
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]);
// Only the server continuation ran; the local edge was suppressed.
expect(messages).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('LOCAL branch');
});
it('a failing callback does not fail or crash the run', async () => {
gateway.onEvent('player_login', offerWithBoundary());
const offerEffect = effect('store', { storeSlug: 'starter' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallbackError('offer', 'onPurchase', 500);
const failures: ScenarioRunFailedEvent[] = [];
const completed: PlanRun[] = [];
const failures: unknown[] = [];
let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client);
client.effects.onStoreOffer((effect) => { offer = effect; });
runtime.onRunFailed = (e) => failures.push(e);
runtime.onRunCompleted = (r) => completed.push(r);
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onScenarioFailed((e) => { failures.push(e); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
// Must not throw despite the 500 from the callback endpoint.
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
expect(failures).toHaveLength(0); // 500 is transient — no terminal failure
// The run stays pending because the boundary call failed transiently.
// On reconnection the consumer can retry the purchase completion.
expect(failures).toHaveLength(0);
expect(runtime.isRunning).toBe(true);
});
});
+55 -39
View File
@@ -3,7 +3,7 @@ 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 { plan } from '../helpers/plan.js';
import { effect } from '../helpers/plan.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
const MINUTE = 60_000;
@@ -13,28 +13,38 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null }, // exercised separately in the persistence test
});
}
/**
* The offer scenario both branches share:
*
* login → wait 1m → offer ──onDecline──→ wait 1m → "still available"
* └─onPurchase─→ "thanks for your purchase"
*/
function offerScenario() {
return plan('offer_flow')
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
.node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' })
.node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' })
.node('reminder', 'notification', { message: 'Your offer is still available!' })
.node('thanks', 'notification', { message: 'Thanks for your purchase!' })
.edge('wait_intro', 'onComplete', 'offer')
.edge('offer', 'onDecline', 'wait_reminder')
.edge('wait_reminder', 'onComplete', 'reminder')
.edge('offer', 'onPurchase', 'thanks')
.build();
function offerScenario(now: number) {
const waitIntro = effect('wait', {}, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const waitReminder = effect('wait', {}, {
scenarioId: '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!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
return { waitIntro, offer, waitReminder, reminder, thanks };
}
describe('E2E: player offer journey', () => {
@@ -66,43 +76,41 @@ describe('E2E: player offer journey', () => {
expect(res.accessToken).toBeTruthy();
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
// The login request carried the project key + a device id.
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();
// The post-login runtime trigger carries the bearer token.
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 () => {
gateway.onEvent('player_login', offerScenario());
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((effect) => { offer = effect; });
client.effects.onNotification((effect) => { notifications.push(effect); });
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
await client.auth.loginWithDevice();
// Immediately after login the player is just waiting — no offer yet.
expect(offer).toBeUndefined();
// The offer must NOT appear before the full minute has elapsed...
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(offer).toBeUndefined();
// ...and surfaces exactly when the minute is up.
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
// Player declines → the DAG moves to the reminder wait, no notification yet.
await offer!.dismiss();
expect(notifications).toHaveLength(0);
// The reminder also honours the full minute, not a moment sooner.
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_000);
@@ -111,38 +119,46 @@ describe('E2E: player offer journey', () => {
});
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
gateway.onEvent('player_login', offerScenario());
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((effect) => { offer = effect; });
client.effects.onNotification((effect) => { notifications.push(effect); });
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());
// The game buys a selected offer; the session advances only after success.
const selectedOffer = offer!.offers[0];
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
expect(purchase.success).toBe(true);
// The purchase hit the gateway with idempotency key + bearer token.
expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]);
// The scenario took the onPurchase branch → "thanks", and the run finished.
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 () => {
gateway.onEvent('player_login', offerScenario());
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((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice();
await vi.advanceTimersByTimeAsync(MINUTE);
+32 -34
View File
@@ -2,39 +2,44 @@ 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 { createMemoryPlanStore, stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import type { StoreSession, WaitSession } from '../../src/scenario/engine/sessions.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: scenario state survives a reload', () => {
describe('E2E: pending effects resume after a new login', () => {
let gateway: FakeGateway;
// Shared backing store mimics IndexedDB persisting across page loads.
const backing = { value: null as string | null };
function clientWithSharedStore(): RudderClient {
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: createMemoryPlanStore(backing), loginEvent: null },
runtime: { loginEvent: null },
});
}
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
backing.value = null;
gateway = createFakeGateway();
gateway.onEvent(
'session_start',
plan('offer_flow')
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
.node('offer', 'store', { message: 'Limited starter pack!' })
.edge('wait_intro', 'onComplete', 'offer')
.build(),
);
gateway = createFakeGateway({
stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] }],
});
const now = Date.now();
const wait = effect('wait', {}, {
scenarioId: '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' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('session_start', wait);
gateway.onCallback('wait_intro', 'onComplete', offer);
gateway.install();
});
@@ -43,33 +48,26 @@ describe('E2E: scenario state survives a reload', () => {
vi.unstubAllGlobals();
});
it('a wait started before reload resumes and fires the offer after reload', async () => {
// --- Session 1: trigger the scenario, then "close the tab" mid-wait ---
const client1 = clientWithSharedStore();
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);
expect(backing.value).toBeTruthy(); // run was persisted
// --- Session 2: fresh client, same persisted state (page reload) ---
const client2 = clientWithSharedStore();
const runtime2 = await getScenarioRuntime(client2);
const resumedWaits: WaitSession[] = [];
let offer: StoreSession | undefined;
runtime2.onWait = (s) => resumedWaits.push(s);
runtime2.onStore = (s) => { offer = s; };
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();
// The wait node was rehydrated and re-dispatched.
expect(runtime2.isRunning).toBe(true);
expect(resumedWaits).toHaveLength(1);
expect(offer).toBeUndefined();
// The remaining wait time still elapses → the offer surfaces on the new client.
await vi.advanceTimersByTimeAsync(MINUTE);
expect(offer).toBeDefined();
expect(offer!.get('message', '')).toBe('Limited starter pack!');
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
});
});
+1 -24
View File
@@ -3,7 +3,6 @@ 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 { plan } from '../helpers/plan.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js';
interface GameRemoteConfig extends Record<string, unknown> {
@@ -25,7 +24,7 @@ function makeClient(): RudderClient<GameRemoteConfig> {
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null },
runtime: { loginEvent: null },
});
}
@@ -76,26 +75,4 @@ describe('E2E: remote config', () => {
expect(client.remoteConfig.status).toBe('idle');
expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
});
it('a remote_config_override scenario node patches the live cache', async () => {
client = new RudderClient<GameRemoteConfig>({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null },
});
gateway.onEvent(
'player_login',
plan('boost')
.node('override', 'remote_config_override', {
patches: [{ path: 'drop_rate', valueType: 'float', value: '0.9' }],
})
.build(),
);
await client.auth.loginWithDevice();
// The override is reflected immediately, without a reload.
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.9);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null },
runtime: { loginEvent: null },
});
}
+76 -53
View File
@@ -1,5 +1,5 @@
import { vi } from 'vitest';
import type { ExecutionPlan } from '../../src/generated/common.js';
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';
@@ -8,18 +8,6 @@ import type {
ListQuestsResponse,
} from '../../src/generated/quests.js';
/**
* In-process fake of the LiveOps API gateway.
*
* Stubs the global `fetch` and routes requests the same way the real gateway
* does — so tests exercise the *real* SDK transport, auth-header injection,
* scenario engine and DAG traversal, just against deterministic in-memory state
* instead of a live backend.
*
* Seed state up front (scenarios per event, remote configs, stores), then drive
* the SDK through a journey and assert on `recorded` requests / server state.
*/
export interface RecordedRequest {
method: string;
path: string;
@@ -29,36 +17,28 @@ export interface RecordedRequest {
}
export interface FakeGatewayState {
/** Plans returned by POST /scenarios/trigger, keyed by event name. */
scenarios: Map<string, ExecutionPlan[]>;
/** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */
callbacks: Map<string, ExecutionPlan>;
/** HTTP status to fail a callback with, keyed by `nodeId:handle`. */
callbackErrors: Map<string, number>;
/** Optional response for POST /scenarios/counter. */
counterPlan?: ExecutionPlan;
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>;
/** Player KV storage, keyed by item id. */
storage: Map<string, StorageItem>;
}
export interface FakeGateway {
state: FakeGatewayState;
recorded: RecordedRequest[];
/** Purchases received, in order. */
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>;
/** Quest claim requests received, in order. */
questClaims: Array<{ questId: string; authToken: string | null }>;
install(): void;
/** Convenience: register the plans returned for a trigger event. */
onEvent(event: string, ...plans: ExecutionPlan[]): void;
/** Convenience: register the plan returned when a boundary handle calls back. */
onCallback(nodeId: string, handle: string, plan: ExecutionPlan): void;
/** Convenience: make a boundary callback fail with an HTTP status (default 500). */
onCallbackError(nodeId: string, handle: string, status?: number): 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 {
@@ -70,12 +50,19 @@ 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(),
counterPlan: seed?.counterPlan,
pendingEffects: seed?.pendingEffects ?? [],
counterCompleted: seed?.counterCompleted ?? false,
counterEffect: seed?.counterEffect,
remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] },
@@ -87,10 +74,32 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
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;
// --- Auth ---
if (method === 'POST' && path === '/sdk/v1/authorization/device') {
const b = body as { deviceId?: string };
return json({
@@ -99,39 +108,56 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
});
}
// --- Player ---
if (method === 'GET' && path === '/sdk/v1/player/information') {
return json({ player: null, wallets: [] });
}
// --- Sync (baseline revision poll after login) ---
if (method === 'GET' && path === '/sdk/v1/sync') {
return json({});
}
// --- Catalog ---
if (method === 'GET' && path === '/sdk/v1/catalog') {
return json({ items: [] });
}
// --- Scenarios ---
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
const event = (body as { event?: string }).event ?? '';
return json({ plans: state.scenarios.get(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 };
const b = body as { nodeId?: string; handle?: string; runId?: string };
const key = `${b.nodeId}:${b.handle}`;
const errStatus = state.callbackErrors.get(key);
if (errStatus) return json({ error: 'callback failed' }, errStatus);
const plan = state.callbacks.get(key);
return json(plan ? { plan } : {});
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') {
return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false });
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 });
}
// --- Remote config ---
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
return json({ configs: state.remoteConfigs });
}
@@ -141,7 +167,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return cfg ? json(cfg) : json({ error: 'not found' }, 404);
}
// --- Stores ---
if (method === 'GET' && path === '/sdk/v1/stores') {
return json({ stores: state.stores, total: state.stores.length });
}
@@ -163,7 +188,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return store ? json(store) : json({ error: 'not found' }, 404);
}
// --- Quests ---
if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests);
}
@@ -174,7 +198,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
}
// --- Storage ---
if (path === '/sdk/v1/storage') {
if (method === 'GET') {
const typeFilter = query.get('types');
@@ -228,14 +251,14 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
purchases,
questClaims,
install,
onEvent(event, ...plans) {
state.scenarios.set(event, plans);
onEvent(event, ...effects) {
state.scenarios.set(event, effects);
},
onCallback(nodeId, handle, plan) {
state.callbacks.set(`${nodeId}:${handle}`, plan);
onCallback(nodeId, handle, next) {
state.callbacks.set(`${nodeId}:${handle}`, next);
},
onCallbackError(nodeId, handle, status = 500) {
state.callbackErrors.set(`${nodeId}:${handle}`, status);
onCallbackError(nodeId, handle, status = 500, code) {
state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
},
};
}
-26
View File
@@ -1,29 +1,3 @@
import type { PlanStateStore } from '../../src/scenario/engine/IndexedDbPlanStore.js';
/**
* In-memory PlanStateStore that survives across RudderClient instances — lets a
* test simulate a page reload: drive scenario A on one client, construct a fresh
* client sharing the same `backing`, call `scenarios.restore()`, and assert the
* run resumed mid-DAG.
*/
export function createMemoryPlanStore(backing: { value: string | null } = { value: null }): PlanStateStore {
return {
get state() {
return backing.value;
},
set state(v: string | null) {
backing.value = v;
},
async load() {
/* state already in `backing` */
},
async save(s: string | null) {
backing.value = s;
},
};
}
/** Deterministic, collision-free crypto.randomUUID() stub for scenario run IDs. */
export function stubDeterministicUuid(): void {
let n = 0;
const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto);
+12 -68
View File
@@ -1,72 +1,16 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.js';
import type { PendingEffect } from '../../src/generated/scenarios.js';
/**
* Small fluent builder for ExecutionPlans, so scenario journeys read like the
* DAGs they model rather than walls of object literals.
*
* plan('offer_flow')
* .node('wait1', 'wait', { duration: 1, unit: 'minutes' })
* .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1' })
* .edge('wait1', 'onComplete', 'offer')
* .build();
*
* The first node added becomes the start node unless `.start(id)` is called.
*/
export class PlanBuilder {
private readonly nodes: ExecutionPlanNode[] = [];
private readonly edges: PlanEdge[] = [];
private readonly boundaryNodes: BoundaryNode[] = [];
private startNodeId?: string;
private runIdValue?: string;
constructor(
private readonly scenarioId: string,
private readonly opts: { planId?: string; userId?: string } = {},
) {}
/** Sets a specific run ID (default: auto-derived from scenarioId). */
runId(id: string): this {
this.runIdValue = id;
return this;
}
node(id: string, type: string, data?: Record<string, unknown>): this {
this.nodes.push({ id, type, data: data as ExecutionPlanNode['data'] });
if (!this.startNodeId) this.startNodeId = id;
return this;
}
edge(source: string, sourceHandle: string, target: string): this {
this.edges.push({ id: `e-${source}-${sourceHandle}-${target}`, source, sourceHandle, target });
return this;
}
/** Registers a server-side boundary node (handle that calls back to the gateway). */
boundary(sourceNodeId: string, sourceHandle: string, nodeId = `b-${sourceNodeId}-${sourceHandle}`): this {
this.boundaryNodes.push({ sourceNodeId, sourceHandle, nodeId });
return this;
}
start(id: string): this {
this.startNodeId = id;
return this;
}
build(): ExecutionPlan {
export function effect(
type: string,
data: Record<string, unknown> = {},
overrides: Partial<PendingEffect> = {},
): PendingEffect {
return {
planId: this.opts.planId ?? `${this.scenarioId}-plan`,
scenarioId: this.scenarioId,
userId: this.opts.userId ?? 'player-1',
startNodeId: this.startNodeId,
runId: this.runIdValue ?? `${this.scenarioId}-run`,
nodes: this.nodes,
edges: this.edges,
boundaryNodes: this.boundaryNodes,
context: undefined,
runId: 'run-1',
scenarioId: 'scenario-1',
nodeId: `${type}-1`,
type,
data,
...overrides,
};
}
}
export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder {
return new PlanBuilder(scenarioId, opts);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
target: 'es2022',
dts: true,
clean: true,
// Code-split the lazily-imported scenario engine out of the initial chunk.
// Code-split the lazily-imported scenario client out of the initial chunk.
splitting: true,
treeshake: true,
platform: 'browser',