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