7 Commits

Author SHA1 Message Date
edmand46 7271874cac 2.0.0: changelog and skill docs for slugs and environments
CI / check (push) Successful in 16s
CI / publish (push) Has been skipped
Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
2026-09-06 22:39:03 +03:00
edmand46 a7401b534a Slug-based scenarios, quests and offers per the environments contract; regenerated models; e2e seed passes environment to admin mirrors
Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
2026-09-06 21:44:54 +03:00
edmand46 e863cc3bf7 Align e2e-prod suite with the monolith API; drop dead applyOverride; treat run_not_active as terminal
CI / check (push) Successful in 18s
CI / publish (push) Has been skipped
2026-09-06 09:53:45 +03:00
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
55 changed files with 2091 additions and 2160 deletions
+47
View File
@@ -1,5 +1,52 @@
# Changelog
## 2.0.0
Breaking change — major bump, required by the backend environments release.
Every project now has exactly two environments, `staging` and `prod`, and the
SDK key passed as `projectKey` decides which one the player belongs to. The
environment never appears in the client API: it is resolved at login and
carried inside the access and refresh tokens. Tokens issued before this
release have no environment claim and are rejected with 401, so the first call
after the backend upgrade refreshes, fails, clears the token store and emits
`'signed-out'` to every `onAuthStateChange` listener. Log the player in again
with `client.auth.loginWithDevice()` / `loginWithCustom()`.
Quests, scenarios and offers are addressed by their slug instead of their id,
because ids differ between staging and prod while slugs are stable. Renamed
accordingly: `Quest.id` is now `Quest.slug`, `client.quests.claim()` takes a
quest slug, `reportProgress()` resolves to the slugs of the completed quests
(`ReportQuestProgressResponse.completedQuestSlugs`),
`client.stores.purchase(storeSlug, offerSlug, options?)` takes an offer slug
and `OfferHandle` carries a `slug` alongside its `id`,
`QuestMetrics.purchaseOffer(offerSlug)` builds its metric from the offer slug,
and every scenario-scoped type exposes `scenarioSlug` instead of `scenarioId`
`PendingEffect`, `ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle
pass requests and `client.battlePass.getProgress(scenarioSlug, nodeId)`.
`offer.buy()` is unchanged; it now binds the slug for you. Leaderboards, items
and stores already used slugs and are untouched.
Also shipped here, previously committed but never published: the effects client
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run
that disappears server-side (finished elsewhere, expired after a promote) now
emits `onScenarioCompleted` instead of lingering; and `run_not_active` joins
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
emits `onScenarioFailed`.
## 1.0.0
- Scenario execution moved server-side. The SDK no longer walks a local DAG
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
+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": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rudder/js-sdk",
"version": "0.5.0",
"version": "2.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.6.0",
"version": "2.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, offerSlug, 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
+119
View File
@@ -0,0 +1,119 @@
# 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'`.
## Environments
A project has two environments, `staging` and `prod`. The SDK key you pass as
`RudderClientOptions.projectKey` belongs to one of them, so the environment is
resolved at login and carried inside the access and refresh tokens; nothing in
the client API takes an environment argument, and a player created in one
environment is invisible in the other. Content released only to `staging` is
empty for a `prod` key and vice versa.
Tokens issued before SDK 2.0.0 carry no environment claim and are rejected with
401. The transport's refresh then fails, clears the token store and emits
`'signed-out'` — log the player in again.
## 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 `scenarioSlug` + `nodeId` (plus
`runId` for mutating calls). NOT observable — re-fetch progress explicitly
after a mutation.
In most games you do not call this service directly: a scenario battle pass
node surfaces through `client.effects.onBattlePass` with a session object that
wraps these calls (see `reference/scenarios.md`). Use the service directly when
you already know the scenario/node/run identifiers.
## Methods
```ts
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse>
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
```
## Request / response types
```ts
interface AddBattlePassXpRequest {
amount?: number;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
source?: string; // configured XP source
}
interface AddBattlePassXpResponse {
level?: number;
leveledUp?: boolean;
maxLevel?: boolean;
xp?: number;
}
interface ClaimBattlePassRewardRequest {
level?: number;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
track?: 'free' | 'premium';
}
interface ClaimBattlePassRewardResponse {
alreadyClaimed?: boolean;
error?: string;
granted?: Reward[]; // { amount?, currency?, itemId? }
success?: boolean;
}
interface GetBattlePassProgressResponse {
claimedTiers?: ClaimedTier[]; // { level?, track? }
level?: number;
premiumOwned?: boolean;
xp?: number;
}
interface PurchaseBattlePassPremiumRequest {
idempotencyKey?: string;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
}
interface PurchaseBattlePassPremiumResponse {
error?: string;
success?: boolean;
}
```
## Semantics
- `addXp` credits XP from a configured source; returns the new `xp`/`level`
plus `leveledUp` / `maxLevel` flags.
- `claimReward` claims a tier reward at a reached level; idempotent
server-side (`alreadyClaimed`). Claiming a tier above the current level (or
a premium tier without premium) fails server-side — check `success`/`error`
in the response and the typed error `code` (`RudderErrorCodes`).
- `purchasePremium` charges the player's wallet; idempotent; pass your own
`idempotencyKey` for safe retries.
- Prefer the `onBattlePass` effect session, which binds `scenarioSlug` /
`nodeId` / `runId` and posts scenario callbacks for you.
@@ -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(questSlug: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // slugs of quests completed by this report
```
## Types
```ts
interface Quest {
slug?: string; // stable across environments — what claim() takes
name?: string;
objectives?: QuestObjectiveProgress[];
rewards?: Reward[];
status?: 'active' | 'claimed' | 'completed';
}
interface QuestObjectiveProgress {
completed?: boolean;
current?: number;
metric?: string;
objectiveId?: string;
target?: number;
}
interface ClaimQuestResponse {
alreadyClaimed?: boolean;
error?: string;
granted?: Reward[]; // { amount?, currency?, itemId? }
success?: boolean;
}
```
## Usage notes
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.slug) {
const res = await client.quests.claim(quest.slug);
// res.success / res.alreadyClaimed / res.granted
}
}
const completedSlugs = await client.quests.reportProgress('kills', 1);
```
- `claim` is idempotent server-side; check `success` / `alreadyClaimed` /
`error` in the response rather than relying on exceptions.
- Objective completion is judged server-side from metric reports.
## `QuestMetrics` helpers
Exported as a namespace: `import { QuestMetrics } from '@rudder/js-sdk'`
(source: `src/quests/QuestMetrics.ts`).
```ts
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
Purchase metrics are reported automatically server-side by the store purchase
fan-out — these helpers exist so quest configs and client code name the format
consistently. Catalog counter slugs are reported via `reportProgress`. Custom
free-text metrics no longer progress quests — they no-op at runtime.
@@ -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 `scenarioSlug`/`nodeId`/`runId`
already bound — prefer them over calling the service manually.
### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier)
```ts
{ readonly level: number; claim(): Promise<void> }
```
### Run lifecycle effects
```ts
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioSlug: string }
interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioSlug: string; readonly nodeId: string;
readonly error: Error;
}
```
Always subscribe to `onScenarioFailed` — otherwise run failures surface only
as console warnings.
## Supported node types
`wait`, `notification`, `store`, `leaderboard`, `quest`, `battlepass`,
`battlepass_level`. Any other node type fails the run (surfaced via
`onScenarioFailed`). `remote_config_override` is applied server-side and is
not delivered to the client.
## Reliability notes
- Completion methods POST `/sdk/v1/scenarios/callback` with
`{scenarioSlug, runId, nodeId, handle}` using the handles `output`,
`onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`,
`onPremiumPurchase`.
- Transient callback failures (network error, 5xx) leave the effect active
for retry; `unknown_run` / `run_expired` drop that run's effects and fire
`onScenarioFailed`.
- Server scenario errors use the typed codes `run_expired`, `run_not_active`,
`node_not_active`, `scenario_not_active`, `unknown_run`,
`early_completion`, `objectives_incomplete`, `level_not_reached`,
`forbidden` (`RudderErrorCodes`).
@@ -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.
+82
View File
@@ -0,0 +1,82 @@
# Stores / purchases — `client.stores`
`StoresDomain` (source: `src/domains/StoresDomain.ts`, handles in
`src/state/shops.ts`). The observable store list plus the purchase executor.
Synced under revision key `stores`. This is the SDK's economy surface — there
is no separate economy service.
## Surface
```ts
client.stores.data // ShopHandle[] | undefined
client.stores.onChange(cb);
await client.stores.load() / .reload();
// Direct purchase (bypassing handles):
await client.stores.purchase(storeSlug, offerSlug, options?);
```
## Handles
```ts
class ShopHandle {
readonly slug: string;
readonly name?: string;
readonly description?: string;
readonly data?: { [key: string]: unknown };
readonly offers: OfferHandle[];
}
class OfferHandle {
readonly id: string; // authoring id, differs between staging and prod
readonly slug: string; // stable across environments — what purchases use
readonly name?: string;
readonly price?: OfferPrice; // { amount?: number; currency?: string }
readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[]
readonly maxPurchases?: number;
buy(options?: BuyOptions): Promise<PurchaseOfferResponse>;
}
interface BuyOptions { idempotencyKey?: string }
interface PurchaseOfferResponse {
error?: string;
purchaseId?: string;
success?: boolean;
}
```
## Purchase semantics
- `stores.purchase(storeSlug, offerSlug, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the store slug and the offer slug.
- When `options.idempotencyKey` is omitted the SDK generates
`crypto.randomUUID()` per call — safe retries require passing your own key.
- On success the executor invalidates `player`, `inventory`, and `stores`, so
subscribers observe fresh wallet/inventory/store data immediately.
- Check `response.success` / `response.error` — a failed purchase is a resolved
response, not necessarily a thrown error.
- Purchase metrics (`purchase.offer:<offerSlug>`, `purchase.item:<itemId>`) are
reported to quests automatically server-side — do not report them manually
(see `reference/quests.md`).
## Wire types
```ts
interface Store {
createdAt?: string; data?: { [key: string]: unknown }; description?: string;
environment?: string; id?: string; name?: string; offers?: Offer[];
projectId?: string; scenarioId?: string; slug?: string; status?: string;
updatedAt?: string;
}
interface Offer {
contents?: OfferContent[]; createdAt?: string; id?: string;
maxPurchases?: number; name?: string; price?: OfferPrice; slug?: string;
updatedAt?: string;
}
```
`ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store
without `slug` / an offer without `id` or `slug` — in practice the domain only
builds handles from server data that has all of them.
+3 -3
View File
@@ -2,7 +2,7 @@
* BattlePassService — call-and-response access to the battle pass endpoints.
*
* Battle pass state is tied to a scenario battle pass node, so every call
* carries a `scenarioId` + `nodeId` (and a `runId` for the mutating calls).
* carries a `scenarioSlug` + `nodeId` (and a `runId` for the mutating calls).
* Battle pass has no sync-revision key, so this is a plain service, not an
* observable domain; re-fetch progress explicitly after a mutation.
*/
@@ -23,8 +23,8 @@ export class BattlePassService {
constructor(private readonly ctx: RudderContext) {}
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId });
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioSlug, nodeId });
}
/** Credits XP from a configured source. Returns the new xp/level. */
+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;
}
+4 -4
View File
@@ -14,7 +14,7 @@ import type { PurchaseOfferResponse } from '../generated/stores.js';
export class StoresDomain extends SyncedState<ShopHandle[]> {
readonly syncKey = 'stores';
/** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */
/** Shared purchase executor: `stores.purchase(slug, offerSlug, opts)`. */
readonly purchase: PurchaseOffer;
private readonly ctx: RudderContext;
@@ -24,15 +24,15 @@ export class StoresDomain extends SyncedState<ShopHandle[]> {
// handles created in the loader all delegate to this one executor.
const purchase: PurchaseOffer = async (
storeSlug: string,
offerId: string,
offerSlug: string,
options: BuyOptions = {},
): Promise<PurchaseOfferResponse> => {
const response = await api.purchaseOffer(
ctx,
{ storeSlug, offerId },
{ storeSlug, offerSlug },
{
storeSlug,
offerId,
offerSlug,
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
},
);
+21 -82
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,
@@ -44,40 +34,28 @@ 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;
readonly scenarioSlug: 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;
readonly scenarioSlug: string;
readonly nodeId: string;
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>;
@@ -87,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>;
@@ -166,36 +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(),
claim: () => session.claim(),
rewardClaimed: () => session.claim(),
});
emitLeaderboard(effect: LeaderboardEffect): void {
this.emit(this.leaderboardHandlers, effect);
}
/** @internal */
@@ -204,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>(
+8 -9
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> =>
@@ -164,10 +163,10 @@ export const api = {
purchaseOffer: (
t: Transport,
path: { storeSlug: string; offerId: string },
path: { storeSlug: string; offerSlug: string },
body: PurchaseOfferRequest,
): Promise<PurchaseOfferResponse> =>
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerId)}/purchase`, body),
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
refreshAccessToken: (
t: Transport,
+5 -7
View File
@@ -1,12 +1,12 @@
// 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;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
"source"?: string;
}
@@ -14,7 +14,6 @@ export interface AddBattlePassXpResponse {
"level"?: number;
"leveledUp"?: boolean;
"maxLevel"?: boolean;
"plan"?: ExecutionPlan;
"xp"?: number;
}
@@ -22,7 +21,7 @@ export interface ClaimBattlePassRewardRequest {
"level"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
"track"?: "free" | "premium";
}
@@ -40,7 +39,7 @@ export interface ClaimedTier {
export interface GetBattlePassProgressRequest {
"nodeId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
}
export interface GetBattlePassProgressResponse {
@@ -54,12 +53,11 @@ export interface PurchaseBattlePassPremiumRequest {
"idempotencyKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
}
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;
+3 -3
View File
@@ -3,7 +3,7 @@
import type { Reward } from './common.js';
export interface ClaimQuestRequest {
"questId"?: string;
"questSlug"?: string;
}
export interface ClaimQuestResponse {
@@ -18,10 +18,10 @@ export interface ListQuestsResponse {
}
export interface Quest {
"id"?: string;
"name"?: string;
"objectives"?: QuestObjectiveProgress[];
"rewards"?: Reward[];
"slug"?: string;
"status"?: "active" | "claimed" | "completed";
}
@@ -39,6 +39,6 @@ export interface ReportQuestProgressRequest {
}
export interface ReportQuestProgressResponse {
"completedQuestIds"?: string[];
"completedQuestSlugs"?: string[];
}
+18 -17
View File
@@ -1,26 +1,27 @@
// 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;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
}
export interface HandleScenarioCallbackResponse {
"plan"?: ExecutionPlan;
"effect"?: PendingEffect;
}
export interface ListPendingScenarioEffectsResponse {
"effects": PendingEffect[];
}
export interface PendingEffect {
"data": { [key: string]: unknown };
"nodeId": string;
"runId": string;
"scenarioSlug": string;
"type": string;
"waitDeadline"?: string;
}
export interface TriggerScenarioRequest {
@@ -28,7 +29,7 @@ export interface TriggerScenarioRequest {
}
export interface TriggerScenarioResponse {
"plans"?: ExecutionPlan[];
"effects": PendingEffect[];
}
export interface UpdateScenarioCounterRequest {
@@ -36,11 +37,11 @@ export interface UpdateScenarioCounterRequest {
"counterKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
}
export interface UpdateScenarioCounterResponse {
"completed"?: boolean;
"plan"?: ExecutionPlan;
"effect"?: PendingEffect;
}
+2 -1
View File
@@ -12,6 +12,7 @@ export interface Offer {
"maxPurchases"?: number;
"name"?: string;
"price"?: OfferPrice;
"slug"?: string;
"updatedAt"?: string;
}
@@ -27,7 +28,7 @@ export interface OfferPrice {
export interface PurchaseOfferRequest {
"idempotencyKey"?: string;
"offerId"?: string;
"offerSlug"?: string;
"storeSlug"?: string;
}
+2 -2
View File
@@ -9,8 +9,8 @@
*/
/** Metric for purchasing a store offer; auto-reported on purchase. */
export function purchaseOffer(offerId: string): string {
return `purchase.offer:${offerId}`;
export function purchaseOffer(offerSlug: string): string {
return `purchase.offer:${offerSlug}`;
}
/** Metric for purchasing a catalog item; auto-reported on purchase. */
+3 -3
View File
@@ -20,13 +20,13 @@ export class QuestsService {
}
/** Claims a completed quest's rewards (idempotent server-side). */
claim(questId: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questId });
claim(questSlug: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questSlug });
}
/** Reports metric progress; returns ids of quests completed by this report. */
async reportProgress(metric: string, amount: number): Promise<string[]> {
const response = await api.reportQuestProgress(this.ctx, { metric, amount });
return response.completedQuestIds ?? [];
return response.completedQuestSlugs ?? [];
}
}
+356 -505
View File
@@ -1,603 +1,454 @@
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;
scenarioSlug: 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 isRankNotEligible(err: unknown): boolean {
return err instanceof RudderHttpError && err.code === 'rank_not_eligible';
function isDroppedRunError(err: unknown): boolean {
if (!(err instanceof RudderHttpError)) return false;
return (
err.code === RudderErrorCodes.unknownRun ||
err.code === RudderErrorCodes.runExpired ||
err.code === RudderErrorCodes.runNotActive
);
}
function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
private readonly runs = new Map<string, RuntimeRun>();
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.scenarioSlug);
}
const after = new Set(this.active.keys());
if (before.size !== after.size || [...before].some((key) => !after.has(key))) {
this.domains.player.invalidate();
this.domains.inventory.invalidate();
}
}
private ingest(effects: PendingEffect[]): void {
for (const effect of effects) {
this.ingestOne(effect);
}
}
private ingestOne(pending: PendingEffect): void {
if (this.droppedRuns.has(pending.runId)) return;
const key = effectKey(pending.runId, pending.nodeId);
if (this.active.has(key)) {
if (pending.waitDeadline && !this.waitTimers.has(key)) {
this.scheduleWait(key, pending.waitDeadline);
}
return;
}
const 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),
);
const record: ActiveRecord = {
runId: pending.runId,
nodeId: pending.nodeId,
scenarioSlug: pending.scenarioSlug,
};
this.active.set(key, record);
this.completedRuns.delete(pending.runId);
if (pending.waitDeadline) {
this.scheduleWait(key, pending.waitDeadline);
}
this.dispatch(pending, record);
}
switch (node.type) {
case 'wait':
this.dispatchWait(run, state, ctx);
break;
case 'remote_config_override':
this.dispatchRemoteConfigOverride(run, state, ctx);
break;
private dispatch(pending: PendingEffect, record: ActiveRecord): void {
switch (pending.type) {
case 'notification':
{
const session = new NotificationSession(ctx);
this.ctx.effects.emitNotification(session);
this.onNotification?.(session);
}
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--;
if (isRankNotEligible(err)) {
await this.persist();
throw err;
}
this.failRun(
run,
nodeId,
err instanceof Error ? err : new Error(String(err)),
);
}
}
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) {
try {
const response = await api.handleScenarioCallback(this.ctx, {
scenarioId: run.plan.scenarioId,
nodeId: boundary.sourceNodeId,
handle: boundary.sourceHandle,
runId: run.runId,
private dispatchBattlePass(record: ActiveRecord): void {
const { scenarioSlug, nodeId, runId } = record;
this.ctx.effects.emitBattlePass({
getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
addXp: (source, amount) =>
this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
claimReward: (level, track) =>
this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
purchasePremium: async () => {
const response = await this.battlePass.purchasePremium({
scenarioSlug,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (response?.plan) {
this.startPlan(response.plan);
if (response.success) {
await this.complete(record, 'onPremiumPurchase');
}
} 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.
throw err;
}
// If reconciled, the boundary was handled (run corrected or removed).
// Fall through to continue to the next boundary.
}
}
return true;
return response;
},
levelUp: () => this.complete(record, 'onLevelUp'),
end: () => this.complete(record, 'onComplete'),
});
}
// ---- Internal: Counter progress ----
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 };
}
async updateProgressInternal(
runId: string,
nodeId: string,
counterKey: string,
amount: number,
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> {
const run = this.runs.get(runId);
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioId: run?.plan.scenarioId ?? '',
nodeId,
counterKey,
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
counterKey: objectiveId,
amount,
runId,
runId: record.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);
if (!response?.completed) return;
this.domains.player.invalidate();
this.domains.inventory.invalidate();
const key = effectKey(record.runId, record.nodeId);
if (this.active.has(key)) {
this.deactivate(key);
}
if (response.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch {
// Counter update failure does not fail the run.
}
}
// ---- 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 failRun(
run: RuntimeRun,
nodeId: string,
error: Error,
): void {
console.warn(
`[Rudder] Scenario run ${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],
})),
});
store.state = state;
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 {
await store.save(state);
} catch {
// Best-effort persistence.
const response = await api.handleScenarioCallback(this.ctx, {
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
handle,
runId: record.runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (!this.active.has(key)) return;
this.deactivate(key);
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch (err) {
if (isTransientHttpError(err)) return;
if (isDroppedRunError(err)) {
const error = err instanceof Error ? err : new Error(String(err));
this.dropRun(record.runId, record.nodeId, error);
throw err;
}
throw err;
}
}
private scheduleWait(key: string, waitDeadline: string): void {
const deadline = Date.parse(waitDeadline);
if (Number.isNaN(deadline)) return;
const remaining = Math.max(0, deadline - Date.now());
const existing = this.waitTimers.get(key);
if (existing) clearTimeout(existing);
this.waitTimers.set(
key,
setTimeout(() => {
this.waitTimers.delete(key);
void this.pollPending();
}, remaining),
);
}
private deactivate(key: string): void {
const timer = this.waitTimers.get(key);
if (timer) {
clearTimeout(timer);
this.waitTimers.delete(key);
}
this.active.delete(key);
}
private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId);
let scenarioSlug = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
this.deactivate(key);
}
console.warn(
`[Rudder] Scenario run ${runId} failed at node ${nodeId}: ${error.message}`,
);
this.ctx.effects.emitScenarioFailed({
runId,
scenarioSlug,
nodeId,
error,
});
}
private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
if (this.droppedRuns.has(runId)) return;
if (this.completedRuns.has(runId)) return;
for (const record of this.active.values()) {
if (record.runId === runId) return;
}
this.completedRuns.add(runId);
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
}
}
-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);
});
},
};
}
-347
View File
@@ -1,347 +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;
await this.context.complete('onEnd');
this.resolved = true;
}
async claim(): Promise<void> {
if (this.resolved) return;
await this.context.complete('onClaim');
this.resolved = true;
}
/** @deprecated Use {@link LeaderboardSession.claim}. Removed in the next SDK version. */
async rewardClaimed(): Promise<void> {
return this.claim();
}
}
// ---- 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;
}
-16
View File
@@ -23,22 +23,6 @@ export class RemoteConfigState<
if (!config || config.value == null) return defaultValue;
return parseValue(config.value, config.valueType, defaultValue);
}
/**
* Applies a scenario remote_config_override patch to the current snapshot.
*
* @internal
*/
applyOverride(key: string, value: string, valueType = 'json'): void {
const next = new Map(this.data ?? []);
next.set(key, {
key,
value,
valueType,
active: true,
} as RemoteConfig);
this.set(next);
}
}
function parseValue<T>(
+7 -2
View File
@@ -16,12 +16,13 @@ export interface BuyOptions {
*/
export type PurchaseOffer = (
storeSlug: string,
offerId: string,
offerSlug: string,
options?: BuyOptions,
) => Promise<PurchaseOfferResponse>;
export class OfferHandle {
public readonly id: string;
public readonly slug: string;
public readonly name?: string;
public readonly price?: OfferPrice;
public readonly contents: OfferContent[];
@@ -35,7 +36,11 @@ export class OfferHandle {
if (!offer.id) {
throw new Error('Rudder Stores: offer.id is required');
}
if (!offer.slug) {
throw new Error('Rudder Stores: offer.slug is required');
}
this.id = offer.id;
this.slug = offer.slug;
this.name = offer.name;
this.price = offer.price;
this.contents = offer.contents ?? [];
@@ -43,7 +48,7 @@ export class OfferHandle {
}
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
return this.purchase(this.storeSlug, this.id, options);
return this.purchase(this.storeSlug, this.slug, options);
}
}
+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: [],
scenarioSlug: '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 }));
}
+259 -371
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', () => {
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('send (trigger)', () => {
it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => {
it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => {
const { client, 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 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', slug: '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');
it('completing a node with already-completed handle is idempotent', 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')],
const fetchMock = vi.mocked(fetch);
const callbackCall = fetchMock.mock.calls.find((call) => {
const url = String(call[0]);
return url.includes('/sdk/v1/scenarios/callback');
});
expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({
scenarioSlug: 'scenario-1',
nodeId: 'n1',
handle: 'output',
runId: 'run-1',
});
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
expect(scenarios.isRunning).toBe(false);
});
it('run completion fires onRunCompleted and onCompleted', async () => {
const { scenarios } = await createClientWithScenario();
it('completing the last effect emits onScenarioCompleted', async () => {
const { client, scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onRunCompleted = vi.fn();
scenarios.onCompleted = onCompleted;
scenarios.onRunCompleted = onRunCompleted;
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
const onNotif = vi.fn();
client.effects.onScenarioCompleted(onCompleted);
client.effects.onNotification(onNotif);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('notification')] });
}
return jsonResponse({});
});
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();
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
expect(onCompleted).toHaveBeenCalledOnce();
expect(onCompleted.mock.calls[0][0]).toEqual({
runId: 'run-1',
scenarioSlug: 'scenario-1',
});
expect(scenarios.isRunning).toBe(false);
});
});
describe('wait nodes', () => {
it('sets a deadline and fires onWait', async () => {
const { scenarios } = await createClientWithScenario();
const onWait = vi.fn();
scenarios.onWait = onWait;
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);
});
});
const plan = makePlan({
nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })],
describe('waitDeadline timer', () => {
it('polls pending at waitDeadline and emits the next effect', async () => {
vi.useFakeTimers();
const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn();
const onNotif = vi.fn();
client.effects.onWait(onWait);
client.effects.onNotification(onNotif);
const deadline = new Date(Date.now() + 5_000).toISOString();
const wait = effect('wait', {}, { waitDeadline: deadline, nodeId: 'wait-1' });
const next = effect('notification', { message: 'after wait' }, { nodeId: 'n2' });
let pending: PendingEffect[] = [wait];
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [wait] });
}
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
return jsonResponse({ effects: pending });
}
return jsonResponse({});
});
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);
});
});
describe('expired-run drop', () => {
it('drops the run on unknown_run and does not retry it', async () => {
const { client, scenarios } = await createClientWithScenario();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const onFailed = vi.fn();
const onNotif = vi.fn();
client.effects.onScenarioFailed(onFailed);
client.effects.onNotification(onNotif);
const pending = effect('notification', { message: 'hello' });
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [pending] });
}
if (path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ code: 'unknown_run', error: 'gone' }, 410);
}
if (path === '/sdk/v1/scenarios/pending') {
return jsonResponse({ effects: [pending] });
}
return jsonResponse({});
});
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
await expect((onNotif.mock.calls[0][0] as NotificationEffect).done())
.rejects.toBeInstanceOf(RudderHttpError);
expect(onFailed).toHaveBeenCalledOnce();
expect(onFailed.mock.calls[0][0].runId).toBe('run-1');
expect(scenarios.isRunning).toBe(false);
await scenarios.start();
expect(onNotif).toHaveBeenCalledTimes(1);
warn.mockRestore();
client.dispose();
});
it('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;
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);
// 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: [effect('notification')] });
}
if (path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ error: 'boom' }, 500);
}
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();
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 }));
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
});
}
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', slug: '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);
});
});
});
+14 -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,
@@ -79,6 +74,19 @@ export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
return api;
}
/** Credits a player's wallet through the admin batch endpoint. */
export async function grantCurrency(
artifact: SeedArtifact,
playerId: string,
currencyCode: string,
amount: number,
): Promise<void> {
await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/wallet/adjust?environment=${artifact.environment}`,
{ items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] },
);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
+88 -66
View File
@@ -1,11 +1,10 @@
// Production seed: registers an operator, creates a project, configures staging
// content (items, remote configs, leaderboard, store with free+paid offers, a
// content (counter, item, remote configs, leaderboard, store with free+paid offers, a
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
// Excludes realtime & analytics.
//
// The scenario flow uses the SDK runtime's exact node `data` keys and handle names —
// the game's plan builder copies node.data verbatim into the execution plan
// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs.
// The scenario flow uses the server engine's node types, `data` keys and handle names —
// the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
// and .../fsm), so what we store is what the server runs.
import { createAdminClient, type AdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js';
@@ -20,6 +19,7 @@ interface ReleaseResponse {
interface QuestResponse {
id: string;
slug: string;
name: string;
status: string;
objectives?: Array<{ id: string; metric: string; target: number }>;
@@ -29,9 +29,10 @@ interface QuestResponse {
interface StoreResponse {
id: string;
name: string;
data?: { slug?: string };
slug: string;
offers?: Array<{
id: string;
slug: string;
name: string;
contents?: Array<{ itemId: string; amount: number }>;
}>;
@@ -110,31 +111,43 @@ export async function runSeed(
api.setToken(auth.accessToken);
log(`registered ${adminEmail}`);
// 2. Project.
const project = await api.post<{ id: string; key: string; name: string }>(
'/platform/v1/projects',
{ name: `e2e-${ts}` },
);
// 2. Project. Creating one provisions the staging + prod environments, each with
// its own SDK key — that key is what the runtime SDK sends as projectKey.
const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
name: `e2e-${ts}`,
});
const projPath = `/platform/v1/projects/${project.id}`;
log(`project ${project.id} key=${project.key}`);
// 3. Staging environment (idempotent — may already exist).
try {
await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv });
} catch {
// already exists / not required — content POSTs below are the real test
const environments = await api.get<Array<{ name: string; sdkKey: string }>>(
`${projPath}/environments`,
);
const projectKey = environments.find((e) => e.name === runtimeEnv)?.sdkKey;
if (!projectKey) {
throw new Error(`project ${project.id} has no ${runtimeEnv} environment sdk key`);
}
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
// 3. Soft currency counter — offer prices are validated against counter slugs, and
// the purchase debit resolves the currency through the catalog counter cache.
const currency = 'soft';
await api.post(`${projPath}/counters${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Soft Currency',
slug: currency,
kind: 'currency',
});
// 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Gold Coin',
tags: ['currency'],
slug: 'gold-coin',
});
// 5. Remote configs — one per value type. RCs are live per-env flags served
// straight from the outbox/cache, so they go directly into the runtime env.
// straight from the cache and are not copied by a promote, so they go directly
// into the runtime env.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
@@ -164,43 +177,44 @@ export async function runSeed(
});
// 7. Store with a free offer and a paid (currency) offer with a purchase limit.
// The runtime resolves a store's slug from data.slug (liveops-game shop cache), and
// the CreateStore API has no slug field — so we set it via data.
// Stores and offers carry an authored slug that survives promotes.
const storeSlug = 'starter-shop';
const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>(
`${projPath}/stores${q}`,
{
projectId: project.id,
environment: authoringEnv,
name: 'Starter Shop',
description: 'E2E shop',
data: { slug: storeSlug },
offers: [
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } },
{
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency: 'soft', amount: 100 },
maxPurchases: 2,
},
],
},
);
const paidOfferSlug = 'gold-pack';
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: storeSlug,
name: 'Starter Shop',
description: 'E2E shop',
offers: [
{
slug: 'welcome-gift',
name: 'Welcome Gift',
contents: [{ itemId: item.id, amount: 10 }],
price: { currency, amount: 0 },
},
{
slug: paidOfferSlug,
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency, amount: 100 },
maxPurchases: 2,
},
],
});
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack');
if (!paidOffer?.id) {
throw new Error('seeded paid offer did not return an id');
}
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
const questName = 'Buy Gold Pack Quest';
const questObjectiveId = 'buy-gold-pack';
const questTarget = 1;
const questMetric = `purchase.offer:${paidOffer.id}`;
const questSlug = 'buy-gold-pack-quest';
const questMetric = `purchase.offer:${paidOfferSlug}`;
const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: questSlug,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
@@ -210,24 +224,26 @@ export async function runSeed(
// 9. Scenario (must be live now: startAt <= now <= endAt).
const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenarioEvent = 'session_start';
const scenarioSlug = 'onboarding';
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: scenarioSlug,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
endAt,
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}`, {
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
projectId: project.id,
scenarioId: scenario.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves prod/latest.json).
// 11. Promote staging content to prod (the runtime serves the prod snapshot).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id,
fromEnvironment: authoringEnv,
@@ -235,14 +251,15 @@ export async function runSeed(
});
await pollRelease(api, projPath, runtimeEnv, release.id, log);
// Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
const prodStore = prodStores.find((s) => s.slug === storeSlug);
if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
}
const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack');
if (!prodPaidOffer?.id) {
throw new Error('promoted paid offer did not return an id');
const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
if (!prodPaidOffer?.slug) {
throw new Error('promoted paid offer did not return a slug');
}
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
@@ -250,26 +267,31 @@ export async function runSeed(
}
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
const prodQuest = prodQuests.find((q) => q.name === questName);
if (!prodQuest?.id) {
throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`);
const prodQuest = prodQuests.find((qq) => qq.slug === questSlug);
if (!prodQuest?.slug) {
throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`);
}
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) {
throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`);
throw new Error(`promoted quest ${questSlug} did not return metric ${questObjectiveId}`);
}
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
if (!prodQuestRewardItemId) {
throw new Error(`promoted quest ${questName} did not return reward item id`);
throw new Error(`promoted quest ${questSlug} did not return reward item id`);
}
// 12. Settle: let the game service hot-reload caches from the new snapshot.
await sleep(3000);
const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
`${projPath}/scenarios${prodQ}`,
);
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
if (!prodScenario?.slug) {
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
}
return {
baseUrl,
projectId: project.id,
projectKey: project.key,
projectKey,
adminToken: auth.accessToken,
adminEmail,
environment: runtimeEnv,
@@ -280,14 +302,14 @@ export async function runSeed(
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id,
paidPrice: { currency: 'soft', amount: 100 },
paidOfferSlug: prodPaidOffer.slug,
paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
id: prodQuest.id,
slug: prodQuest.slug,
name: questName,
objectiveId: questObjectiveId,
metric: prodQuestMetric,
@@ -305,7 +327,7 @@ export async function runSeed(
shopLayout: { cols: 3 },
spawnRateOverride: 3.0,
},
scenario: { id: scenario.id, event: 'session_start' },
scenario: { slug: prodScenario.slug, event: scenarioEvent },
};
}
+3 -3
View File
@@ -16,14 +16,14 @@ export interface SeedArtifact {
slug: string;
freeOfferName: string;
paidOfferName: string;
paidOfferId: string;
paidOfferSlug: string;
paidPrice: { currency: string; amount: number };
paidMaxPurchases: number;
grantedItemName: string;
paidGrantAmount: number;
};
quest: {
id: string;
slug: string;
name: string;
objectiveId: string;
metric: string;
@@ -42,5 +42,5 @@ export interface SeedArtifact {
/** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number;
};
scenario: { id: string; event: string };
scenario: { slug: string; event: string };
}
+5 -6
View File
@@ -15,12 +15,11 @@ describe('e2e-prod: health', () => {
expect(body.status).toBe('ok');
});
it('content + game gRPC backends are serving', async () => {
// /readyz may be 503 if analytics is not_serving (out of scope here); assert the
// backends this suite actually uses are serving.
it('GET /readyz reports every backing store ready', async () => {
const res = await fetch(`${baseUrl}/readyz`);
const body = (await res.json()) as { grpc?: Record<string, string> };
expect(body.grpc?.content).toBe('serving');
expect(body.grpc?.game).toBe('serving');
expect(res.ok).toBe(true);
const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
expect(body.status).toBe('ready');
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
});
});
+4 -8
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { adminApi, freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.js';
@@ -48,12 +48,8 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
const price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client);
// 1. Grant currency via the new admin endpoint.
await api.post(`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, {
currencyCode: currency,
amount: 1000,
reason: 'e2e grant',
});
// 1. Grant currency via the admin endpoint.
await grantCurrency(artifact, playerId, currency, 1000);
const granted = await withReadRetry(
() => balance(client, currency),
(b) => b === 1000,
@@ -68,7 +64,7 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
// 3. Inventory credited (verified via admin player details).
const details = await api.get<AdminPlayerDetails>(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}`,
`/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`,
);
const inv = details.inventory?.find((i) => i.slug === artifact.item.id);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
+9 -12
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { adminApi, loadArtifact, makeProdClient } from './_setup/harness.js';
import { grantCurrency, loadArtifact, makeProdClient } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.js';
@@ -16,7 +16,6 @@ describe('e2e-prod: scenarios', () => {
let runCompleted = false;
const client = makeProdClient(artifact, {
planStateStore: null,
loginEvent: artifact.scenario.event,
});
@@ -25,28 +24,26 @@ 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.
const playerId = (await client.player.reload()).player!.id!;
await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`,
{
currencyCode: artifact.store.paidPrice.currency,
amount: artifact.store.paidPrice.amount,
reason: 'e2e scenario funding',
},
await grantCurrency(
artifact,
playerId,
artifact.store.paidPrice.currency,
artifact.store.paidPrice.amount,
);
// First notification dispatched.
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done();
// 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,
);
+51 -84
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;
@@ -51,7 +24,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}],
});
gateway.install();
@@ -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!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reward',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onPurchase', reward);
const messages: string[] = [];
const completed: 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.scenarioSlug); });
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({
scenarioSlug: '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!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'consolation',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onDecline', consolation);
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((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' }, {
scenarioSlug: '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);
});
});
+57 -41
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', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const waitReminder = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_reminder',
waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
});
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
return { waitIntro, offer, waitReminder, reminder, thanks };
}
describe('E2E: player offer journey', () => {
@@ -48,7 +58,7 @@ describe('E2E: player offer journey', () => {
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}],
});
gateway.install();
@@ -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 },
{ storeSlug: 'starter', offerSlug: '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', slug: 'pack-1', name: 'Pack' }] }],
});
const now = Date.now();
const wait = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('session_start', wait);
gateway.onCallback('wait_intro', 'onComplete', offer);
gateway.install();
});
@@ -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 },
});
}
+83 -60
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 }>;
purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
questClaims: Array<{ questSlug: 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 });
}
@@ -150,7 +175,7 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
const b = body as { idempotencyKey?: string };
purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]),
offerId: decodeURIComponent(purchaseMatch[2]),
offerSlug: decodeURIComponent(purchaseMatch[2]),
idempotencyKey: b.idempotencyKey ?? '',
authToken: req.authToken,
});
@@ -163,18 +188,16 @@ 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);
}
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questId?: string };
const questId = b.questId ?? '';
questClaims.push({ questId, authToken: req.authToken });
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
const b = body as { questSlug?: string };
const questSlug = b.questSlug ?? '';
questClaims.push({ questSlug, authToken: req.authToken });
return json(state.questClaims.get(questSlug) ?? { success: false, error: 'not found' });
}
// --- 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);
+14 -70
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 {
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,
};
}
}
export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder {
return new PlanBuilder(scenarioId, opts);
export function effect(
type: string,
data: Record<string, unknown> = {},
overrides: Partial<PendingEffect> = {},
): PendingEffect {
return {
runId: 'run-1',
scenarioSlug: 'scenario-1',
nodeId: `${type}-1`,
type,
data,
...overrides,
};
}
+1 -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',