15 Commits

Author SHA1 Message Date
edmand46 7271874cac 2.0.0: changelog and skill docs for slugs and environments
CI / check (push) Successful in 16s
CI / publish (push) Has been skipped
Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
2026-09-06 22:39:03 +03:00
edmand46 a7401b534a Slug-based scenarios, quests and offers per the environments contract; regenerated models; e2e seed passes environment to admin mirrors
Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
2026-09-06 21:44:54 +03:00
edmand46 e863cc3bf7 Align e2e-prod suite with the monolith API; drop dead applyOverride; treat run_not_active as terminal
CI / check (push) Successful in 18s
CI / publish (push) Has been skipped
2026-09-06 09:53:45 +03:00
edmand46 8863ef80e2 Release 1.0.0
CI / publish (push) Successful in 9s
CI / check (push) Successful in 14s
2026-09-04 15:36:37 +03:00
edmand46 1491b5e357 Server-side scenario execution: thin effects client replaces local engine
- engine/ (DagWalker, sessions, IndexedDbPlanStore) deleted; server owns the graph
- trigger/callback/counter carry PendingEffect; GET /sdk/v1/scenarios/pending polled
  (30s jittered heartbeat paused on hidden tab + wait-deadline timers)
- client.effects public API unchanged (done/buy/dismiss/end/claim/battlepass/quest)
- planStateStore removed; loginEvent kept; reconcile() makes server the source of truth
- generated models regenerated; skills/README/CHANGELOG updated
2026-09-04 14:08:48 +03:00
edmand46 ecbbf93951 Regenerate player models with avatarUrl
CI / check (push) Successful in 1m4s
CI / publish (push) Has been skipped
2026-09-04 12:40:54 +03:00
edmand46 8753239fbd Add agent skill (SKILL.md + per-domain reference)
CI / check (push) Successful in 55s
CI / publish (push) Has been skipped
2026-08-29 11:46:20 +03:00
edmand46 04e3565412 0.6.0: LeaderboardSession.claim, rank_not_eligible is recoverable
CI / publish (push) Successful in 27s
CI / check (push) Successful in 59s
onClaim replaces onRewardClaimed. rewardClaimed() is a deprecated alias removed in the next version.
2026-08-29 11:33:01 +03:00
edmand46 19a296ab9a ci: publish with NPM_TOKEN PAT (job GITEA_TOKEN cannot write org packages)
CI / publish (push) Successful in 29s
CI / check (push) Successful in 58s
2026-08-28 18:42:59 +03:00
edmand46 a72989402e 0.5.1: custom quest metrics no-op, doc fix
CI / publish (push) Failing after 31s
CI / check (push) Successful in 1m5s
2026-08-28 17:38:02 +03:00
edmand46 3556c0b035 ci: grant packages:write to publish job (GITEA_TOKEN 401 on npm publish) 2026-08-28 17:38:02 +03:00
edmand46 fff971ad15 0.5.0: typecheck against regenerated models
CI / publish (push) Failing after 26s
CI / check (push) Successful in 1m4s
listQuests no longer takes a body, battle-pass track is a union, scenario
run status has no unknown_run. This ships loginWithCustom and quest
metric helpers that never reached the registry after the v0.4.0 publish
job failed.
2026-08-28 15:18:31 +03:00
edmand46 e6128e54bd sdk: loginWithCustom facade + regenerated models
CI / check (push) Failing after 22s
CI / publish (push) Has been skipped
2026-08-28 13:36:09 +03:00
edmand46 68287d212e quests: QuestMetrics helpers (purchaseOffer/purchaseItem), README quests section
CI / check (push) Successful in 59s
CI / publish (push) Has been skipped
2026-08-25 16:25:32 +03:00
edmand46 1013f2395f e2e-prod: platform wallet adjust route, fund scenario player, RCs seeded directly in prod env
CI / check (push) Successful in 58s
CI / publish (push) Has been skipped
2026-08-20 11:35:21 +03:00
60 changed files with 2271 additions and 2175 deletions
+4 -1
View File
@@ -35,6 +35,9 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
needs: check needs: check
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -55,4 +58,4 @@ jobs:
- name: Publish - name: Publish
run: npm publish run: npm publish
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITEA_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+74
View File
@@ -1,5 +1,79 @@
# Changelog # Changelog
## 2.0.0
Breaking change — major bump, required by the backend environments release.
Every project now has exactly two environments, `staging` and `prod`, and the
SDK key passed as `projectKey` decides which one the player belongs to. The
environment never appears in the client API: it is resolved at login and
carried inside the access and refresh tokens. Tokens issued before this
release have no environment claim and are rejected with 401, so the first call
after the backend upgrade refreshes, fails, clears the token store and emits
`'signed-out'` to every `onAuthStateChange` listener. Log the player in again
with `client.auth.loginWithDevice()` / `loginWithCustom()`.
Quests, scenarios and offers are addressed by their slug instead of their id,
because ids differ between staging and prod while slugs are stable. Renamed
accordingly: `Quest.id` is now `Quest.slug`, `client.quests.claim()` takes a
quest slug, `reportProgress()` resolves to the slugs of the completed quests
(`ReportQuestProgressResponse.completedQuestSlugs`),
`client.stores.purchase(storeSlug, offerSlug, options?)` takes an offer slug
and `OfferHandle` carries a `slug` alongside its `id`,
`QuestMetrics.purchaseOffer(offerSlug)` builds its metric from the offer slug,
and every scenario-scoped type exposes `scenarioSlug` instead of `scenarioId`
`PendingEffect`, `ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle
pass requests and `client.battlePass.getProgress(scenarioSlug, nodeId)`.
`offer.buy()` is unchanged; it now binds the slug for you. Leaderboards, items
and stores already used slugs and are untouched.
Also shipped here, previously committed but never published: the effects client
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run
that disappears server-side (finished elsewhere, expired after a promote) now
emits `onScenarioCompleted` instead of lingering; and `run_not_active` joins
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
emits `onScenarioFailed`.
## 1.0.0
- Scenario execution moved server-side. The SDK no longer walks a local DAG
or persists plan state to IndexedDB.
- Removed `RudderClientOptions.runtime.planStateStore` and the
`PlanStateStore` type.
- `GET /sdk/v1/scenarios/pending` is polled on login, on a ~30s heartbeat,
and at each effect `waitDeadline`. Completions POST
`/sdk/v1/scenarios/callback`; the response may carry the next
`PendingEffect`.
- Scenario `remote_config_override` nodes no longer emit `onConfigChanged`
(the server applies the override). The `ConfigChangedEffect` type and
`onConfigChanged` subscription remain.
## 0.6.0
- `LeaderboardSession.claim()` / effect `claim()` completes the leaderboard
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? })`
— custom webhook auth, same token + runtime start as `loginWithDevice`.
- `QuestMetrics.purchaseOffer` / `purchaseItem` helpers for the shop purchase
metric format.
- Typecheck against regenerated models: `listQuests` no longer takes a body,
battle-pass `track` is `'free' | 'premium'`, scenario run status has no
`unknown_run`.
## 0.4.0 ## 0.4.0
- Fixed `BattlePassLevelSession.level` reading node data key `level` — the - Fixed `BattlePassLevelSession.level` reading node data key `level` — the
+38 -4
View File
@@ -52,7 +52,7 @@ client.dispose();
| Area | Access | | Area | Access |
|---|---| |---|---|
| Auth | `client.auth.loginWithDevice()`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` | | Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
| Player / wallets | `client.player` (observable) | | Player / wallets | `client.player` (observable) |
| Inventory | `client.inventory` (observable, catalog-merged) | | Inventory | `client.inventory` (observable, catalog-merged) |
| Catalog | `client.catalog` (observable) | | Catalog | `client.catalog` (observable) |
@@ -61,7 +61,7 @@ client.dispose();
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` | | Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
| Leaderboards | `client.leaderboards.findBySlug(slug)``handle.submit(score)` / `handle.list(limit?)` | | Leaderboards | `client.leaderboards.findBySlug(slug)``handle.submit(score)` / `handle.list(limit?)` |
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) | | Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) |
| Quests | `client.quests.list()` / `client.quests.claim(id)` | | Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` |
| Scenario effects | `client.effects.on*` | | Scenario effects | `client.effects.on*` |
Type the remote config for `client.remoteConfig`: Type the remote config for `client.remoteConfig`:
@@ -75,6 +75,37 @@ const client = new RudderClient<GameConfig>({ /* … */ });
client.remoteConfig.get('player_speed', 200); // number client.remoteConfig.get('player_speed', 200); // number
``` ```
## Quests
`client.quests` covers the player's global quests — list with per-objective
progress, claim, and metric reports. These are distinct from scenario quest
nodes, which advance through the `onQuest` effect's `QuestSession`. Global
quests have no live sync: re-list after a claim or report.
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.id) {
await client.quests.claim(quest.id);
}
}
// Custom metrics advance matching objectives server-side; the call returns
// the ids of quests completed by this report.
const completedIds = await client.quests.reportProgress('kills', 1);
```
Purchase metrics are reported automatically when a purchase goes through
`client.stores`; the `QuestMetrics` helpers name the format so quest configs
and client code agree on it:
```ts
import { QuestMetrics } from '@rudder/js-sdk';
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
## Scenario effects ## Scenario effects
The scenario runtime is not exposed directly; scenario nodes surface through The scenario runtime is not exposed directly; scenario nodes surface through
@@ -84,8 +115,11 @@ The scenario runtime is not exposed directly; scenario nodes surface through
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel` - `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
- `onScenarioCompleted`, `onScenarioFailed` - `onScenarioCompleted`, `onScenarioFailed`
The scenario engine (and its IndexedDB persistence) loads lazily on first The server executes the scenario graph. The SDK is a thin effects client
login — a client that only reads domains never pulls it into the page. (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` Errors thrown inside effect handlers are reported via the `onEffectError`
client option (default: `console.error`). client option (default: `console.error`).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "0.1.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "0.1.0", "version": "2.0.0",
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "0.4.0", "version": "2.0.0",
"publishConfig": { "publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/" "registry": "https://hub.rudder.build/api/packages/rudder/npm/"
}, },
+136
View File
@@ -0,0 +1,136 @@
---
name: rudder-web-sdk
description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/custom auth, observable domains (player, inventory, catalog, stores, remote config, storage), leaderboards, quests, battle pass, scenario effects, and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears.
---
# Rudder Web SDK (@rudder/js-sdk)
Browser-facing player SDK for the Rudder LiveOps platform. Source of truth:
`liveops-web-sdk/src` (generated wire types in `src/generated` come from the
gateway's apigen — never edit them by hand).
## Install
Published to a private registry, not npmjs. Point the `@rudder` scope at it in
`.npmrc` (anonymous read, no token):
```
@rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/
```
```bash
npm install @rudder/js-sdk
```
ESM + CJS + `.d.ts`; `type: module`; zero runtime dependencies.
## Init
```ts
import { RudderClient } from '@rudder/js-sdk';
const client = new RudderClient({
baseUrl: 'https://api.rudder.build', // required
projectKey: 'your-project-key', // required
// tokenStore?: TokenStore — default: localStorage, in-memory fallback
// requestTimeoutMs?: number — default 10000
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
// onEffectError?: (error) => void — default console.error
// runtime?: { loginEvent? } — advanced, see reference/scenarios.md
});
```
`client` is generic: `RudderClient<TConfig extends Record<string, unknown>>`
types `client.remoteConfig.get()`. Missing `baseUrl`/`projectKey` throws
`RudderError` with `code: 'sdk/invalid-options'` from the constructor.
Call `client.dispose()` on unmount/HMR to stop the sync poll, wait timers, and
drop cached state.
## Auth essentials
```ts
await client.auth.loginWithDevice({ region, language, nickname }); // device id auto-generated/persisted
await client.auth.loginWithCustom({ customData, region, language, nickname });
client.auth.logout();
client.auth.isAuthenticated; // boolean
client.auth.onAuthStateChange(cb); // fires immediately, returns unsubscribe
```
Tokens are saved to the `TokenStore` automatically. The transport injects
`Authorization: Bearer <token>`, retries GETs, and single-flight refreshes on
401; a failed refresh clears tokens and emits `signed-out`. Details:
[reference/auth.md](reference/auth.md).
## Client surface (all domains that exist)
Every member below is a property on `RudderClient`. There is no UGC, wallet
service, or standalone economy service in this SDK — wallets live on
`client.player.data.wallets`, and purchases go through `client.stores`.
| Area | Access | Kind |
|---|---|---|
| Auth | `client.auth` | service |
| Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` |
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` |
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerSlug, opts?)` | observable `SyncedState<ShopHandle[]>` |
| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation |
| Leaderboards | `client.leaderboards.findBySlug(slug)` | service → cached handle |
| Battle pass | `client.battlePass` | plain service (no live sync) |
| Quests | `client.quests` | plain service (no live sync) |
| Scenario effects | `client.effects.on*` | event subscriptions |
## Observable domains — the shared pattern
`player`, `catalog`, `inventory`, `remoteConfig`, `storage`,
`projectStorage`, and `stores` all extend `SyncedState<T>`:
- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`,
`.error?: Error`
- `onChange(cb: (snapshot: SyncedSnapshot<T>) => void): () => void` — fires
immediately with the current snapshot; maps onto React `useSyncExternalStore`
- `load(): Promise<T>` — deduplicated, no-op when ready
- `reload(): Promise<T>` — forced refetch
`remoteConfig`, `player`, `stores`, and `catalog` are warmed once at login;
`inventory`, `storage`, and `projectStorage` load on first use. All observable
domains are then kept fresh by a revision poll every 30 s (±20% jitter);
polling pauses while the tab is hidden. Mutations (purchases, storage writes,
scenario callbacks) invalidate affected domains immediately. Call `reload()`
when you need a freshness guarantee.
`leaderboards`, `battlePass`, and `quests` are NOT observable — refetch
explicitly after mutations.
## Errors
All SDK errors extend `RudderError` (optional machine-readable `code`):
- `RudderNetworkError` — fetch failed / timeout (`error.cause` holds the original)
- `RudderHttpError` — non-2xx, carries `status`, `statusText`, `body`, `code`
- `RudderAuthError extends RudderHttpError` — 401 after failed refresh; tokens
already cleared, `signed-out` already emitted
- `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`)
— constructor validation
Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`):
`early_completion`, `forbidden`, `level_not_reached`, `node_not_active`,
`objectives_incomplete`, `run_expired`, `run_not_active`,
`scenario_not_active`, `unknown_run`. Compare with
`error.code === RudderErrorCodes.runExpired`.
## Reference files
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
- [reference/inventory.md](reference/inventory.md) — inventory domain, merged `InventoryItem`, catalog
- [reference/stores.md](reference/stores.md) — `ShopHandle`/`OfferHandle`, purchases, idempotency
- [reference/remote-config.md](reference/remote-config.md) — typed `get()`, value types, overrides
- [reference/storage.md](reference/storage.md) — player + project storage, limits
- [reference/leaderboards.md](reference/leaderboards.md) — handles, submit, list
- [reference/quests.md](reference/quests.md) — list/claim/reportProgress, `QuestMetrics`
- [reference/battlepass.md](reference/battlepass.md) — progress, XP, claims, premium
- [reference/scenarios.md](reference/scenarios.md) — effects, scenario runtime, wait/quest/battlepass nodes
+119
View File
@@ -0,0 +1,119 @@
# Auth — `client.auth`
`AuthService` (source: `src/auth/AuthService.ts`). Player authentication:
device ID login (primary for game clients), custom webhook login, logout, and
auth state observation.
## Methods
```ts
loginWithDevice(options?: LoginWithDeviceOptions): Promise<LoginViaDeviceResponse>
loginWithCustom(options: LoginWithCustomOptions): Promise<LoginViaCustomResponse>
logout(): void
get isAuthenticated(): boolean
onAuthStateChange(listener: AuthStateListener): () => void
```
### `LoginWithDeviceOptions`
```ts
{
region?: string; // default 'global'
language?: string; // default 'en'
nickname?: string; // omitted from the request when not set
}
```
The device ID is auto-generated on first call and persisted in localStorage
(`src/device/DeviceId.ts`). The request also carries the client's
`projectKey`.
### `LoginWithCustomOptions`
```ts
{
customData: Record<string, unknown>; // required — forwarded to the project's custom auth webhook
region?: string; // default 'global'
language?: string; // default 'en'
nickname?: string;
}
```
### Login responses
```ts
interface LoginViaDeviceResponse { accessToken?: string; refreshToken?: string }
interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string }
```
On success both tokens are saved to the client's `TokenStore`, the runtime
starts (domains warmed, scenario engine restored, `player_login` event fired),
and the state flips to `'signed-in'`.
## Environments
A project has two environments, `staging` and `prod`. The SDK key you pass as
`RudderClientOptions.projectKey` belongs to one of them, so the environment is
resolved at login and carried inside the access and refresh tokens; nothing in
the client API takes an environment argument, and a player created in one
environment is invisible in the other. Content released only to `staging` is
empty for a `prod` key and vice versa.
Tokens issued before SDK 2.0.0 carry no environment claim and are rejected with
401. The transport's refresh then fails, clears the token store and emits
`'signed-out'` — log the player in again.
## Auth state
```ts
type AuthState = 'signed-in' | 'signed-out';
type AuthStateListener = (state: AuthState) => void;
```
- `isAuthenticated` is `true` while an access token is present in the token store.
- `onAuthStateChange` fires the listener **immediately** with the current state
and returns an unsubscribe function.
- `logout()` clears tokens, stops the runtime (sync poll, scenario runs,
cached domain data), and emits `'signed-out'`.
## Token refresh (automatic, transport level)
Source: `src/transport/request.ts`.
- Every request injects `Authorization: Bearer <accessToken>` when a token exists.
- On 401 the transport does a single-flight refresh against
`POST /sdk/v1/authorization/refresh` (concurrent 401s share one refresh) and
retries the original request once.
- If refresh fails, tokens are cleared, `onAuthStateChange` listeners get
`'signed-out'`, and the request throws `RudderAuthError`.
## TokenStore
```ts
interface TokenStore {
getAccessToken(): string | null;
getRefreshToken(): string | null;
saveTokens(accessToken: string, refreshToken: string): void;
clear(): void;
}
```
Factories (exported from the package root):
- `createDefaultTokenStore()` — localStorage, with a silent in-memory fallback
where localStorage is unavailable (SSR, private mode). This is the default
when `tokenStore` is omitted from `RudderClientOptions`.
- `createLocalStorageTokenStore()` — keys `rudder_access_token` /
`rudder_refresh_token`.
Provide a custom `TokenStore` via `RudderClientOptions.tokenStore` for other
backends (sessionStorage, cookies).
## Errors
- Constructor: missing `baseUrl`/`projectKey``RudderError`,
`code: 'sdk/invalid-options'`.
- Login failure → `RudderHttpError` (e.g. unknown project key) or
`RudderNetworkError`.
- Any later request with an expired session → `RudderAuthError` (after the
refresh attempt above fails).
@@ -0,0 +1,84 @@
# Battle pass — `client.battlePass`
`BattlePassService` (source: `src/battlepass/BattlePassService.ts`).
Call-and-response access to battle pass endpoints. Battle pass state is tied to
a **scenario battle pass node**, so calls carry `scenarioSlug` + `nodeId` (plus
`runId` for mutating calls). NOT observable — re-fetch progress explicitly
after a mutation.
In most games you do not call this service directly: a scenario battle pass
node surfaces through `client.effects.onBattlePass` with a session object that
wraps these calls (see `reference/scenarios.md`). Use the service directly when
you already know the scenario/node/run identifiers.
## Methods
```ts
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse>
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
```
## Request / response types
```ts
interface AddBattlePassXpRequest {
amount?: number;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
source?: string; // configured XP source
}
interface AddBattlePassXpResponse {
level?: number;
leveledUp?: boolean;
maxLevel?: boolean;
xp?: number;
}
interface ClaimBattlePassRewardRequest {
level?: number;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
track?: 'free' | 'premium';
}
interface ClaimBattlePassRewardResponse {
alreadyClaimed?: boolean;
error?: string;
granted?: Reward[]; // { amount?, currency?, itemId? }
success?: boolean;
}
interface GetBattlePassProgressResponse {
claimedTiers?: ClaimedTier[]; // { level?, track? }
level?: number;
premiumOwned?: boolean;
xp?: number;
}
interface PurchaseBattlePassPremiumRequest {
idempotencyKey?: string;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
}
interface PurchaseBattlePassPremiumResponse {
error?: string;
success?: boolean;
}
```
## Semantics
- `addXp` credits XP from a configured source; returns the new `xp`/`level`
plus `leveledUp` / `maxLevel` flags.
- `claimReward` claims a tier reward at a reached level; idempotent
server-side (`alreadyClaimed`). Claiming a tier above the current level (or
a premium tier without premium) fails server-side — check `success`/`error`
in the response and the typed error `code` (`RudderErrorCodes`).
- `purchasePremium` charges the player's wallet; idempotent; pass your own
`idempotencyKey` for safe retries.
- Prefer the `onBattlePass` effect session, which binds `scenarioSlug` /
`nodeId` / `runId` and posts scenario callbacks for you.
@@ -0,0 +1,56 @@
# Inventory + catalog — `client.inventory`, `client.catalog`
## Inventory
`InventoryDomain` (source: `src/domains/InventoryDomain.ts`) — owned items
merged with their catalog entries. Synced under revision key `inventory`;
also refreshed whenever the catalog changes.
```ts
client.inventory.data // InventoryItem[] | undefined
client.inventory.onChange(cb);
await client.inventory.load() / .reload();
```
### `InventoryItem` (merged view, source: `src/state/inventory.ts`)
```ts
interface InventoryItem {
slug: string; // '' when the wire item has no slug
amount: number; // 0 when absent on the wire
name: string; // nameOverride || catalog name || slug
properties: Record<string, unknown>; // catalog properties + propertiesOverride (override wins)
tags: string[]; // from the catalog entry
}
```
The raw wire shape is `PlayerInventoryItem`
(`slug?, amount?, nameOverride?, propertiesOverride?, updatedAt?`) — you rarely
need it; the domain hands out the merged `InventoryItem`.
## Catalog
`CatalogDomain` (source: `src/domains/CatalogDomain.ts`) — the item catalog
keyed by slug. Synced under revision key `catalog`.
```ts
client.catalog.data // Map<string, CatalogItem> | undefined
interface CatalogItem {
name?: string;
properties?: { [key: string]: unknown };
slug?: string;
tags?: string[];
}
```
Items without a `slug` are skipped when the map is built.
## Behavior notes
- The catalog is warmed at login; inventory loads on first use, waits on the
catalog load, and merges, so `client.inventory.data` always has catalog
fields filled in.
- Invalidated after store purchases and scenario callbacks.
- Inventory has no client-side mutations — items change via purchases, quest /
battle pass rewards, and scenario nodes (all server-side).
@@ -0,0 +1,50 @@
# Leaderboards — `client.leaderboards`
`LeaderboardsService` (source: `src/leaderboards/LeaderboardsService.ts`).
Plain call-and-response service, NOT observable — refetch explicitly.
## Surface
```ts
const board = client.leaderboards.findBySlug('weekly-kills'); // cached handle
await board.submit(score);
const entries = await board.list(limit?);
board.getEntries();
```
## `LeaderboardHandle`
```ts
class LeaderboardHandle {
readonly slug: string;
getEntries(): readonly RankEntry[]; // last fetched list, [] initially
submit(score: number): Promise<void>;
list(limit = 100): Promise<readonly RankEntry[]>; // fetches and caches
}
```
- `findBySlug(slug)` caches handles per slug — repeated calls return the same
instance (and its cached entries).
- `list(limit)`: `limit <= 0` sends no limit to the server; default is 100.
- `submit` does not update the cached entries; call `list()` afterwards to see
the effect.
## Types
```ts
interface RankEntry {
playerId?: string;
playerName?: string;
rank?: number;
score?: number;
}
```
## Notes
- No player-around-me or metadata endpoints are exposed by this SDK — submit
and top-N list only.
- Scenario `leaderboard` nodes surface through
`client.effects.onLeaderboard` (`end()`, `rewardClaimed()`) — see
`reference/scenarios.md`.
+53
View File
@@ -0,0 +1,53 @@
# Player profile + wallets — `client.player`
`PlayerDomain` (source: `src/domains/PlayerDomain.ts`) — the observable player
profile: identity plus currency wallets. Synced under revision key `profile`.
There is no separate wallet service in this SDK. Wallet balances are read from
the profile and are credited/debited server-side (purchases, quest/battle pass
rewards, scenario nodes).
## Surface
`client.player` is a `SyncedState<PlayerProfile>`:
```ts
client.player.data // PlayerProfile | undefined
client.player.status // 'idle' | 'loading' | 'ready' | 'error'
client.player.onChange((snapshot) => { /* fires immediately */ });
await client.player.load();
await client.player.reload(); // force refetch
```
## Types
```ts
interface PlayerProfile {
player?: Player;
wallets?: Wallet[];
}
interface Player {
createdAt?: string;
id?: string;
language?: string;
nickname?: string;
projectId?: string;
region?: string;
}
interface Wallet {
balance?: number;
currency?: string; // currency code configured in the dashboard
}
```
## Behavior notes
- Warmed automatically at login (one of the four parallel warm loads).
- Invalidated (refetched) after every successful store purchase and after
scenario server callbacks — subscribers see fresh balances without waiting
for the revision poll.
- All fields are optional on the wire (`?`); code defensively.
- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK
README "React recipe").
+75
View File
@@ -0,0 +1,75 @@
# Quests — `client.quests`
`QuestsService` (source: `src/quests/QuestsService.ts`). The player's **global**
quests — list, claim, report metric progress. Plain call-and-response service,
NOT observable (no sync revision key): re-list after a claim or report.
Distinct from scenario quest nodes, which advance through
`client.effects.onQuest` (see `reference/scenarios.md`).
## Methods
```ts
list(): Promise<Quest[]>
claim(questSlug: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // slugs of quests completed by this report
```
## Types
```ts
interface Quest {
slug?: string; // stable across environments — what claim() takes
name?: string;
objectives?: QuestObjectiveProgress[];
rewards?: Reward[];
status?: 'active' | 'claimed' | 'completed';
}
interface QuestObjectiveProgress {
completed?: boolean;
current?: number;
metric?: string;
objectiveId?: string;
target?: number;
}
interface ClaimQuestResponse {
alreadyClaimed?: boolean;
error?: string;
granted?: Reward[]; // { amount?, currency?, itemId? }
success?: boolean;
}
```
## Usage notes
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.slug) {
const res = await client.quests.claim(quest.slug);
// res.success / res.alreadyClaimed / res.granted
}
}
const completedSlugs = await client.quests.reportProgress('kills', 1);
```
- `claim` is idempotent server-side; check `success` / `alreadyClaimed` /
`error` in the response rather than relying on exceptions.
- Objective completion is judged server-side from metric reports.
## `QuestMetrics` helpers
Exported as a namespace: `import { QuestMetrics } from '@rudder/js-sdk'`
(source: `src/quests/QuestMetrics.ts`).
```ts
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
Purchase metrics are reported automatically server-side by the store purchase
fan-out — these helpers exist so quest configs and client code name the format
consistently. Catalog counter slugs are reported via `reportProgress`. Custom
free-text metrics no longer progress quests — they no-op at runtime.
@@ -0,0 +1,63 @@
# Remote config — `client.remoteConfig`
`ConfigDomain<TConfig>` (source: `src/domains/ConfigDomain.ts`, base class
`src/state/RemoteConfigState.ts`) — typed remote configuration as an
observable entity. Synced under revision key `config`.
## Typed access
```ts
interface GameConfig extends Record<string, unknown> {
player_speed: number;
feature_x: boolean;
}
const client = new RudderClient<GameConfig>({ baseUrl, projectKey });
client.remoteConfig.get('player_speed', 200); // number
client.remoteConfig.get('feature_x'); // boolean | undefined
```
`get()` overloads:
```ts
get(key): TConfig[key] | undefined;
get(key, defaultValue: TConfig[key]): TConfig[key];
```
- Values are parsed synchronously from the loaded snapshot according to the
config's server-declared `valueType`.
- Returns the default value (or `undefined`) while not loaded, for unknown
keys, and when parsing fails.
- Parsing rules: `int`/`integer``parseInt`; `float`/`double`/`number`
`parseFloat`; `bool`/`boolean``value === 'true'`; `json`/`object`
`JSON.parse`; anything else → raw string.
## Observable
```ts
client.remoteConfig.data // Map<string, RemoteConfig> | undefined
client.remoteConfig.onChange(cb); // fires immediately
await client.remoteConfig.reload();
```
```ts
interface RemoteConfig {
active?: boolean; createdAt?: string; description?: string;
environment?: string; id?: string; key?: string; projectId?: string;
updatedAt?: string; value?: string;
valueType?: 'bool' | 'float' | 'int' | 'json' | 'string';
}
```
Only configs that are not explicitly `active: false` enter the map (the server
already filters inactive configs out and may omit the flag).
## Behavior notes
- Warmed at login.
- Scenario `remote_config_override` nodes are applied server-side and do not
reach the client. Reload or wait for the config sync poll to observe the
patched value via `get()`.
- Without a `TConfig` type argument, `RemoteConfigShape` defaults to
`Record<string, unknown>` and `get()` returns `unknown`.
@@ -0,0 +1,166 @@
# Scenarios + effects — `client.effects`
Scenarios are server-authored node graphs (configured in the dashboard) that
run per player. The server executes the graph. The SDK is a thin effects
client: it sends trigger events, turns `PendingEffect` payloads into typed
`client.effects` handlers, posts callbacks when the game completes an
effect, and polls for pending effects. The runtime is deliberately **not**
part of the public client surface (source: `src/effects/EffectsCenter.ts`)
and loads lazily on first login.
## Lifecycle
- After login the SDK fetches `GET /sdk/v1/scenarios/pending` (replacing any
local restore), then fires the login event (`'player_login'` by default).
- A heartbeat polls pending every ~30s (±20% jitter), paused while
`document.hidden`. Each effect with a `waitDeadline` also schedules a
local timer so waits fire on time.
- An effect with an already-active `(runId, nodeId)` is not re-emitted.
- `logout()` / `dispose()` stop the poll, cancel wait timers, and drop
active effects. There is no local plan persistence.
### Runtime options (`RudderClientOptions.runtime`)
```ts
runtime?: {
// Scenario event fired automatically after login.
// undefined = 'player_login'; null = fire nothing.
loginEvent?: string | null;
}
```
## Subscribing
Every `on*` method takes a handler `(effect) => void | Promise<void>` and
returns an unsubscribe function. Errors thrown inside handlers go to the
`onEffectError` client option (default `console.error`).
```ts
const off = client.effects.onNotification(async (n) => {
showToast(n.title, n.message);
await n.done(); // always resolve the session or the run stalls
});
```
## Effect types
```ts
interface Effects {
onNotification(handler): EffectUnsubscribe;
onStoreOffer(handler): EffectUnsubscribe;
onLeaderboard(handler): EffectUnsubscribe;
onConfigChanged(handler): EffectUnsubscribe;
onWait(handler): EffectUnsubscribe;
onScenarioCompleted(handler): EffectUnsubscribe;
onScenarioFailed(handler): EffectUnsubscribe;
onQuest(handler): EffectUnsubscribe;
onBattlePass(handler): EffectUnsubscribe;
onBattlePassLevel(handler): EffectUnsubscribe;
}
```
`onConfigChanged` remains on the public surface but scenario
`remote_config_override` nodes no longer reach the client — the server
applies them. Patched values show up through `client.remoteConfig` after
sync/reload.
### `NotificationEffect` — a notification node became active
```ts
{ readonly title: string; readonly message: string; done(): Promise<void> }
```
### `StoreOfferEffect` — a store node became active
```ts
{
readonly store: ShopHandle;
readonly offers: readonly OfferHandle[];
readonly message?: string;
buy(offer: OfferHandle, options?: BuyOptions): Promise<PurchaseOfferResponse>;
dismiss(): Promise<void>; // declines the offer and advances the run
}
```
### `LeaderboardEffect` — a leaderboard node became active
```ts
{ end(): Promise<void>; claim(): Promise<void>; rewardClaimed(): Promise<void> }
```
`rewardClaimed()` is a deprecated alias for `claim()`.
### `WaitEffect` — a wait node became active
```ts
{ readonly deadlineUtc: Date } // SDK polls pending at the deadline; the server advances the run
```
### `QuestEffect` — a scenario quest node became active
```ts
{
readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>;
}
```
The node auto-completes server-side once every objective is satisfied; the
counter response may carry the next `PendingEffect`.
### `BattlePassEffect` — a battle pass node became active
```ts
{
getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
levelUp(): Promise<void>;
end(): Promise<void>;
}
```
These wrap `client.battlePass` with the run's `scenarioSlug`/`nodeId`/`runId`
already bound — prefer them over calling the service manually.
### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier)
```ts
{ readonly level: number; claim(): Promise<void> }
```
### Run lifecycle effects
```ts
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioSlug: string }
interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioSlug: string; readonly nodeId: string;
readonly error: Error;
}
```
Always subscribe to `onScenarioFailed` — otherwise run failures surface only
as console warnings.
## Supported node types
`wait`, `notification`, `store`, `leaderboard`, `quest`, `battlepass`,
`battlepass_level`. Any other node type fails the run (surfaced via
`onScenarioFailed`). `remote_config_override` is applied server-side and is
not delivered to the client.
## Reliability notes
- Completion methods POST `/sdk/v1/scenarios/callback` with
`{scenarioSlug, runId, nodeId, handle}` using the handles `output`,
`onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`,
`onPremiumPurchase`.
- Transient callback failures (network error, 5xx) leave the effect active
for retry; `unknown_run` / `run_expired` drop that run's effects and fire
`onScenarioFailed`.
- Server scenario errors use the typed codes `run_expired`, `run_not_active`,
`node_not_active`, `scenario_not_active`, `unknown_run`,
`early_completion`, `objectives_incomplete`, `level_not_reached`,
`forbidden` (`RudderErrorCodes`).
@@ -0,0 +1,69 @@
# Storage — `client.storage` + `client.projectStorage`
Two key/value stores: per-player (`StorageDomain`, revision key `storage`) and
project-wide shared (`ProjectStorageDomain`, revision key `projectStorage`).
Sources: `src/domains/StorageDomain.ts`, `src/domains/ProjectStorageDomain.ts`.
## Player storage
```ts
client.storage.data // GetStorageResponse | undefined
await client.storage.save(items);
await client.storage.delete(type);
```
```ts
interface GetStorageResponse {
items?: StorageItem[];
nextCursor?: string;
}
interface StorageItem {
data?: string; // opaque payload, JSON-stringify yourself if needed
id?: string;
type?: string; // the storage "collection" key
}
```
- `save(items: StorageItem[]): Promise<void>` — upserts items, then
invalidates the domain so subscribers refetch.
- `delete(type: string): Promise<void>` — deletes **all** player storage items
of that type, then invalidates.
## Project storage
```ts
client.projectStorage.data // GetProjectStorageResponse | undefined
await client.projectStorage.save(items);
```
```ts
interface GetProjectStorageResponse {
items?: ProjectStorageItem[];
nextCursor?: string;
}
interface ProjectStorageItem {
data?: string;
expiresAt?: string;
id?: string;
readPermission?: 'public' | 'serverOnly';
size?: number;
type?: string;
updatedAt?: string;
version?: number;
writePermission?: 'public' | 'serverOnly';
}
interface ProjectStorageUpdateItem { data?: string; type?: string }
```
- `save(items: ProjectStorageUpdateItem[]): Promise<void>` — upserts, then
invalidates. Writing is only possible for items whose `writePermission` is
`public`; `serverOnly` items are read-only for clients.
- The SDK exposes no client-side project-storage delete.
## Limits and notes
- Both domains load with `{ limit: 100 }` — the observable snapshot holds at
most 100 items and `nextCursor` pagination is not surfaced by the domain.
- `data` is a raw string on the wire; serialize/deserialize JSON yourself.
- Both are observable (`SyncedState`) and warmed by the revision poll only when
in use; mutations self-invalidate.
+82
View File
@@ -0,0 +1,82 @@
# Stores / purchases — `client.stores`
`StoresDomain` (source: `src/domains/StoresDomain.ts`, handles in
`src/state/shops.ts`). The observable store list plus the purchase executor.
Synced under revision key `stores`. This is the SDK's economy surface — there
is no separate economy service.
## Surface
```ts
client.stores.data // ShopHandle[] | undefined
client.stores.onChange(cb);
await client.stores.load() / .reload();
// Direct purchase (bypassing handles):
await client.stores.purchase(storeSlug, offerSlug, options?);
```
## Handles
```ts
class ShopHandle {
readonly slug: string;
readonly name?: string;
readonly description?: string;
readonly data?: { [key: string]: unknown };
readonly offers: OfferHandle[];
}
class OfferHandle {
readonly id: string; // authoring id, differs between staging and prod
readonly slug: string; // stable across environments — what purchases use
readonly name?: string;
readonly price?: OfferPrice; // { amount?: number; currency?: string }
readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[]
readonly maxPurchases?: number;
buy(options?: BuyOptions): Promise<PurchaseOfferResponse>;
}
interface BuyOptions { idempotencyKey?: string }
interface PurchaseOfferResponse {
error?: string;
purchaseId?: string;
success?: boolean;
}
```
## Purchase semantics
- `stores.purchase(storeSlug, offerSlug, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the store slug and the offer slug.
- When `options.idempotencyKey` is omitted the SDK generates
`crypto.randomUUID()` per call — safe retries require passing your own key.
- On success the executor invalidates `player`, `inventory`, and `stores`, so
subscribers observe fresh wallet/inventory/store data immediately.
- Check `response.success` / `response.error` — a failed purchase is a resolved
response, not necessarily a thrown error.
- Purchase metrics (`purchase.offer:<offerSlug>`, `purchase.item:<itemId>`) are
reported to quests automatically server-side — do not report them manually
(see `reference/quests.md`).
## Wire types
```ts
interface Store {
createdAt?: string; data?: { [key: string]: unknown }; description?: string;
environment?: string; id?: string; name?: string; offers?: Offer[];
projectId?: string; scenarioId?: string; slug?: string; status?: string;
updatedAt?: string;
}
interface Offer {
contents?: OfferContent[]; createdAt?: string; id?: string;
maxPurchases?: number; name?: string; price?: OfferPrice; slug?: string;
updatedAt?: string;
}
```
`ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store
without `slug` / an offer without `id` or `slug` — in practice the domain only
builds handles from server data that has all of them.
+41 -4
View File
@@ -1,15 +1,15 @@
/** /**
* AuthService — device-based player authentication. * AuthService — player authentication.
* *
* Handles login via device ID (the primary auth flow for game clients), * Handles login via device ID (the primary auth flow for game clients) or a
* logout (token clearing), and auth state observation. * custom backend webhook, logout (token clearing), and auth state observation.
*/ */
import type { RudderContext } from '../core/context.js'; import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js'; import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
import { getOrCreateDeviceId } from '../device/DeviceId.js'; import { getOrCreateDeviceId } from '../device/DeviceId.js';
import { api } from '../generated/api.js'; import { api } from '../generated/api.js';
import type { LoginViaDeviceResponse } from '../generated/auth.js'; import type { LoginViaCustomResponse, LoginViaDeviceResponse } from '../generated/auth.js';
export type AuthState = 'signed-in' | 'signed-out'; export type AuthState = 'signed-in' | 'signed-out';
@@ -24,6 +24,17 @@ export interface LoginWithDeviceOptions {
nickname?: string; nickname?: string;
} }
export interface LoginWithCustomOptions {
/** Arbitrary payload forwarded to the developer's custom auth webhook. */
customData: Record<string, unknown>;
/** Player's region code (default: "global"). */
region?: string;
/** Player's language code (default: "en"). */
language?: string;
/** Optional player nickname; omitted from the request when not set. */
nickname?: string;
}
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> { export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
private readonly listeners = new Set<AuthStateListener>(); private readonly listeners = new Set<AuthStateListener>();
private state: AuthState; private state: AuthState;
@@ -81,6 +92,32 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
return response; return response;
} }
/**
* Authenticates via the project's custom authorization webhook and returns
* access + refresh tokens.
*
* The payload is forwarded to the developer's backend; on success, tokens are
* saved to the client's TokenStore.
*/
async loginWithCustom(options: LoginWithCustomOptions): Promise<LoginViaCustomResponse> {
const { customData, region = 'global', language = 'en', nickname } = options;
const response = await api.loginViaCustom(this.ctx, {
key: this.ctx.options.projectKey,
customData,
region,
language,
nickname,
});
this.ctx.options.tokenStore.saveTokens(
response.accessToken ?? '',
response.refreshToken ?? '',
);
await this.startRuntime();
this.setState('signed-in');
return response;
}
/** Clears all stored tokens (logout). */ /** Clears all stored tokens (logout). */
logout(): void { logout(): void {
this.ctx.options.tokenStore.clear(); this.ctx.options.tokenStore.clear();
+3 -3
View File
@@ -2,7 +2,7 @@
* BattlePassService — call-and-response access to the battle pass endpoints. * BattlePassService — call-and-response access to the battle pass endpoints.
* *
* Battle pass state is tied to a scenario battle pass node, so every call * Battle pass state is tied to a scenario battle pass node, so every call
* carries a `scenarioId` + `nodeId` (and a `runId` for the mutating calls). * carries a `scenarioSlug` + `nodeId` (and a `runId` for the mutating calls).
* Battle pass has no sync-revision key, so this is a plain service, not an * Battle pass has no sync-revision key, so this is a plain service, not an
* observable domain; re-fetch progress explicitly after a mutation. * observable domain; re-fetch progress explicitly after a mutation.
*/ */
@@ -23,8 +23,8 @@ export class BattlePassService {
constructor(private readonly ctx: RudderContext) {} constructor(private readonly ctx: RudderContext) {}
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */ /** Reads current progress (xp, level, premium, claimed tiers) for a node. */
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> { getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId }); return api.getBattlePassProgress(this.ctx, { scenarioSlug, nodeId });
} }
/** Credits XP from a configured source. Returns the new xp/level. */ /** Credits XP from a configured source. Returns the new xp/level. */
+12 -41
View File
@@ -9,8 +9,8 @@
* client (which keeps the module graph acyclic). Cross-domain wiring (the * client (which keeps the module graph acyclic). Cross-domain wiring (the
* inventory↔catalog link, purchase invalidation) is done here at construction. * inventory↔catalog link, purchase invalidation) is done here at construction.
* *
* The scenario runtime (engine + plan persistence) is loaded lazily on first * The scenario effects client is loaded lazily on first use — a client that
* use — a client that never logs in never pays for the scenario engine. * never logs in never pays for the scenario machinery.
*/ */
import type { import type {
@@ -26,7 +26,6 @@ import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
import { BattlePassService } from '../battlepass/BattlePassService.js'; import { BattlePassService } from '../battlepass/BattlePassService.js';
import { QuestsService } from '../quests/QuestsService.js'; import { QuestsService } from '../quests/QuestsService.js';
import type { ScenarioService } from '../scenario/ScenarioService.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 { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
import { SyncEngine } from '../state/SyncEngine.js'; import { SyncEngine } from '../state/SyncEngine.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.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 { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
import { StoresDomain } from '../domains/StoresDomain.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 }; type ManagedDomain = { invalidate(): void; reset(): void };
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> { export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
@@ -50,7 +48,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
public readonly quests: QuestsService; public readonly quests: QuestsService;
public readonly effects: Effects; public readonly effects: Effects;
// Observable domains: subscribe via `onChange`, read `.data`, or `reload()`.
public readonly player: PlayerDomain; public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain; public readonly catalog: CatalogDomain;
public readonly inventory: InventoryDomain; public readonly inventory: InventoryDomain;
@@ -60,8 +57,8 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
public readonly stores: StoresDomain; public readonly stores: StoresDomain;
private readonly effectsCenter: EffectsCenter; private readonly effectsCenter: EffectsCenter;
private scenarioRuntime: ScenarioService<TConfig> | null = null; private scenarioRuntime: ScenarioService | null = null;
private scenarioRuntimePromise: Promise<ScenarioService<TConfig>> | null = null; private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
private readonly syncEngine: SyncEngine; private readonly syncEngine: SyncEngine;
private readonly loginEvent: string | null; private readonly loginEvent: string | null;
private readonly ctx: RudderContext; 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 { dispose(): void {
this.syncEngine.stop(); this.syncEngine.stop();
this.scenarioRuntime?.clear(); this.scenarioRuntime?.clear();
@@ -157,8 +149,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.invalidateAll(); this.invalidateAll();
} }
this.runtimeStarted = true; 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([ await Promise.all([
this.remoteConfig.load(), this.remoteConfig.load(),
this.player.load(), this.player.load(),
@@ -166,51 +156,34 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.catalog.load(), this.catalog.load(),
]); ]);
const runtime = await runtimePromise; const runtime = await runtimePromise;
await runtime.restore(); await runtime.start();
if (this.loginEvent) { if (this.loginEvent) {
await this.sendRuntimeEvent(runtime, this.loginEvent); await this.sendRuntimeEvent(runtime, this.loginEvent);
} }
this.syncEngine.start(); this.syncEngine.start();
} }
/** private ensureScenarioRuntime(): Promise<ScenarioService> {
* 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>> {
this.scenarioRuntimePromise ??= this.createScenarioRuntime(); this.scenarioRuntimePromise ??= this.createScenarioRuntime();
return this.scenarioRuntimePromise; return this.scenarioRuntimePromise;
} }
private async createScenarioRuntime(): Promise<ScenarioService<TConfig>> { private async createScenarioRuntime(): Promise<ScenarioService> {
const { ScenarioService } = await import('../scenario/ScenarioService.js'); const { ScenarioService } = await import('../scenario/ScenarioService.js');
const configured = this.options.runtime?.planStateStore; this.scenarioRuntime = new ScenarioService(
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.ctx, this.ctx,
{ {
player: this.player, player: this.player,
inventory: this.inventory, inventory: this.inventory,
config: this.remoteConfig,
stores: this.stores, stores: this.stores,
}, },
this.battlePass, this.battlePass,
planStore,
); );
return this.scenarioRuntime; return this.scenarioRuntime;
} }
private async sendRuntimeEvent( private async sendRuntimeEvent(
runtime: ScenarioService<TConfig>, runtime: ScenarioService,
event: string, event: string,
): Promise<void> { ): Promise<void> {
try { try {
@@ -240,12 +213,10 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
]; ];
} }
/** Re-login: refetch every domain that is in use. */
private invalidateAll(): void { private invalidateAll(): void {
for (const domain of this.managedDomains) domain.invalidate(); for (const domain of this.managedDomains) domain.invalidate();
} }
/** Logout: drop all cached data. */
private resetAll(): void { private resetAll(): void {
for (const domain of this.managedDomains) domain.reset(); 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 * Test/internal access to the scenario runtime, which is deliberately not part
* of the public client surface (it is driven through `client.effects`). * 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 * @internal
*/ */
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>( export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
client: RudderClient<TConfig>, client: RudderClient<TConfig>,
): Promise<ScenarioService<TConfig>> { ): Promise<ScenarioService> {
return ( return (
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> } client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
).ensureScenarioRuntime(); ).ensureScenarioRuntime();
} }
+1 -17
View File
@@ -1,22 +1,6 @@
import type { TokenStore } from '../token/TokenStore.js'; 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 { 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; loginEvent?: string | null;
} }
@@ -46,7 +30,7 @@ export interface RudderClientOptions {
*/ */
onEffectError?: (error: unknown) => void; onEffectError?: (error: unknown) => void;
/** Advanced runtime knobs (plan persistence, login event). */ /** Advanced runtime knobs (login event). */
runtime?: RudderRuntimeOptions; runtime?: RudderRuntimeOptions;
} }
+4 -4
View File
@@ -14,7 +14,7 @@ import type { PurchaseOfferResponse } from '../generated/stores.js';
export class StoresDomain extends SyncedState<ShopHandle[]> { export class StoresDomain extends SyncedState<ShopHandle[]> {
readonly syncKey = 'stores'; readonly syncKey = 'stores';
/** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */ /** Shared purchase executor: `stores.purchase(slug, offerSlug, opts)`. */
readonly purchase: PurchaseOffer; readonly purchase: PurchaseOffer;
private readonly ctx: RudderContext; private readonly ctx: RudderContext;
@@ -24,15 +24,15 @@ export class StoresDomain extends SyncedState<ShopHandle[]> {
// handles created in the loader all delegate to this one executor. // handles created in the loader all delegate to this one executor.
const purchase: PurchaseOffer = async ( const purchase: PurchaseOffer = async (
storeSlug: string, storeSlug: string,
offerId: string, offerSlug: string,
options: BuyOptions = {}, options: BuyOptions = {},
): Promise<PurchaseOfferResponse> => { ): Promise<PurchaseOfferResponse> => {
const response = await api.purchaseOffer( const response = await api.purchaseOffer(
ctx, ctx,
{ storeSlug, offerId }, { storeSlug, offerSlug },
{ {
storeSlug, storeSlug,
offerId, offerSlug,
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
}, },
); );
+24 -82
View File
@@ -1,15 +1,5 @@
import type { PurchaseOfferResponse } from '../generated/stores.js'; import type { PurchaseOfferResponse } from '../generated/stores.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.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 { import type {
AddBattlePassXpResponse, AddBattlePassXpResponse,
ClaimBattlePassRewardResponse, ClaimBattlePassRewardResponse,
@@ -35,6 +25,8 @@ export interface StoreOfferEffect {
export interface LeaderboardEffect { export interface LeaderboardEffect {
end(): Promise<void>; end(): Promise<void>;
claim(): Promise<void>;
/** @deprecated Use {@link LeaderboardEffect.claim}. Removed in the next SDK version. */
rewardClaimed(): Promise<void>; rewardClaimed(): Promise<void>;
} }
@@ -42,50 +34,37 @@ export interface ConfigChangedEffect {
readonly key: string; readonly key: string;
} }
/** A scenario wait node became active; the run resumes at `deadlineUtc`. */
export interface WaitEffect { export interface WaitEffect {
readonly deadlineUtc: Date; readonly deadlineUtc: Date;
} }
/** A scenario run reached a terminal end successfully. */
export interface ScenarioCompletedEffect { export interface ScenarioCompletedEffect {
readonly runId: string; readonly runId: string;
readonly scenarioId: string; readonly scenarioSlug: string;
} }
/**
* A scenario run failed at a node (transport gave up, or the node type is not
* supported by this SDK). Surfaced so the game can react instead of the failure
* being swallowed into a console warning.
*/
export interface ScenarioFailedEffect { export interface ScenarioFailedEffect {
readonly runId: string; readonly runId: string;
readonly scenarioId: string; readonly scenarioSlug: string;
readonly nodeId: string; readonly nodeId: string;
readonly error: Error; 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 { export interface QuestEffect {
readonly name: string; readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>; readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>; reportProgress(objectiveId: string, amount?: number): Promise<void>;
} }
/** A scenario battle pass node became active. */
export interface BattlePassEffect { export interface BattlePassEffect {
getProgress(): Promise<GetBattlePassProgressResponse>; getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>; addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
claimReward(level: number, track?: string): Promise<ClaimBattlePassRewardResponse>; claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>; purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
levelUp(): Promise<void>; levelUp(): Promise<void>;
end(): Promise<void>; end(): Promise<void>;
} }
/** A scenario battlepass_level node became active (a single claimable tier). */
export interface BattlePassLevelEffect { export interface BattlePassLevelEffect {
readonly level: number; readonly level: number;
claim(): Promise<void>; claim(): Promise<void>;
@@ -164,35 +143,20 @@ export class EffectsCenter implements Effects {
} }
/** @internal */ /** @internal */
emitNotification(session: NotificationSession): void { emitNotification(effect: NotificationEffect): void {
this.emit(this.notificationHandlers, { this.emit(this.notificationHandlers, effect);
title: session.title,
message: session.message,
done: () => session.complete(),
});
} }
/** @internal */ /** @internal */
emitStoreOffer(session: StoreSession): void { emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
session.getStore() Promise.resolve(effect)
.then((store) => { .then((value) => this.emit(this.storeOfferHandlers, value))
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(),
});
})
.catch((error) => this.onError(error)); .catch((error) => this.onError(error));
} }
/** @internal */ /** @internal */
emitLeaderboard(session: LeaderboardSession): void { emitLeaderboard(effect: LeaderboardEffect): void {
this.emit(this.leaderboardHandlers, { this.emit(this.leaderboardHandlers, effect);
end: () => session.end(),
rewardClaimed: () => session.rewardClaimed(),
});
} }
/** @internal */ /** @internal */
@@ -201,55 +165,33 @@ export class EffectsCenter implements Effects {
} }
/** @internal */ /** @internal */
emitWait(session: WaitSession): void { emitWait(effect: WaitEffect): void {
this.emit(this.waitHandlers, { deadlineUtc: session.deadlineUtc }); this.emit(this.waitHandlers, effect);
} }
/** @internal */ /** @internal */
emitScenarioCompleted(run: PlanRun): void { emitScenarioCompleted(effect: ScenarioCompletedEffect): void {
this.emit(this.scenarioCompletedHandlers, { this.emit(this.scenarioCompletedHandlers, effect);
runId: run.runId,
scenarioId: run.scenarioId,
});
} }
/** @internal */ /** @internal */
emitScenarioFailed(event: ScenarioRunFailedEvent): void { emitScenarioFailed(effect: ScenarioFailedEffect): void {
this.emit(this.scenarioFailedHandlers, { this.emit(this.scenarioFailedHandlers, effect);
runId: event.run.runId,
scenarioId: event.run.scenarioId,
nodeId: event.nodeId,
error: event.error,
});
} }
/** @internal */ /** @internal */
emitQuest(session: QuestSession): void { emitQuest(effect: QuestEffect): void {
this.emit(this.questHandlers, { this.emit(this.questHandlers, effect);
name: session.name,
objectives: session.objectives,
reportProgress: (objectiveId, amount) => session.reportProgress(objectiveId, amount),
});
} }
/** @internal */ /** @internal */
emitBattlePass(session: BattlePassSession): void { emitBattlePass(effect: BattlePassEffect): void {
this.emit(this.battlePassHandlers, { this.emit(this.battlePassHandlers, effect);
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(),
});
} }
/** @internal */ /** @internal */
emitBattlePassLevel(session: BattlePassLevelSession): void { emitBattlePassLevel(effect: BattlePassLevelEffect): void {
this.emit(this.battlePassLevelHandlers, { this.emit(this.battlePassLevelHandlers, effect);
level: session.level,
claim: () => session.claim(),
});
} }
private addHandler<TEffect>( private addHandler<TEffect>(
+17 -13
View File
@@ -1,15 +1,15 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { LoginViaDeviceRequest, LoginViaDeviceResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse } from './auth.js'; import type { LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse } from './auth.js';
import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js'; import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js';
import type { ListCatalogItemsResponse } from './catalog.js'; import type { ListCatalogItemsResponse } from './catalog.js';
import type { GetInventoryResponse } from './inventory.js'; import type { GetInventoryResponse } from './inventory.js';
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js'; import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
import type { PlayerProfile } from './player.js'; import type { PlayerProfile } from './player.js';
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js'; import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsRequest, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js'; import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js';
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.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 { GetStorageResponse, UpdateStorageRequest } from './storage.js';
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js'; import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
@@ -100,12 +100,6 @@ export const api = {
): Promise<{ [key: string]: number }> => ): Promise<{ [key: string]: number }> =>
t.request<{ [key: string]: number }>('GET', '/sdk/v1/sync'), 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: ( getStorage: (
t: Transport, t: Transport,
query?: { types?: string; limit?: number; cursor?: string }, query?: { types?: string; limit?: number; cursor?: string },
@@ -129,11 +123,15 @@ export const api = {
): Promise<ListCatalogItemsResponse> => ): Promise<ListCatalogItemsResponse> =>
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'), t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
listPendingScenarioEffects: (
t: Transport,
): Promise<ListPendingScenarioEffectsResponse> =>
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
listQuests: ( listQuests: (
t: Transport, t: Transport,
body: ListQuestsRequest,
): Promise<ListQuestsResponse> => ): Promise<ListQuestsResponse> =>
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list', body), t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list'),
listSdkRemoteConfigs: ( listSdkRemoteConfigs: (
t: Transport, t: Transport,
@@ -145,6 +143,12 @@ export const api = {
): Promise<ListStoresResponse> => ): Promise<ListStoresResponse> =>
t.request<ListStoresResponse>('GET', '/sdk/v1/stores'), t.request<ListStoresResponse>('GET', '/sdk/v1/stores'),
loginViaCustom: (
t: Transport,
body: LoginViaCustomRequest,
): Promise<LoginViaCustomResponse> =>
t.request<LoginViaCustomResponse>('POST', '/sdk/v1/authorization/custom', body),
loginViaDevice: ( loginViaDevice: (
t: Transport, t: Transport,
body: LoginViaDeviceRequest, body: LoginViaDeviceRequest,
@@ -159,10 +163,10 @@ export const api = {
purchaseOffer: ( purchaseOffer: (
t: Transport, t: Transport,
path: { storeSlug: string; offerId: string }, path: { storeSlug: string; offerSlug: string },
body: PurchaseOfferRequest, body: PurchaseOfferRequest,
): Promise<PurchaseOfferResponse> => ): Promise<PurchaseOfferResponse> =>
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerId)}/purchase`, body), t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
refreshAccessToken: ( refreshAccessToken: (
t: Transport, t: Transport,
+13
View File
@@ -1,5 +1,18 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
export interface LoginViaCustomRequest {
"customData"?: { [key: string]: unknown };
"key"?: string;
"language"?: string;
"nickname"?: string;
"region"?: string;
}
export interface LoginViaCustomResponse {
"accessToken"?: string;
"refreshToken"?: string;
}
export interface LoginViaDeviceRequest { export interface LoginViaDeviceRequest {
"deviceId"?: string; "deviceId"?: string;
"key"?: string; "key"?: string;
+7 -15
View File
@@ -1,12 +1,12 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan } from './common.js'; import type { Reward } from './common.js';
export interface AddBattlePassXpRequest { export interface AddBattlePassXpRequest {
"amount"?: number; "amount"?: number;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
"source"?: string; "source"?: string;
} }
@@ -14,28 +14,21 @@ export interface AddBattlePassXpResponse {
"level"?: number; "level"?: number;
"leveledUp"?: boolean; "leveledUp"?: boolean;
"maxLevel"?: boolean; "maxLevel"?: boolean;
"plan"?: ExecutionPlan;
"xp"?: number; "xp"?: number;
} }
export interface BattlePassReward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
}
export interface ClaimBattlePassRewardRequest { export interface ClaimBattlePassRewardRequest {
"level"?: number; "level"?: number;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
"track"?: string; "track"?: "free" | "premium";
} }
export interface ClaimBattlePassRewardResponse { export interface ClaimBattlePassRewardResponse {
"alreadyClaimed"?: boolean; "alreadyClaimed"?: boolean;
"error"?: string; "error"?: string;
"granted"?: BattlePassReward[]; "granted"?: Reward[];
"success"?: boolean; "success"?: boolean;
} }
@@ -46,7 +39,7 @@ export interface ClaimedTier {
export interface GetBattlePassProgressRequest { export interface GetBattlePassProgressRequest {
"nodeId"?: string; "nodeId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
} }
export interface GetBattlePassProgressResponse { export interface GetBattlePassProgressResponse {
@@ -60,12 +53,11 @@ export interface PurchaseBattlePassPremiumRequest {
"idempotencyKey"?: string; "idempotencyKey"?: string;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
} }
export interface PurchaseBattlePassPremiumResponse { export interface PurchaseBattlePassPremiumResponse {
"error"?: string; "error"?: string;
"plan"?: ExecutionPlan;
"success"?: boolean; "success"?: boolean;
} }
+6 -34
View File
@@ -1,44 +1,16 @@
// Code generated by apigen. DO NOT EDIT. // 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 { export interface ErrorResponse {
"code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run"; "code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
"error"?: string; "error"?: string;
"index"?: number;
"playerId"?: string;
"requestId"?: string; "requestId"?: string;
} }
export interface ExecutionPlan { export interface Reward {
"boundaryNodes"?: BoundaryNode[]; "amount"?: number;
"context"?: { [key: string]: unknown }; "currency"?: string;
"edges"?: PlanEdge[]; "itemId"?: string;
"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;
} }
+1
View File
@@ -1,6 +1,7 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
export interface Player { export interface Player {
"avatarUrl"?: string;
"createdAt"?: string; "createdAt"?: string;
"id"?: string; "id"?: string;
"language"?: string; "language"?: string;
+8 -15
View File
@@ -1,29 +1,28 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { Reward } from './common.js';
export interface ClaimQuestRequest { export interface ClaimQuestRequest {
"questId"?: string; "questSlug"?: string;
} }
export interface ClaimQuestResponse { export interface ClaimQuestResponse {
"alreadyClaimed"?: boolean; "alreadyClaimed"?: boolean;
"error"?: string; "error"?: string;
"granted"?: QuestReward[]; "granted"?: Reward[];
"success"?: boolean; "success"?: boolean;
} }
export interface ListQuestsRequest {
}
export interface ListQuestsResponse { export interface ListQuestsResponse {
"quests"?: Quest[]; "quests"?: Quest[];
} }
export interface Quest { export interface Quest {
"id"?: string;
"name"?: string; "name"?: string;
"objectives"?: QuestObjectiveProgress[]; "objectives"?: QuestObjectiveProgress[];
"rewards"?: QuestReward[]; "rewards"?: Reward[];
"status"?: string; "slug"?: string;
"status"?: "active" | "claimed" | "completed";
} }
export interface QuestObjectiveProgress { export interface QuestObjectiveProgress {
@@ -34,18 +33,12 @@ export interface QuestObjectiveProgress {
"target"?: number; "target"?: number;
} }
export interface QuestReward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
}
export interface ReportQuestProgressRequest { export interface ReportQuestProgressRequest {
"amount"?: number; "amount"?: number;
"metric"?: string; "metric"?: string;
} }
export interface ReportQuestProgressResponse { export interface ReportQuestProgressResponse {
"completedQuestIds"?: string[]; "completedQuestSlugs"?: string[];
} }
+1 -1
View File
@@ -14,6 +14,6 @@ export interface RemoteConfig {
"projectId"?: string; "projectId"?: string;
"updatedAt"?: string; "updatedAt"?: string;
"value"?: string; "value"?: string;
"valueType"?: string; "valueType"?: "bool" | "float" | "int" | "json" | "string";
} }
+18 -17
View File
@@ -1,26 +1,27 @@
// Code generated by apigen. DO NOT EDIT. // 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"?: string;
}
export interface HandleScenarioCallbackRequest { export interface HandleScenarioCallbackRequest {
"handle"?: string; "handle"?: string;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
} }
export interface HandleScenarioCallbackResponse { export interface HandleScenarioCallbackResponse {
"plan"?: ExecutionPlan; "effect"?: PendingEffect;
}
export interface ListPendingScenarioEffectsResponse {
"effects": PendingEffect[];
}
export interface PendingEffect {
"data": { [key: string]: unknown };
"nodeId": string;
"runId": string;
"scenarioSlug": string;
"type": string;
"waitDeadline"?: string;
} }
export interface TriggerScenarioRequest { export interface TriggerScenarioRequest {
@@ -28,7 +29,7 @@ export interface TriggerScenarioRequest {
} }
export interface TriggerScenarioResponse { export interface TriggerScenarioResponse {
"plans"?: ExecutionPlan[]; "effects": PendingEffect[];
} }
export interface UpdateScenarioCounterRequest { export interface UpdateScenarioCounterRequest {
@@ -36,11 +37,11 @@ export interface UpdateScenarioCounterRequest {
"counterKey"?: string; "counterKey"?: string;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
} }
export interface UpdateScenarioCounterResponse { export interface UpdateScenarioCounterResponse {
"completed"?: boolean; "completed"?: boolean;
"plan"?: ExecutionPlan; "effect"?: PendingEffect;
} }
+2 -1
View File
@@ -12,6 +12,7 @@ export interface Offer {
"maxPurchases"?: number; "maxPurchases"?: number;
"name"?: string; "name"?: string;
"price"?: OfferPrice; "price"?: OfferPrice;
"slug"?: string;
"updatedAt"?: string; "updatedAt"?: string;
} }
@@ -27,7 +28,7 @@ export interface OfferPrice {
export interface PurchaseOfferRequest { export interface PurchaseOfferRequest {
"idempotencyKey"?: string; "idempotencyKey"?: string;
"offerId"?: string; "offerSlug"?: string;
"storeSlug"?: string; "storeSlug"?: string;
} }
+3 -2
View File
@@ -22,11 +22,13 @@ export type {
AuthService, AuthService,
AuthState, AuthState,
AuthStateListener, AuthStateListener,
LoginWithCustomOptions,
LoginWithDeviceOptions, LoginWithDeviceOptions,
} from './auth/AuthService.js'; } from './auth/AuthService.js';
export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js'; export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js';
export type { BattlePassService } from './battlepass/BattlePassService.js'; export type { BattlePassService } from './battlepass/BattlePassService.js';
export type { QuestsService } from './quests/QuestsService.js'; export type { QuestsService } from './quests/QuestsService.js';
export * as QuestMetrics from './quests/QuestMetrics.js';
// Observable state primitives // Observable state primitives
export { SyncedState } from './state/SyncedState.js'; export { SyncedState } from './state/SyncedState.js';
@@ -87,14 +89,13 @@ export type {
ProjectStorageUpdateItem, ProjectStorageUpdateItem,
} from './generated/project-storage.js'; } from './generated/project-storage.js';
export type { RemoteConfig } from './generated/remote-config.js'; export type { RemoteConfig } from './generated/remote-config.js';
export type { Reward } from './generated/common.js';
export type { export type {
Quest, Quest,
QuestObjectiveProgress, QuestObjectiveProgress,
QuestReward,
ClaimQuestResponse, ClaimQuestResponse,
} from './generated/quests.js'; } from './generated/quests.js';
export type { export type {
BattlePassReward,
ClaimedTier, ClaimedTier,
AddBattlePassXpRequest, AddBattlePassXpRequest,
AddBattlePassXpResponse, AddBattlePassXpResponse,
+19
View File
@@ -0,0 +1,19 @@
/**
* QuestMetrics — builders for the quest metric strings known to the platform.
*
* Purchase metrics are reported automatically server-side by the shop
* purchase fan-out, so these helpers exist mainly to name the format
* instead of hardcoding it. Catalog counter slugs are reported via
* {@link QuestsService.reportProgress}. Custom free-text metrics no longer
* progress quests — they no-op at runtime.
*/
/** Metric for purchasing a store offer; auto-reported on purchase. */
export function purchaseOffer(offerSlug: string): string {
return `purchase.offer:${offerSlug}`;
}
/** Metric for purchasing a catalog item; auto-reported on purchase. */
export function purchaseItem(itemId: string): string {
return `purchase.item:${itemId}`;
}
+4 -4
View File
@@ -15,18 +15,18 @@ export class QuestsService {
/** Lists the player's quests with per-objective progress and rewards. */ /** Lists the player's quests with per-objective progress and rewards. */
async list(): Promise<Quest[]> { async list(): Promise<Quest[]> {
const response = await api.listQuests(this.ctx, {}); const response = await api.listQuests(this.ctx);
return response.quests ?? []; return response.quests ?? [];
} }
/** Claims a completed quest's rewards (idempotent server-side). */ /** Claims a completed quest's rewards (idempotent server-side). */
claim(questId: string): Promise<ClaimQuestResponse> { claim(questSlug: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questId }); return api.claimQuest(this.ctx, { questSlug });
} }
/** Reports metric progress; returns ids of quests completed by this report. */ /** Reports metric progress; returns ids of quests completed by this report. */
async reportProgress(metric: string, amount: number): Promise<string[]> { async reportProgress(metric: string, amount: number): Promise<string[]> {
const response = await api.reportQuestProgress(this.ctx, { metric, amount }); const response = await api.reportQuestProgress(this.ctx, { metric, amount });
return response.completedQuestIds ?? []; return response.completedQuestSlugs ?? [];
} }
} }
+355 -494
View File
@@ -1,593 +1,454 @@
import type { RudderContext } from '../core/context.js'; import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape, RemoteConfigState } from '../state/RemoteConfigState.js'; import type { ShopHandle, OfferHandle, BuyOptions } from '../state/shops.js';
import type { ShopHandle } from '../state/shops.js'; import type { PurchaseOfferResponse } from '../generated/stores.js';
import { api } from '../generated/api.js'; import { api } from '../generated/api.js';
import type { TriggerScenarioResponse } from '../generated/scenarios.js'; import type { PendingEffect, TriggerScenarioResponse } from '../generated/scenarios.js';
import type { ExecutionPlan } from '../generated/common.js'; import { RudderErrorCodes } from '../generated/errors.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 { import {
RudderNetworkError, RudderNetworkError,
RudderHttpError, RudderHttpError,
} from '../client/RudderError.js'; } 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'; import type { BattlePassService } from '../battlepass/BattlePassService.js';
/** export interface ScenarioDomains {
* Error thrown when a boundary HTTP call fails with a transient error readonly player: { invalidate(): void };
* (network failure or server 5xx). The caller should NOT advance the run; readonly inventory: { invalidate(): void };
* the node stays active and the handle stays pending for retry on reconnect. readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
*/ }
class TransientBoundaryError extends Error {
constructor(public readonly inner: unknown) { const DEFAULT_PENDING_INTERVAL_MS = 30_000;
super('Transient boundary error'); const JITTER_RATIO = 0.2;
this.name = 'TransientBoundaryError';
} type ActiveRecord = {
runId: string;
nodeId: string;
scenarioSlug: string;
storePurchased?: boolean;
lastPurchase?: PurchaseOfferResponse;
};
function effectKey(runId: string, nodeId: string): string {
return `${runId}:${nodeId}`;
} }
/** Returns true for network / 5xx errors that may succeed on retry. */
function isTransientHttpError(err: unknown): boolean { function isTransientHttpError(err: unknown): boolean {
if (err instanceof RudderNetworkError) return true; if (err instanceof RudderNetworkError) return true;
if (err instanceof RudderHttpError && err.status >= 500) return true; if (err instanceof RudderHttpError && err.status >= 500) return true;
return false; return false;
} }
function isDroppedRunError(err: unknown): boolean {
if (!(err instanceof RudderHttpError)) return false;
return (
err.code === RudderErrorCodes.unknownRun ||
err.code === RudderErrorCodes.runExpired ||
err.code === RudderErrorCodes.runNotActive
);
}
export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigShape> { function asString(value: unknown, fallback = ''): string {
private readonly runs = new Map<string, RuntimeRun>(); 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 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( constructor(
private readonly ctx: RudderContext, private readonly ctx: RudderContext,
private readonly domains: ScenarioDomains, private readonly domains: ScenarioDomains,
private readonly battlePass: BattlePassService, 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 { 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> { async send(eventName: string): Promise<TriggerScenarioResponse> {
const response = await api.triggerScenario(this.ctx, { event: eventName }); const response = await api.triggerScenario(this.ctx, { event: eventName });
this.domains.player.invalidate(); this.domains.player.invalidate();
this.domains.inventory.invalidate(); this.domains.inventory.invalidate();
this.startPlans(response.plans ?? []); this.ingest(response.effects ?? []);
return response; return response;
} }
/** Completes the current active node with the given handle. */ async start(): Promise<void> {
async respond(handle: string): Promise<void> { if (this.running) return;
for (const run of this.runs.values()) { this.running = true;
for (const nodeId of run.activeNodes.keys()) { if (typeof document !== 'undefined') {
await this.completeNodeAsync(run.runId, nodeId, handle); document.addEventListener('visibilitychange', this.onVisibilityChange);
return;
}
} }
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 });
if (response?.status === 'unknown_run' || response?.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 { clear(): void {
this.running = false;
this.pollQueued = false;
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
for (const timer of this.waitTimers.values()) { for (const timer of this.waitTimers.values()) {
clearTimeout(timer); clearTimeout(timer);
} }
this.waitTimers.clear(); this.waitTimers.clear();
this.runs.clear(); this.active.clear();
this.persist().catch(() => {}); this.droppedRuns.clear();
} this.completedRuns.clear();
if (typeof document !== 'undefined') {
// ---- Internal: Plan lifecycle ---- document.removeEventListener('visibilitychange', this.onVisibilityChange);
private startPlans(plans: ExecutionPlan[]): void {
for (const plan of plans ?? []) {
this.startPlan(plan);
} }
} }
private startPlan(plan: ExecutionPlan): void { private readonly onVisibilityChange = (): void => {
if (!plan.nodes?.length) return; if (this.running && typeof document !== 'undefined' && !document.hidden) {
if (plan.boundaryNodes?.length && !plan.runId) { void this.heartbeatTick();
throw new Error('ExecutionPlan has boundaryNodes but missing runId');
} }
// 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; private scheduleHeartbeat(): void {
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0]; if (!this.running) return;
if (!startNode?.id) return; if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
const runId = plan.runId ?? crypto.randomUUID().replace(/-/g, ''); const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
const run = new RuntimeRun(runId, plan); this.heartbeatTimer = setTimeout(() => {
this.runs.set(run.runId, run); void this.heartbeatTick();
this.activateNode(run, startNode.id); }, this.intervalMs * jitter);
this.persist().catch(() => {});
} }
private activateNode( private async heartbeatTick(): Promise<void> {
run: RuntimeRun, if (!this.running) return;
nodeId: string, if (typeof document !== 'undefined' && document.hidden) {
restoredState?: ActiveNodeState, this.scheduleHeartbeat();
): void { return;
const node = findNode(run.plan, nodeId); }
if (!node?.id) return; await this.pollPending();
const state = restoredState ?? { nodeId }; this.scheduleHeartbeat();
run.activeNodes.set(nodeId, state);
this.dispatchActiveNode(run, state, restoredState !== undefined);
} }
// ---- 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( private reconcile(effects: PendingEffect[]): void {
run: RuntimeRun, const incoming = effects.filter((effect) => !this.droppedRuns.has(effect.runId));
state: ActiveNodeState, const incomingKeys = new Set(
restored: boolean, incoming.map((effect) => effectKey(effect.runId, effect.nodeId)),
): void { );
const node = findNode(run.plan, state.nodeId); const before = new Set(this.active.keys());
if (!node) {
run.activeNodes.delete(state.nodeId); this.ingest(incoming);
this.checkRunCompleted(run);
for (const [key, record] of [...this.active.entries()]) {
if (incomingKeys.has(key)) continue;
this.deactivate(key);
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
const after = new Set(this.active.keys());
if (before.size !== after.size || [...before].some((key) => !after.has(key))) {
this.domains.player.invalidate();
this.domains.inventory.invalidate();
}
}
private ingest(effects: PendingEffect[]): void {
for (const effect of effects) {
this.ingestOne(effect);
}
}
private ingestOne(pending: PendingEffect): void {
if (this.droppedRuns.has(pending.runId)) return;
const key = effectKey(pending.runId, pending.nodeId);
if (this.active.has(key)) {
if (pending.waitDeadline && !this.waitTimers.has(key)) {
this.scheduleWait(key, pending.waitDeadline);
}
return; return;
} }
const ctx = new ScenarioNodeContext( const record: ActiveRecord = {
this.toPlanRun(run), runId: pending.runId,
node, nodeId: pending.nodeId,
(rid, nid, h) => this.completeNodeInternal(rid, nid, h, true), scenarioSlug: pending.scenarioSlug,
(rid, nid, ck, amt) => this.updateProgressInternal(rid, nid, ck, amt), };
); this.active.set(key, record);
this.completedRuns.delete(pending.runId);
switch (node.type) { if (pending.waitDeadline) {
case 'wait': this.scheduleWait(key, pending.waitDeadline);
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);
} }
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; break;
case 'store': case 'store':
{ this.dispatchStore(pending, record);
const session = new StoreSession(ctx, this.domains.stores);
this.ctx.effects.emitStoreOffer(session);
this.onStore?.(session);
}
break; break;
case 'leaderboard': case 'wait':
{ this.ctx.effects.emitWait({
const session = new LeaderboardSession(ctx); deadlineUtc: pending.waitDeadline
this.ctx.effects.emitLeaderboard(session); ? new Date(pending.waitDeadline)
this.onLeaderboard?.(session); : new Date(),
} });
break; 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': 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; break;
case 'battlepass': case 'battlepass':
this.ctx.effects.emitBattlePass(new BattlePassSession(ctx, this.battlePass)); this.dispatchBattlePass(record);
break; break;
case 'battlepass_level': 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; break;
default: default:
// Unsupported node type — fail the run (surfaced via onScenarioFailed)
// instead of leaving it stalled on a node no handler will complete.
console.warn( console.warn(
`[Rudder] Unsupported scenario node type '${node.type}' (${node.id})`, `[Rudder] Unsupported scenario node type '${pending.type}' (${pending.nodeId})`,
); );
this.failRun( this.dropRun(
run, record.runId,
state.nodeId, record.nodeId,
new Error(`Unsupported scenario node type '${node.type}'`), new Error(`Unsupported scenario node type '${pending.type}'`),
); );
break; break;
} }
} }
private dispatchWait( private dispatchStore(pending: PendingEffect, record: ActiveRecord): void {
run: RuntimeRun, const slug = asString(pending.data.storeSlug);
state: ActiveNodeState, this.ctx.effects.emitStoreOffer(
ctx: ScenarioNodeContext, this.domains.stores.getBySlug(slug).then((store) => ({
): void { store,
// Prefer server-provided waitDeadline from the plan boundary over local calculation. offers: store.offers,
// The server stamps waitDeadline on server-enforced wait boundaries (see StampBoundaries). message: typeof pending.data.message === 'string' ? pending.data.message : undefined,
if (!state.waitDeadlineUtc) { buy: (offer, options) => this.buyStore(record, offer, options),
const boundary = (run.plan.boundaryNodes ?? []).find( dismiss: () => this.dismissStore(record),
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 dispatchRemoteConfigOverride( private dispatchBattlePass(record: ActiveRecord): void {
run: RuntimeRun, const { scenarioSlug, nodeId, runId } = record;
state: ActiveNodeState, this.ctx.effects.emitBattlePass({
ctx: ScenarioNodeContext, getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
): void { addXp: (source, amount) =>
const patches = ctx.data.patches as Array<{ this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
path?: string; claimReward: (level, track) =>
valueType?: string; this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
value?: string; purchasePremium: async () => {
}> | undefined; const response = await this.battlePass.purchasePremium({
scenarioSlug,
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,
nodeId, 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, {
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
counterKey: objectiveId,
amount,
runId: record.runId,
});
if (!response?.completed) return;
this.domains.player.invalidate();
this.domains.inventory.invalidate();
const key = effectKey(record.runId, record.nodeId);
if (this.active.has(key)) {
this.deactivate(key);
}
if (response.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch {
}
}
private async complete(record: ActiveRecord, handle: string): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (this.droppedRuns.has(record.runId)) return;
try { try {
const response = await api.handleScenarioCallback(this.ctx, { const response = await api.handleScenarioCallback(this.ctx, {
scenarioId: run.plan.scenarioId, scenarioSlug: record.scenarioSlug,
nodeId: boundary.sourceNodeId, nodeId: record.nodeId,
handle: boundary.sourceHandle, handle,
runId: run.runId, runId: record.runId,
}); });
this.domains.player.invalidate(); this.domains.player.invalidate();
this.domains.inventory.invalidate(); this.domains.inventory.invalidate();
if (response?.plan) { if (!this.active.has(key)) return;
this.startPlan(response.plan); this.deactivate(key);
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
} }
} catch (err: unknown) { } catch (err) {
// Boundary call failed — try to reconcile with server. if (isTransientHttpError(err)) return;
let reconciled = false; if (isDroppedRunError(err)) {
try { const error = err instanceof Error ? err : new Error(String(err));
const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId }); this.dropRun(record.runId, record.nodeId, error);
if (reconcile?.status === 'unknown_run' || reconcile?.status === 'expired') {
this.runs.delete(run.runId);
await this.persist();
reconciled = true;
} else if (reconcile?.plan) {
const newRun = new RuntimeRun(run.runId, reconcile.plan);
this.runs.set(run.runId, newRun);
const startNode = findNode(reconcile.plan, reconcile.plan.startNodeId) ?? reconcile.plan.nodes?.[0];
if (startNode?.id) {
this.activateNode(newRun, startNode.id);
}
reconciled = true;
}
} catch {
// Reconciliation also failed.
}
if (!reconciled) {
// Reconcile did not resolve — distinguish transient from terminal.
if (isTransientHttpError(err)) {
throw new TransientBoundaryError(err);
}
// Terminal error (typed error code from the server) — let the caller failRun.
throw err; throw err;
} }
// If reconciled, the boundary was handled (run corrected or removed). throw err;
// 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.
} }
} }
// ---- Internal: Run lifecycle ---- private scheduleWait(key: string, waitDeadline: string): void {
const deadline = Date.parse(waitDeadline);
private checkRunCompleted(run: RuntimeRun): void { if (Number.isNaN(deadline)) return;
if (run.activeNodes.size > 0) return; const remaining = Math.max(0, deadline - Date.now());
// A node transition is still settling (e.g. an auto-completing const existing = this.waitTimers.get(key);
// remote_config_override node is about to activate its successor). if (existing) clearTimeout(existing);
// Wait for it to finish before declaring the run complete. this.waitTimers.set(
if (run.pendingTransitions > 0) return; key,
if (!this.runs.has(run.runId)) return; // already completed/removed setTimeout(() => {
this.runs.delete(run.runId); this.waitTimers.delete(key);
const planRun = this.toPlanRun(run); void this.pollPending();
this.onRunCompleted?.(planRun); }, remaining),
this.onCompleted?.(); );
this.ctx.effects.emitScenarioCompleted(planRun);
this.persist().catch(() => {});
} }
private failRun( private deactivate(key: string): void {
run: RuntimeRun, const timer = this.waitTimers.get(key);
nodeId: string, if (timer) {
error: Error, clearTimeout(timer);
): void { this.waitTimers.delete(key);
}
this.active.delete(key);
}
private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId);
let scenarioSlug = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
this.deactivate(key);
}
console.warn( 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); this.ctx.effects.emitScenarioFailed({
const event: ScenarioRunFailedEvent = { run: this.toPlanRun(run), nodeId, error }; runId,
this.onRunFailed?.(event); scenarioSlug,
this.ctx.effects.emitScenarioFailed(event); nodeId,
this.persist().catch(() => {}); error,
}
// ---- Internal: Helpers ----
private toPlanRun(run: RuntimeRun): PlanRun {
return new PlanRun(
run.runId,
run.plan.planId ?? '',
run.plan.scenarioId ?? '',
run.plan.userId ?? '',
[...run.activeNodes.keys()],
run.plan,
);
}
private async persist(): Promise<void> {
const store = this.planStore;
if (!store) return;
const state = JSON.stringify({
runs: [...this.runs.values()].map((run) => ({
runId: run.runId,
plan: run.plan,
activeNodes: [...run.activeNodes.values()],
completedHandles: [...run.completedHandles],
})),
}); });
store.state = state; }
try {
await store.save(state); private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
} catch { if (this.droppedRuns.has(runId)) return;
// Best-effort persistence. if (this.completedRuns.has(runId)) return;
} for (const record of this.active.values()) {
if (record.runId === runId) return;
}
this.completedRuns.add(runId);
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
} }
} }
-74
View File
@@ -1,74 +0,0 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../generated/common.js';
/** Finds a node by ID within a plan (linear scan of the nodes array). */
export function findNode(
plan: ExecutionPlan,
nodeId: string | undefined,
): ExecutionPlanNode | undefined {
if (!nodeId || !plan.nodes) return undefined;
return plan.nodes.find((n) => n.id === nodeId);
}
/** Yields all edges whose source node and sourceHandle match. */
export function* matchingEdges(
plan: ExecutionPlan,
sourceNodeId: string,
sourceHandle: string,
): Generator<PlanEdge> {
for (const edge of plan.edges ?? []) {
if (
edge.source === sourceNodeId &&
(edge.sourceHandle ?? '') === (sourceHandle ?? '')
) {
yield edge;
}
}
}
/** Yields all boundary nodes matching the completed node + handle. */
export function* matchingBoundaryNodes(
plan: ExecutionPlan,
sourceNodeId: string,
sourceHandle: string,
): Generator<BoundaryNode> {
for (const boundary of plan.boundaryNodes ?? []) {
if (
boundary.sourceNodeId === sourceNodeId &&
(boundary.sourceHandle ?? '') === (sourceHandle ?? '')
) {
yield boundary;
}
}
}
/** Returns a deduplication key for completed node+handle pairs. */
export function completedHandleKey(nodeId: string, handle: string): string {
return `${nodeId}:${handle ?? ''}`;
}
/** Converts wait duration+unit to milliseconds. Unit required; defaults to seconds when unit is unrecognized. */
export function durationToMs(duration: number, unit: string): number {
switch ((unit ?? 'seconds').toLowerCase()) {
case 'days':
case 'day':
case 'd':
return duration * 86_400_000;
case 'hours':
case 'hour':
case 'hr':
case 'h':
return duration * 3_600_000;
case 'minutes':
case 'minute':
case 'min':
case 'm':
return duration * 60_000;
case 'seconds':
case 'second':
case 'sec':
case 's':
return duration * 1000;
default:
return duration * 1000; // default: seconds
}
}
-94
View File
@@ -1,94 +0,0 @@
/**
* IndexedDB-backed plan state store for persisting scenario execution state.
*
* The C# SDK uses a synchronous `IPlanStateStore { string State { get; set; } }`.
* IndexedDB is inherently async, so we provide an async `load()` / `save()` API
* alongside a synchronous `state` property for immediate reads after load.
*/
export interface PlanStateStore {
/** Current serialized state (available after `load()` or `save()`). */
state: string | null;
/** Loads persisted state from IndexedDB. Call once on SDK initialization. */
load(): Promise<void>;
/** Persists the current state to IndexedDB. */
save(state: string | null): Promise<void>;
}
const DB_NAME = 'RudderPlanState';
const STORE_NAME = 'state';
const KEY = 'active_runs';
const DB_VERSION = 1;
export function createIndexedDbPlanStateStore(): PlanStateStore {
let dbPromise: Promise<IDBDatabase> | null = null;
let cachedState: string | null = null;
function getDb(): Promise<IDBDatabase> {
if (!dbPromise) {
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return dbPromise;
}
return {
get state(): string | null {
return cachedState;
},
set state(value: string | null) {
cachedState = value;
// Best-effort async write — does not block the setter.
getDb()
.then((db) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
if (value === null) {
tx.objectStore(STORE_NAME).delete(KEY);
} else {
tx.objectStore(STORE_NAME).put(value, KEY);
}
})
.catch(() => {
// IndexedDB write failed — state is still in memory.
});
},
async load(): Promise<void> {
const db = await getDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(KEY);
req.onsuccess = () => {
cachedState = (req.result as string) ?? null;
resolve();
};
req.onerror = () => reject(req.error);
});
},
async save(state: string | null): Promise<void> {
cachedState = state;
const db = await getDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const req =
state === null
? tx.objectStore(STORE_NAME).delete(KEY)
: tx.objectStore(STORE_NAME).put(state, KEY);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
};
}
-342
View File
@@ -1,342 +0,0 @@
import type { ExecutionPlanNode } from '../../generated/common.js';
import type { PlanRun } from './types.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../../state/shops.js';
import type { PurchaseOfferResponse } from '../../generated/stores.js';
import type { BattlePassService } from '../../battlepass/BattlePassService.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
GetBattlePassProgressResponse,
PurchaseBattlePassPremiumResponse,
} from '../../generated/battlepass.js';
/** Resolves a store handle by slug — satisfied by the stores domain. */
interface StoreResolver {
getBySlug(slug: string): Promise<ShopHandle>;
}
// ---- ScenarioNodeContext ----
/**
* Context object passed to each session.
* Wraps the current run, plan node, and raw node data.
* The session calls `complete(handle)` to advance the DAG.
*/
export class ScenarioNodeContext {
constructor(
public readonly run: PlanRun,
public readonly node: ExecutionPlanNode,
private readonly onComplete: (runId: string, nodeId: string, handle: string) => Promise<void>,
private readonly onProgress: (runId: string, nodeId: string, counterKey: string, amount: number) => Promise<void>,
) {}
/** Extracts a typed value from node data. */
get<T>(key: string, defaultValue: T): T {
const data = this.node.data as Record<string, unknown> | undefined;
const value = data?.[key];
if (value === undefined || value === null) return defaultValue;
return value as unknown as T;
}
/** Full node data as a typed record. */
get data(): Record<string, unknown> {
return (this.node.data as Record<string, unknown>) ?? {};
}
/** Completes the current node with the given output handle. */
async complete(handle: string): Promise<void> {
await this.onComplete(this.run.runId, this.node.id!, handle);
}
/** Updates counter progress for server-managed scenario nodes. */
async updateProgress(counterKey: string, amount: number): Promise<void> {
await this.onProgress(this.run.runId, this.node.id!, counterKey, amount);
}
}
// ---- NotificationSession ----
export class NotificationSession {
public readonly title: string;
public readonly message: string;
constructor(private readonly context: ScenarioNodeContext) {
this.title = context.get('title', '');
this.message = context.get('message', '');
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async complete(): Promise<void> {
await this.context.complete('output');
}
}
// ---- WaitSession ----
export class WaitSession {
constructor(
private readonly context: ScenarioNodeContext,
public readonly deadlineUtc: Date,
) {}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
}
// ---- StoreSession ----
export class StoreSession {
private resolved = false;
constructor(
private readonly context: ScenarioNodeContext,
private readonly stores: StoreResolver,
) {}
get isResolved(): boolean {
return this.resolved;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async getStore(): Promise<ShopHandle> {
return this.stores.getBySlug(this.get('storeSlug', ''));
}
async buy(
offer: OfferHandle,
options?: BuyOptions,
): Promise<PurchaseOfferResponse> {
if (this.resolved) {
return { success: false, error: 'store session already resolved' };
}
const purchase = await offer.buy(options);
if (purchase.success) {
this.resolved = true;
await this.context.complete('onPurchase');
}
return purchase;
}
async decline(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onDecline');
}
}
// ---- LeaderboardSession ----
export class LeaderboardSession {
private resolved = false;
constructor(private readonly context: ScenarioNodeContext) {}
get isResolved(): boolean {
return this.resolved;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async end(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onEnd');
}
async rewardClaimed(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onRewardClaimed');
}
}
// ---- QuestSession ----
/**
* A scenario quest node. The game reports objective progress; the server
* auto-completes the node (crossing `onComplete`, which grants rewards and
* advances the run) once every objective is satisfied.
*/
export class QuestSession {
constructor(private readonly context: ScenarioNodeContext) {}
get name(): string {
return this.context.get('name', '');
}
get objectives(): Array<Record<string, unknown>> {
return this.context.get('objectives', [] as Array<Record<string, unknown>>);
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
/**
* Reports progress toward an objective. When the reported counter completes
* every objective, the server signals completion and the node crosses
* `onComplete` automatically.
*/
async reportProgress(objectiveId: string, amount = 1): Promise<void> {
await this.context.updateProgress(objectiveId, amount);
}
}
// ---- BattlePassSession ----
/**
* A scenario battlepass node. Exposes the battlepass operations (xp, premium,
* progress) bound to this node's scenario/node/run ids, plus explicit boundary
* crossings (`onLevelUp`, `onPremiumPurchase`) the game drives from its UI. The
* server validates each crossing (e.g. onLevelUp requires the level be reached).
*/
export class BattlePassSession {
constructor(
private readonly context: ScenarioNodeContext,
private readonly battlePass: BattlePassService,
) {}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
private ids(): { scenarioId: string; nodeId: string; runId: string } {
return {
scenarioId: this.context.run.scenarioId,
nodeId: this.context.node.id!,
runId: this.context.run.runId,
};
}
/** Current xp/level/premium/claimed-tiers for this node. */
getProgress(): Promise<GetBattlePassProgressResponse> {
const { scenarioId, nodeId } = this.ids();
return this.battlePass.getProgress(scenarioId, nodeId);
}
/** Credits XP from a configured source. */
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse> {
const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId });
}
/** Claims a tier reward at a reached level. */
claimReward(level: number, track?: string): Promise<ClaimBattlePassRewardResponse> {
const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId });
}
/** Purchases premium, then crosses `onPremiumPurchase` on success. */
async purchasePremium(): Promise<PurchaseBattlePassPremiumResponse> {
const { scenarioId, nodeId, runId } = this.ids();
const response = await this.battlePass.purchasePremium({
scenarioId,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
if (response.success) {
await this.context.complete('onPremiumPurchase');
}
return response;
}
/** Crosses `onLevelUp` (server validates the node's level was reached). */
async levelUp(): Promise<void> {
await this.context.complete('onLevelUp');
}
/** Ends the battlepass node via `onComplete`. */
async end(): Promise<void> {
await this.context.complete('onComplete');
}
}
// ---- BattlePassLevelSession ----
/**
* A scenario battlepass_level node — a single claimable tier. `claim()` crosses
* `onComplete`, which the server accepts only once the player has reached the
* node's configured level.
*/
export class BattlePassLevelSession {
constructor(private readonly context: ScenarioNodeContext) {}
get level(): number {
return this.context.get('levelNumber', 0);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
/** Claims this tier; crosses `onComplete` (server checks level reached). */
async claim(): Promise<void> {
await this.context.complete('onComplete');
}
}
-63
View File
@@ -1,63 +0,0 @@
import type { ExecutionPlan } from '../../generated/common.js';
/**
* Active node state within a running scenario plan.
* For wait nodes, this includes the deadline.
*/
export interface ActiveNodeState {
nodeId: string;
waitDeadlineUtc?: string; // ISO 8601 string, set for wait nodes
}
/**
* A running scenario plan instance — tracks active nodes and completed handles.
*/
export class RuntimeRun {
public readonly activeNodes = new Map<string, ActiveNodeState>();
public readonly completedHandles = new Set<string>();
/**
* Number of node transitions currently in flight for this run.
* A transition begins when a node starts completing and ends once its
* successors have been activated. While > 0 the run must not be considered
* complete, even if `activeNodes` is transiently empty (e.g. an
* auto-completing remote_config_override node between two client nodes).
*/
public pendingTransitions = 0;
constructor(
public readonly runId: string,
public readonly plan: ExecutionPlan,
) {}
}
/** Serialization format for a persisted run. */
export interface PersistedRun {
runId: string;
plan: ExecutionPlan;
activeNodes: ActiveNodeState[];
completedHandles: string[];
}
/** Top-level serialization format for all active runs. */
export interface PersistedState {
runs: PersistedRun[];
}
/** Read-only snapshot of a running plan, surfaced in events. */
export class PlanRun {
constructor(
public readonly runId: string,
public readonly planId: string,
public readonly scenarioId: string,
public readonly userId: string,
public readonly activeNodeIds: readonly string[],
public readonly plan: ExecutionPlan,
) {}
}
/** Failure event payload. */
export interface ScenarioRunFailedEvent {
run: PlanRun;
nodeId: string;
error: Error;
}
-16
View File
@@ -23,22 +23,6 @@ export class RemoteConfigState<
if (!config || config.value == null) return defaultValue; if (!config || config.value == null) return defaultValue;
return parseValue(config.value, config.valueType, defaultValue); return parseValue(config.value, config.valueType, defaultValue);
} }
/**
* Applies a scenario remote_config_override patch to the current snapshot.
*
* @internal
*/
applyOverride(key: string, value: string, valueType = 'json'): void {
const next = new Map(this.data ?? []);
next.set(key, {
key,
value,
valueType,
active: true,
} as RemoteConfig);
this.set(next);
}
} }
function parseValue<T>( function parseValue<T>(
+7 -2
View File
@@ -16,12 +16,13 @@ export interface BuyOptions {
*/ */
export type PurchaseOffer = ( export type PurchaseOffer = (
storeSlug: string, storeSlug: string,
offerId: string, offerSlug: string,
options?: BuyOptions, options?: BuyOptions,
) => Promise<PurchaseOfferResponse>; ) => Promise<PurchaseOfferResponse>;
export class OfferHandle { export class OfferHandle {
public readonly id: string; public readonly id: string;
public readonly slug: string;
public readonly name?: string; public readonly name?: string;
public readonly price?: OfferPrice; public readonly price?: OfferPrice;
public readonly contents: OfferContent[]; public readonly contents: OfferContent[];
@@ -35,7 +36,11 @@ export class OfferHandle {
if (!offer.id) { if (!offer.id) {
throw new Error('Rudder Stores: offer.id is required'); throw new Error('Rudder Stores: offer.id is required');
} }
if (!offer.slug) {
throw new Error('Rudder Stores: offer.slug is required');
}
this.id = offer.id; this.id = offer.id;
this.slug = offer.slug;
this.name = offer.name; this.name = offer.name;
this.price = offer.price; this.price = offer.price;
this.contents = offer.contents ?? []; this.contents = offer.contents ?? [];
@@ -43,7 +48,7 @@ export class OfferHandle {
} }
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> { async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
return this.purchase(this.storeSlug, this.id, options); return this.purchase(this.storeSlug, this.slug, options);
} }
} }
+2 -3
View File
@@ -30,7 +30,7 @@ describe('AuthService', () => {
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' }); 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]; const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/sdk/v1/authorization/device'); expect(url).toContain('/sdk/v1/authorization/device');
const body = JSON.parse(init.body); const body = JSON.parse(init.body);
@@ -46,10 +46,9 @@ describe('AuthService', () => {
'/sdk/v1/player/information', '/sdk/v1/player/information',
'/sdk/v1/stores', '/sdk/v1/stores',
'/sdk/v1/catalog', '/sdk/v1/catalog',
'/sdk/v1/scenarios/pending',
'/sdk/v1/scenarios/trigger', '/sdk/v1/scenarios/trigger',
// The login event invalidates the warmed profile → refetch.
'/sdk/v1/player/information', '/sdk/v1/player/information',
// Sync engine baseline poll, fired right after login.
'/sdk/v1/sync', '/sdk/v1/sync',
]); ]);
}); });
+2 -2
View File
@@ -64,7 +64,7 @@ describe('public API surface', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
expect(client.effects).toBeDefined(); expect(client.effects).toBeDefined();
@@ -75,7 +75,7 @@ describe('public API surface', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
expect(client.remoteConfig).toBeDefined(); expect(client.remoteConfig).toBeDefined();
+10 -10
View File
@@ -148,7 +148,7 @@ describe('lazy scenario runtime', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
projectKey: 'proj_123', projectKey: 'proj_123',
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
const internals = client as unknown as { const internals = client as unknown as {
scenarioRuntime: unknown; scenarioRuntime: unknown;
@@ -163,7 +163,7 @@ describe('lazy scenario runtime', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
projectKey: 'proj_123', projectKey: 'proj_123',
runtime: { planStateStore: null }, // default loginEvent: player_login runtime: {},
}); });
const notifications: string[] = []; const notifications: string[] = [];
@@ -182,18 +182,18 @@ describe('lazy scenario runtime', () => {
} }
if (path === '/sdk/v1/scenarios/trigger') { if (path === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({ return Promise.resolve(new Response(JSON.stringify({
plans: [{ effects: [{
planId: 'plan-1',
scenarioId: 'scenario-1',
userId: 'user-1',
startNodeId: 'start',
runId: 'run-1', runId: 'run-1',
nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }], scenarioSlug: 'scenario-1',
edges: [], nodeId: 'start',
boundaryNodes: [], type: 'notification',
data: { message: 'Welcome!' },
}], }],
}), { status: 200 })); }), { status: 200 }));
} }
if (path === '/sdk/v1/scenarios/pending') {
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 }));
}
if (path === '/sdk/v1/remote-configs') { if (path === '/sdk/v1/remote-configs') {
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 })); return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
} }
+256 -368
View File
@@ -2,43 +2,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ScenarioService } from '../src/scenario/ScenarioService.js'; import { ScenarioService } from '../src/scenario/ScenarioService.js';
import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js'; import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js';
import { createFakeTokenStore } from './helpers/FakeTokenStore.js'; import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge } from '../src/generated/common.js'; import { effect } from './helpers/plan.js';
import { NotificationSession, StoreSession, WaitSession, LeaderboardSession } from '../src/scenario/engine/sessions.js'; import { RudderHttpError } from '../src/client/RudderError.js';
import type { NotificationEffect, StoreOfferEffect, WaitEffect } from '../src/index.js';
/** Builds a simple execution plan with the given nodes and edges. */ import type { PendingEffect } from '../src/generated/scenarios.js';
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 };
}
async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> { async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-key', projectKey: 'test-key',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null }, // Disable IndexedDB for tests runtime: { loginEvent: null },
}); });
return { client, scenarios: await getScenarioRuntime(client) }; 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', () => { describe('ScenarioService', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('crypto', { vi.stubGlobal('crypto', {
@@ -46,119 +37,100 @@ describe('ScenarioService', () => {
}); });
}); });
describe('send (trigger)', () => { afterEach(() => {
it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => { vi.useRealTimers();
const { client, scenarios } = await createClientWithScenario(); vi.unstubAllGlobals();
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue( describe('send (trigger)', () => {
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => {
)); const { client, scenarios } = await createClientWithScenario();
const onNotification = vi.fn(); 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'); const response = await scenarios.send('level_complete');
expect(response.plans).toHaveLength(1); expect(response.effects).toHaveLength(1);
expect(scenarios.isRunning).toBe(true); expect(scenarios.isRunning).toBe(true);
expect(onNotification).toHaveBeenCalledOnce(); 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 () => { it('notification node fires onNotification', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn(); const onNotif = vi.fn();
scenarios.onNotification = onNotif; client.effects.onNotification(onNotif);
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
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'); await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce(); expect(onNotif).toHaveBeenCalledOnce();
}); });
it('store node fires onStore', async () => { it('store node fires onStoreOffer', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn(); const onStore = vi.fn();
scenarios.onStore = onStore; client.effects.onStoreOffer(onStore);
stubFetch((path) => {
const plan = makePlan({ if (path === '/sdk/v1/scenarios/trigger') {
nodes: [makeNode('start', 'store')], return jsonResponse({
effects: [effect('store', { storeSlug: 'starter', message: 'Buy!' })],
});
}
if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] });
}
return jsonResponse({});
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onStore).toHaveBeenCalledOnce(); await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
expect(onStore.mock.calls[0][0]).toBeInstanceOf(StoreSession); 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 () => { it('wait node fires onWait', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn(); const onWait = vi.fn();
scenarios.onWait = onWait; client.effects.onWait(onWait);
const deadline = new Date(Date.now() + 60_000).toISOString();
const plan = makePlan({ stubFetch(() => jsonResponse({
nodes: [makeNode('start', 'wait', { duration: 5, unit: 'minutes' })], effects: [effect('wait', {}, { waitDeadline: deadline })],
}); }));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce(); 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 () => { it('leaderboard node fires onLeaderboard', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onLb = vi.fn(); const onLb = vi.fn();
scenarios.onLeaderboard = onLb; client.effects.onLeaderboard(onLb);
stubFetch(() => jsonResponse({ effects: [effect('leaderboard')] }));
const plan = makePlan({
nodes: [makeNode('start', 'leaderboard')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onLb).toHaveBeenCalledOnce(); 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 { client, scenarios } = await createClientWithScenario();
const onQuest = vi.fn(); const onQuest = vi.fn();
client.effects.onQuest(onQuest); client.effects.onQuest(onQuest);
stubFetch(() => jsonResponse({
const plan = makePlan({ effects: [effect('quest', { name: 'Daily', objectives: [{ objectiveId: 'kills', target: 10 }] })],
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 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onQuest).toHaveBeenCalledOnce(); expect(onQuest).toHaveBeenCalledOnce();
expect(onQuest.mock.calls[0][0].name).toBe('Daily'); expect(onQuest.mock.calls[0][0].name).toBe('Daily');
// Active and waiting for progress — not stalled, not failed.
expect(scenarios.isRunning).toBe(true); expect(scenarios.isRunning).toBe(true);
}); });
@@ -166,14 +138,7 @@ describe('ScenarioService', () => {
const { client, scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onBp = vi.fn(); const onBp = vi.fn();
client.effects.onBattlePass(onBp); client.effects.onBattlePass(onBp);
stubFetch(() => jsonResponse({ effects: [effect('battlepass', { premiumPrice: 100 })] }));
const plan = makePlan({
nodes: [makeNode('start', 'battlepass', { premiumPrice: 100 })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onBp).toHaveBeenCalledOnce(); expect(onBp).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(true); expect(scenarios.isRunning).toBe(true);
@@ -183,346 +148,269 @@ describe('ScenarioService', () => {
const { client, scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onLevel = vi.fn(); const onLevel = vi.fn();
client.effects.onBattlePassLevel(onLevel); client.effects.onBattlePassLevel(onLevel);
stubFetch(() => jsonResponse({
const plan = makePlan({ effects: [effect('battlepass_level', { levelNumber: 3 })],
nodes: [makeNode('start', 'battlepass_level', { levelNumber: 3 })], }));
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onLevel).toHaveBeenCalledOnce(); expect(onLevel).toHaveBeenCalledOnce();
expect(onLevel.mock.calls[0][0].level).toBe(3); 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 () => { it('unsupported node type fails the run and surfaces onScenarioFailed', async () => {
const { client, scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const onFailed = vi.fn(); const onFailed = vi.fn();
client.effects.onScenarioFailed(onFailed); client.effects.onScenarioFailed(onFailed);
stubFetch(() => jsonResponse({ effects: [effect('unknown_type')] }));
const plan = makePlan({
nodes: [makeNode('start', 'unknown_type')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(warn).toHaveBeenCalledWith( expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Unsupported scenario node type'), expect.stringContaining('Unsupported scenario node type'),
); );
// The run must NOT stall: it fails and the game is notified.
expect(onFailed).toHaveBeenCalledOnce(); expect(onFailed).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(false); expect(scenarios.isRunning).toBe(false);
warn.mockRestore(); warn.mockRestore();
}); });
}); });
describe('DAG traversal', () => { describe('callback continuation', () => {
it('completing a node follows matching edges', async () => { it('completing an effect posts callback and emits the next effect', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn(); 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) stubFetch((path, method) => {
const plan = makePlan({ if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
nodes: [ return jsonResponse({ effects: [first] });
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 }));
} }
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'); await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce(); // start node dispatched expect(onNotif).toHaveBeenCalledOnce();
const session = onNotif.mock.calls[0][0] as NotificationEffect;
// Complete the notification node with handle "output" await session.done();
const session = onNotif.mock.calls[0][0] as NotificationSession;
await session.complete();
expect(onNotif).toHaveBeenCalledTimes(2); expect(onNotif).toHaveBeenCalledTimes(2);
expect(onNotif.mock.calls[1][0].message).toBe('two');
const fetchMock = vi.mocked(fetch);
const callbackCall = fetchMock.mock.calls.find((call) => {
const url = String(call[0]);
return url.includes('/sdk/v1/scenarios/callback');
});
expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({
scenarioSlug: 'scenario-1',
nodeId: 'n1',
handle: 'output',
runId: 'run-1',
});
}); });
it('completing a node with already-completed handle is idempotent', async () => { it('completing the last effect emits onScenarioCompleted', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onNotif = vi.fn(); const onNotif = vi.fn();
scenarios.onNotification = onNotif; client.effects.onScenarioCompleted(onCompleted);
client.effects.onNotification(onNotif);
const plan = makePlan({ stubFetch((path) => {
nodes: [ if (path === '/sdk/v1/scenarios/trigger') {
makeNode('start', 'notification'), return jsonResponse({ effects: [effect('notification')] });
makeNode('node2', 'notification'), }
], return jsonResponse({});
edges: [makeEdge('start', 'output', 'node2')],
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(scenarios.activeRuns).toHaveLength(1); await (onNotif.mock.calls[0][0] as NotificationEffect).done();
expect(onCompleted).toHaveBeenCalledOnce();
const session = onNotif.mock.calls[0][0] as NotificationSession; expect(onCompleted.mock.calls[0][0]).toEqual({
await session.complete(); runId: 'run-1',
scenarioSlug: 'scenario-1',
// node2 should now be active });
expect(scenarios.activeRuns).toHaveLength(1);
// Complete node2
const session2 = onNotif.mock.calls[1][0] as NotificationSession;
await session2.complete();
// Run should be completed
expect(scenarios.isRunning).toBe(false); 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'); describe('pending dedup', () => {
const session = (scenarios as unknown as { onNotification?: (s: NotificationSession) => void }).onNotification?.( it('does not re-emit an effect with the same runId and nodeId', async () => {
// Use the mock calls to get the session const { client, scenarios } = await createClientWithScenario();
vi.mocked(vi.fn()).mock.calls[0]?.[0] as NotificationSession, const onNotification = vi.fn();
); client.effects.onNotification(onNotification);
// We need to get the session from the mock spy const pending = effect('notification', { message: 'once' });
// Actually let's trigger via respond() stubFetch(() => jsonResponse({ effects: [pending] }));
scenarios.respond('output'); await scenarios.send('first-event');
// Fire-and-forget — wait a tick await scenarios.send('second-event');
await new Promise((r) => setTimeout(r, 10)); expect(onNotification).toHaveBeenCalledTimes(1);
expect(scenarios.isRunning).toBe(true);
expect(onRunCompleted).toHaveBeenCalled();
expect(onCompleted).toHaveBeenCalled();
}); });
}); });
describe('wait nodes', () => { describe('waitDeadline timer', () => {
it('sets a deadline and fires onWait', async () => { it('polls pending at waitDeadline and emits the next effect', async () => {
const { scenarios } = await createClientWithScenario(); vi.useFakeTimers();
const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn(); const onWait = vi.fn();
scenarios.onWait = onWait; const onNotif = vi.fn();
client.effects.onWait(onWait);
client.effects.onNotification(onNotif);
const plan = makePlan({ const deadline = new Date(Date.now() + 5_000).toISOString();
nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })], 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'); await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce(); expect(onWait).toHaveBeenCalledOnce();
const session = onWait.mock.calls[0][0] as WaitSession; expect(onNotif).not.toHaveBeenCalled();
expect(session.deadlineUtc).toBeInstanceOf(Date);
// Deadline should be ~10 minutes from now. pending = [next];
const diff = session.deadlineUtc.getTime() - Date.now(); await vi.advanceTimersByTimeAsync(5_000);
expect(diff).toBeGreaterThan(9 * 60 * 1000); expect(onNotif).toHaveBeenCalledOnce();
expect(diff).toBeLessThan(11 * 60 * 1000); 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 () => { describe('expired-run drop', () => {
const { scenarios } = await createClientWithScenario(); it('drops the run on unknown_run and does not retry it', async () => {
const onWait = vi.fn(); const { client, scenarios } = await createClientWithScenario();
const onCompleted = vi.fn(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
scenarios.onWait = onWait; const onFailed = vi.fn();
scenarios.onCompleted = onCompleted; 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. stubFetch((path) => {
const plan = makePlan({ if (path === '/sdk/v1/scenarios/trigger') {
nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })], 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'); await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce(); expect(onNotif).toHaveBeenCalledOnce();
// Wait for the setTimeout(0) to fire. await expect((onNotif.mock.calls[0][0] as NotificationEffect).done())
await new Promise((r) => setTimeout(r, 50)); .rejects.toBeInstanceOf(RudderHttpError);
expect(onCompleted).toHaveBeenCalled(); 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', () => { describe('store session', () => {
it('buy() purchases the selected offer and completes with onPurchase', async () => { it('buy() purchases the selected offer and completes with onPurchase', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn(); const onStore = vi.fn();
scenarios.onStore = onStore; client.effects.onStoreOffer(onStore);
const plan = makePlan({ stubFetch((path, method) => {
nodes: [makeNode('start', 'store', { storeSlug: 'starter' })], if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
}); return jsonResponse({ effects: [effect('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 }));
} }
if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') { if (method === 'GET' && path === '/sdk/v1/stores/starter') {
return Promise.resolve(new Response(JSON.stringify({ return jsonResponse({
name: 'Starter', name: 'Starter',
slug: 'starter', slug: 'starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }], offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}), { status: 200 })); });
} }
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/stores/starter/offers/pack_1/purchase') { if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack-1/purchase') {
return Promise.resolve(new Response(JSON.stringify({ success: true, purchaseId: 'purchase-1' }), { status: 200 })); return jsonResponse({ success: true, purchaseId: 'purchase-1' });
} }
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); return jsonResponse({});
})); });
await scenarios.send('test'); await scenarios.send('test');
const session = onStore.mock.calls[0][0] as StoreSession; await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
const session = onStore.mock.calls[0][0] as StoreOfferEffect;
const onCompleted = vi.fn(); const purchase = await session.buy(session.offers[0]);
scenarios.onCompleted = onCompleted;
const store = await session.getStore();
const purchase = await session.buy(store.offers[0]);
expect(purchase.success).toBe(true); expect(purchase.success).toBe(true);
expect(session.isResolved).toBe(true);
// Second call should be a no-op. const duplicate = await session.buy(session.offers[0]);
const duplicate = await session.buy(store.offers[0]);
expect(duplicate.success).toBe(false); expect(duplicate.success).toBe(false);
}); });
it('decline() completes with onDecline', async () => { it('dismiss() completes with onDecline', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn(); const onStore = vi.fn();
scenarios.onStore = onStore; client.effects.onStoreOffer(onStore);
stubFetch((path) => {
const plan = makePlan({ if (path === '/sdk/v1/scenarios/trigger') {
nodes: [makeNode('start', 'store')], return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
}
if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1', slug: 'pack-1' }] });
}
return jsonResponse({});
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
const session = onStore.mock.calls[0][0] as StoreSession; await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
await session.decline(); await (onStore.mock.calls[0][0] as StoreOfferEffect).dismiss();
expect(session.isResolved).toBe(true); 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', () => { describe('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);
});
it('clear removes all runs', async () => { it('clear removes all runs', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
client.effects.onNotification(vi.fn());
const plan = makePlan({ stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(scenarios.isRunning).toBe(true); expect(scenarios.isRunning).toBe(true);
scenarios.clear(); scenarios.clear();
expect(scenarios.isRunning).toBe(false); expect(scenarios.isRunning).toBe(false);
}); });
}); });
describe('runId filtering', () => {
it('rejects plans with duplicate runId', async () => {
const { scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
scenarios.onNotification = onNotification;
const plan = makePlan({
runId: 'run-1',
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })),
));
// First send creates a run
await scenarios.send('first-event');
expect(scenarios.activeRuns).toHaveLength(1);
expect(onNotification).toHaveBeenCalledTimes(1);
// Second send with same runId is rejected by startPlan dedup
await scenarios.send('second-event');
expect(scenarios.activeRuns).toHaveLength(1);
expect(onNotification).toHaveBeenCalledTimes(1);
});
});
}); });
+14 -6
View File
@@ -9,11 +9,6 @@ import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js'; import type { SeedArtifact } from './types.js';
type RuntimeOptions = { type RuntimeOptions = {
planStateStore?: {
state: string | null;
load(): Promise<void>;
save(state: string | null): Promise<void>;
} | null;
loginEvent?: string | null; loginEvent?: string | null;
}; };
@@ -52,7 +47,7 @@ export function createInMemoryTokenStore(): TokenStore {
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */ /** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>( export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
artifact: SeedArtifact = loadArtifact(), artifact: SeedArtifact = loadArtifact(),
runtime: RuntimeOptions = { planStateStore: null, loginEvent: null }, runtime: RuntimeOptions = { loginEvent: null },
): RudderClient<TConfig> { ): RudderClient<TConfig> {
return new RudderClient<TConfig>({ return new RudderClient<TConfig>({
baseUrl: artifact.baseUrl, baseUrl: artifact.baseUrl,
@@ -79,6 +74,19 @@ export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
return api; return api;
} }
/** Credits a player's wallet through the admin batch endpoint. */
export async function grantCurrency(
artifact: SeedArtifact,
playerId: string,
currencyCode: string,
amount: number,
): Promise<void> {
await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/wallet/adjust?environment=${artifact.environment}`,
{ items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] },
);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */ /** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
+85 -58
View File
@@ -1,11 +1,10 @@
// Production seed: registers an operator, creates a project, configures staging // Production seed: registers an operator, creates a project, configures staging
// content (items, remote configs, leaderboard, store with free+paid offers, a // content (counter, item, remote configs, leaderboard, store with free+paid offers, a
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves. // global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
// Excludes realtime & analytics.
// //
// The scenario flow uses the SDK runtime's exact node `data` keys and handle names — // The scenario flow uses the server engine's node types, `data` keys and handle names —
// the game's plan builder copies node.data verbatim into the execution plan // the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs. // and .../fsm), so what we store is what the server runs.
import { createAdminClient, type AdminClient } from './adminClient.js'; import { createAdminClient, type AdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js'; import type { SeedArtifact } from './types.js';
@@ -20,6 +19,7 @@ interface ReleaseResponse {
interface QuestResponse { interface QuestResponse {
id: string; id: string;
slug: string;
name: string; name: string;
status: string; status: string;
objectives?: Array<{ id: string; metric: string; target: number }>; objectives?: Array<{ id: string; metric: string; target: number }>;
@@ -29,9 +29,10 @@ interface QuestResponse {
interface StoreResponse { interface StoreResponse {
id: string; id: string;
name: string; name: string;
data?: { slug?: string }; slug: string;
offers?: Array<{ offers?: Array<{
id: string; id: string;
slug: string;
name: string; name: string;
contents?: Array<{ itemId: string; amount: number }>; contents?: Array<{ itemId: string; amount: number }>;
}>; }>;
@@ -110,30 +111,43 @@ export async function runSeed(
api.setToken(auth.accessToken); api.setToken(auth.accessToken);
log(`registered ${adminEmail}`); log(`registered ${adminEmail}`);
// 2. Project. // 2. Project. Creating one provisions the staging + prod environments, each with
const project = await api.post<{ id: string; key: string; name: string }>( // its own SDK key — that key is what the runtime SDK sends as projectKey.
'/platform/v1/projects', const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
{ name: `e2e-${ts}` }, name: `e2e-${ts}`,
); });
const projPath = `/platform/v1/projects/${project.id}`; const projPath = `/platform/v1/projects/${project.id}`;
log(`project ${project.id} key=${project.key}`); const environments = await api.get<Array<{ name: string; sdkKey: string }>>(
`${projPath}/environments`,
// 3. Staging environment (idempotent — may already exist). );
try { const projectKey = environments.find((e) => e.name === runtimeEnv)?.sdkKey;
await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv }); if (!projectKey) {
} catch { throw new Error(`project ${project.id} has no ${runtimeEnv} environment sdk key`);
// already exists / not required — content POSTs below are the real test
} }
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
// 3. Soft currency counter — offer prices are validated against counter slugs, and
// the purchase debit resolves the currency through the catalog counter cache.
const currency = 'soft';
await api.post(`${projPath}/counters${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Soft Currency',
slug: currency,
kind: 'currency',
});
// 4. Item (granted as a paid offer's contents). // 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, { const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
name: 'Gold Coin', name: 'Gold Coin',
tags: ['currency'], slug: 'gold-coin',
}); });
// 5. Remote configs — one per value type. // 5. Remote configs — one per value type. RCs are live per-env flags served
// straight from the cache and are not copied by a promote, so they go directly
// into the runtime env.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [ const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' }, { key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' }, { key: 'spawn_rate', value: '1.5', valueType: 'float' },
@@ -142,9 +156,9 @@ export async function runSeed(
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' }, { key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
]; ];
for (const rc of remoteConfigs) { for (const rc of remoteConfigs) {
await api.post(`${projPath}/remote-configs${q}`, { await api.post(`${projPath}/remote-configs${prodQ}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: runtimeEnv,
...rc, ...rc,
}); });
} }
@@ -163,43 +177,44 @@ export async function runSeed(
}); });
// 7. Store with a free offer and a paid (currency) offer with a purchase limit. // 7. Store with a free offer and a paid (currency) offer with a purchase limit.
// The runtime resolves a store's slug from data.slug (liveops-game shop cache), and // Stores and offers carry an authored slug that survives promotes.
// the CreateStore API has no slug field — so we set it via data.
const storeSlug = 'starter-shop'; const storeSlug = 'starter-shop';
const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>( const paidOfferSlug = 'gold-pack';
`${projPath}/stores${q}`, const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
{
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
slug: storeSlug,
name: 'Starter Shop', name: 'Starter Shop',
description: 'E2E shop', description: 'E2E shop',
data: { slug: storeSlug },
offers: [ offers: [
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } },
{ {
slug: 'welcome-gift',
name: 'Welcome Gift',
contents: [{ itemId: item.id, amount: 10 }],
price: { currency, amount: 0 },
},
{
slug: paidOfferSlug,
name: 'Gold Pack', name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }], contents: [{ itemId: item.id, amount: 50 }],
price: { currency: 'soft', amount: 100 }, price: { currency, amount: 100 },
maxPurchases: 2, maxPurchases: 2,
}, },
], ],
}, });
);
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`); log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack');
if (!paidOffer?.id) {
throw new Error('seeded paid offer did not return an id');
}
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward. // 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
const questName = 'Buy Gold Pack Quest'; const questName = 'Buy Gold Pack Quest';
const questObjectiveId = 'buy-gold-pack'; const questObjectiveId = 'buy-gold-pack';
const questTarget = 1; const questTarget = 1;
const questMetric = `purchase.offer:${paidOffer.id}`; const questSlug = 'buy-gold-pack-quest';
const questMetric = `purchase.offer:${paidOfferSlug}`;
const questRewardAmount = 7; const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, { const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
slug: questSlug,
name: questName, name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }], objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }], rewards: [{ itemId: item.id, amount: questRewardAmount }],
@@ -209,35 +224,42 @@ export async function runSeed(
// 9. Scenario (must be live now: startAt <= now <= endAt). // 9. Scenario (must be live now: startAt <= now <= endAt).
const startAt = new Date(ts - 3600_000).toISOString(); const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString(); const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenarioEvent = 'session_start';
const scenarioSlug = 'onboarding';
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, { const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
slug: scenarioSlug,
name: 'Onboarding', name: 'Onboarding',
description: 'e2e onboarding', description: 'e2e onboarding',
startAt, startAt,
endAt, endAt,
}); });
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification). // 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}`, { await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
projectId: project.id, projectId: project.id,
scenarioId: scenario.id,
flow: buildFlow(storeSlug), flow: buildFlow(storeSlug),
}); });
log(`scenario ${scenario.id} flow set`); log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves prod/latest.json). // 11. Promote staging content to prod (the runtime serves the prod snapshot).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`); const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id,
fromEnvironment: authoringEnv,
toEnvironment: runtimeEnv,
});
await pollRelease(api, projPath, runtimeEnv, release.id, log); await pollRelease(api, projPath, runtimeEnv, release.id, log);
// Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`); const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop'); const prodStore = prodStores.find((s) => s.slug === storeSlug);
if (!prodStore) { if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`); throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
} }
const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack'); const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
if (!prodPaidOffer?.id) { if (!prodPaidOffer?.slug) {
throw new Error('promoted paid offer did not return an id'); throw new Error('promoted paid offer did not return a slug');
} }
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId; const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) { if (!prodRewardItemId) {
@@ -245,26 +267,31 @@ export async function runSeed(
} }
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`); const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
const prodQuest = prodQuests.find((q) => q.name === questName); const prodQuest = prodQuests.find((qq) => qq.slug === questSlug);
if (!prodQuest?.id) { if (!prodQuest?.slug) {
throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`); throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`);
} }
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric; const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) { if (!prodQuestMetric) {
throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`); throw new Error(`promoted quest ${questSlug} did not return metric ${questObjectiveId}`);
} }
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId; const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
if (!prodQuestRewardItemId) { if (!prodQuestRewardItemId) {
throw new Error(`promoted quest ${questName} did not return reward item id`); throw new Error(`promoted quest ${questSlug} did not return reward item id`);
} }
// 12. Settle: let the game service hot-reload caches from the new snapshot. const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
await sleep(3000); `${projPath}/scenarios${prodQ}`,
);
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
if (!prodScenario?.slug) {
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
}
return { return {
baseUrl, baseUrl,
projectId: project.id, projectId: project.id,
projectKey: project.key, projectKey,
adminToken: auth.accessToken, adminToken: auth.accessToken,
adminEmail, adminEmail,
environment: runtimeEnv, environment: runtimeEnv,
@@ -275,14 +302,14 @@ export async function runSeed(
slug: storeSlug, slug: storeSlug,
freeOfferName: 'Welcome Gift', freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack', paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id, paidOfferSlug: prodPaidOffer.slug,
paidPrice: { currency: 'soft', amount: 100 }, paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2, paidMaxPurchases: 2,
grantedItemName: 'Gold Coin', grantedItemName: 'Gold Coin',
paidGrantAmount: 50, paidGrantAmount: 50,
}, },
quest: { quest: {
id: prodQuest.id, slug: prodQuest.slug,
name: questName, name: questName,
objectiveId: questObjectiveId, objectiveId: questObjectiveId,
metric: prodQuestMetric, metric: prodQuestMetric,
@@ -300,7 +327,7 @@ export async function runSeed(
shopLayout: { cols: 3 }, shopLayout: { cols: 3 },
spawnRateOverride: 3.0, spawnRateOverride: 3.0,
}, },
scenario: { id: scenario.id, event: 'session_start' }, scenario: { slug: prodScenario.slug, event: scenarioEvent },
}; };
} }
+3 -3
View File
@@ -16,14 +16,14 @@ export interface SeedArtifact {
slug: string; slug: string;
freeOfferName: string; freeOfferName: string;
paidOfferName: string; paidOfferName: string;
paidOfferId: string; paidOfferSlug: string;
paidPrice: { currency: string; amount: number }; paidPrice: { currency: string; amount: number };
paidMaxPurchases: number; paidMaxPurchases: number;
grantedItemName: string; grantedItemName: string;
paidGrantAmount: number; paidGrantAmount: number;
}; };
quest: { quest: {
id: string; slug: string;
name: string; name: string;
objectiveId: string; objectiveId: string;
metric: string; metric: string;
@@ -42,5 +42,5 @@ export interface SeedArtifact {
/** spawn_rate value the scenario's remote_config_override applies. */ /** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number; spawnRateOverride: number;
}; };
scenario: { id: string; event: string }; scenario: { slug: string; event: string };
} }
+5 -6
View File
@@ -15,12 +15,11 @@ describe('e2e-prod: health', () => {
expect(body.status).toBe('ok'); expect(body.status).toBe('ok');
}); });
it('content + game gRPC backends are serving', async () => { it('GET /readyz reports every backing store ready', async () => {
// /readyz may be 503 if analytics is not_serving (out of scope here); assert the
// backends this suite actually uses are serving.
const res = await fetch(`${baseUrl}/readyz`); const res = await fetch(`${baseUrl}/readyz`);
const body = (await res.json()) as { grpc?: Record<string, string> }; expect(res.ok).toBe(true);
expect(body.grpc?.content).toBe('serving'); const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
expect(body.grpc?.game).toBe('serving'); expect(body.status).toBe('ready');
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
}); });
}); });
+4 -8
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from 'vitest'; import { describe, it, expect, beforeAll } from 'vitest';
import { adminApi, freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js'; import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js'; import type { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js'; import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.js'; import type { OfferHandle } from '../../src/state/shops.js';
@@ -48,12 +48,8 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
const price = artifact.store.paidPrice.amount; const price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client); const offer = await resolvePaidOffer(client);
// 1. Grant currency via the new admin endpoint. // 1. Grant currency via the admin endpoint.
await api.post(`/game/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, { await grantCurrency(artifact, playerId, currency, 1000);
currencyCode: currency,
amount: 1000,
reason: 'e2e grant',
});
const granted = await withReadRetry( const granted = await withReadRetry(
() => balance(client, currency), () => balance(client, currency),
(b) => b === 1000, (b) => b === 1000,
@@ -68,7 +64,7 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
// 3. Inventory credited (verified via admin player details). // 3. Inventory credited (verified via admin player details).
const details = await api.get<AdminPlayerDetails>( const details = await api.get<AdminPlayerDetails>(
`/game/v1/projects/${artifact.projectId}/players/${playerId}`, `/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`,
); );
const inv = details.inventory?.find((i) => i.slug === artifact.item.id); const inv = details.inventory?.find((i) => i.slug === artifact.item.id);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount); expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
+13 -5
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, vi } from 'vitest'; import { describe, it, expect, beforeAll, vi } from 'vitest';
import { loadArtifact, makeProdClient } from './_setup/harness.js'; import { grantCurrency, loadArtifact, makeProdClient } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js'; import type { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js'; import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.js'; import { getScenarioRuntime } from '../../src/client/RudderClient.js';
@@ -16,7 +16,6 @@ describe('e2e-prod: scenarios', () => {
let runCompleted = false; let runCompleted = false;
const client = makeProdClient(artifact, { const client = makeProdClient(artifact, {
planStateStore: null,
loginEvent: artifact.scenario.event, loginEvent: artifact.scenario.event,
}); });
@@ -25,17 +24,26 @@ describe('e2e-prod: scenarios', () => {
storeOffer = effect; storeOffer = effect;
}); });
const runtime = await getScenarioRuntime(client); const runtime = await getScenarioRuntime(client);
runtime.onRunCompleted = () => { client.effects.onScenarioCompleted(() => {
runCompleted = true; runCompleted = true;
}; });
await client.auth.loginWithDevice({ region: 'en', language: 'en' }); await client.auth.loginWithDevice({ region: 'en', language: 'en' });
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
const playerId = (await client.player.reload()).player!.id!;
await grantCurrency(
artifact,
playerId,
artifact.store.paidPrice.currency,
artifact.store.paidPrice.amount,
);
// First notification dispatched. // First notification dispatched.
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 }); await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done(); await notifications[0].done();
// remote_config_override applies, then the store offer surfaces.
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 }); await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
await client.remoteConfig.reload();
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo( expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
artifact.remoteConfigs.spawnRateOverride, artifact.remoteConfigs.spawnRateOverride,
); );
+51 -84
View File
@@ -3,44 +3,17 @@ import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.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 { StoreOfferEffect } from '../../src/index.js';
import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js';
function makeClient(): RudderClient { function makeClient(): RudderClient {
return new RudderClient({ return new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), 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)', () => { describe('E2E: boundary nodes (server-side continuation)', () => {
let gateway: FakeGateway; let gateway: FakeGateway;
let client: RudderClient; let client: RudderClient;
@@ -51,7 +24,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
stores: [{ stores: [{
slug: 'starter', slug: 'starter',
name: 'Starter', name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }], offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}], }],
}); });
gateway.install(); gateway.install();
@@ -61,99 +34,93 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
it('purchase crosses the boundary → callback fires and continues the scenario', async () => { it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
gateway.onEvent('player_login', offerWithBoundary()); const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
gateway.onCallback('offer', 'onPurchase', rewardContinuation()); scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reward',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onPurchase', reward);
const messages: string[] = []; const messages: string[] = [];
const completed: PlanRun[] = []; const completed: string[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onNotification((e) => { messages.push(e.message); });
client.effects.onNotification((effect) => { messages.push(effect.message); }); client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioSlug); });
runtime.onRunCompleted = (r) => completed.push(r);
const token = (await client.auth.loginWithDevice()).accessToken; const token = (await client.auth.loginWithDevice()).accessToken;
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]); 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'); const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' }); expect(callback?.body).toEqual({
scenarioSlug: 'offer_flow',
nodeId: 'offer',
handle: 'onPurchase',
runId: 'offer_flow-run',
});
expect(callback?.authToken).toBe(token); 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).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('Maybe next time!'); expect(messages).not.toContain('Maybe next time!');
expect(completed).toEqual([]);
// 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']);
}); });
it('decline stays local → no callback, local edge is followed', async () => { it('decline posts callback and continues with the server-supplied next effect', async () => {
gateway.onEvent('player_login', offerWithBoundary()); const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'consolation',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onDecline', consolation);
const messages: string[] = []; const messages: string[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((effect) => { messages.push(effect.message); }); client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss(); 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!']); 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 () => { it('a failing callback does not fail or crash the run', async () => {
gateway.onEvent('player_login', offerWithBoundary()); const offerEffect = effect('store', { storeSlug: 'starter' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallbackError('offer', 'onPurchase', 500); gateway.onCallbackError('offer', 'onPurchase', 500);
const failures: ScenarioRunFailedEvent[] = []; const failures: unknown[] = [];
const completed: PlanRun[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client); const runtime = await getScenarioRuntime(client);
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
runtime.onRunFailed = (e) => failures.push(e); client.effects.onScenarioFailed((e) => { failures.push(e); });
runtime.onRunCompleted = (r) => completed.push(r);
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined()); 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 }); await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
expect(failures).toHaveLength(0); // 500 is transient — no terminal failure expect(failures).toHaveLength(0);
// The run stays pending because the boundary call failed transiently.
// On reconnection the consumer can retry the purchase completion.
expect(runtime.isRunning).toBe(true); expect(runtime.isRunning).toBe(true);
}); });
}); });
+57 -41
View File
@@ -3,7 +3,7 @@ import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.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'; import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
const MINUTE = 60_000; const MINUTE = 60_000;
@@ -13,28 +13,38 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null }, // exercised separately in the persistence test
}); });
} }
/** function offerScenario(now: number) {
* The offer scenario both branches share: const waitIntro = effect('wait', {}, {
* scenarioSlug: 'offer_flow',
* login → wait 1m → offer ──onDecline──→ wait 1m → "still available" runId: 'offer_flow-run',
* └─onPurchase─→ "thanks for your purchase" nodeId: 'wait_intro',
*/ waitDeadline: new Date(now + MINUTE).toISOString(),
function offerScenario() { });
return plan('offer_flow') const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, {
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' }) scenarioSlug: 'offer_flow',
.node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }) runId: 'offer_flow-run',
.node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' }) nodeId: 'offer',
.node('reminder', 'notification', { message: 'Your offer is still available!' }) });
.node('thanks', 'notification', { message: 'Thanks for your purchase!' }) const waitReminder = effect('wait', {}, {
.edge('wait_intro', 'onComplete', 'offer') scenarioSlug: 'offer_flow',
.edge('offer', 'onDecline', 'wait_reminder') runId: 'offer_flow-run',
.edge('wait_reminder', 'onComplete', 'reminder') nodeId: 'wait_reminder',
.edge('offer', 'onPurchase', 'thanks') waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
.build(); });
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
return { waitIntro, offer, waitReminder, reminder, thanks };
} }
describe('E2E: player offer journey', () => { describe('E2E: player offer journey', () => {
@@ -48,7 +58,7 @@ describe('E2E: player offer journey', () => {
stores: [{ stores: [{
slug: 'starter', slug: 'starter',
name: 'Starter', name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }], offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}], }],
}); });
gateway.install(); gateway.install();
@@ -66,43 +76,41 @@ describe('E2E: player offer journey', () => {
expect(res.accessToken).toBeTruthy(); expect(res.accessToken).toBeTruthy();
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken); 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'); 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).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy(); 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'); const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
expect(trigger?.authToken).toBe(res.accessToken); expect(trigger?.authToken).toBe(res.accessToken);
}); });
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => { 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[] = []; const notifications: NotificationEffect[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((effect) => { notifications.push(effect); }); client.effects.onNotification((e) => { notifications.push(e); });
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
// Immediately after login the player is just waiting — no offer yet.
expect(offer).toBeUndefined(); expect(offer).toBeUndefined();
// The offer must NOT appear before the full minute has elapsed...
await vi.advanceTimersByTimeAsync(MINUTE - 1_000); await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(offer).toBeUndefined(); expect(offer).toBeUndefined();
// ...and surfaces exactly when the minute is up.
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!'); expect(offer!.message).toBe('Limited starter pack!');
// Player declines → the DAG moves to the reminder wait, no notification yet.
await offer!.dismiss(); await offer!.dismiss();
expect(notifications).toHaveLength(0); expect(notifications).toHaveLength(0);
// The reminder also honours the full minute, not a moment sooner.
await vi.advanceTimersByTimeAsync(MINUTE - 1_000); await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(notifications).toHaveLength(0); expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_000); 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 () => { 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; let offer: StoreOfferEffect | undefined;
const notifications: NotificationEffect[] = []; const notifications: NotificationEffect[] = [];
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((effect) => { notifications.push(effect); }); client.effects.onNotification((e) => { notifications.push(e); });
const accessToken = (await client.auth.loginWithDevice()).accessToken; const accessToken = (await client.auth.loginWithDevice()).accessToken;
await vi.advanceTimersByTimeAsync(MINUTE); await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
// The game buys a selected offer; the session advances only after success.
const selectedOffer = offer!.offers[0]; const selectedOffer = offer!.offers[0];
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' }); const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
expect(purchase.success).toBe(true); expect(purchase.success).toBe(true);
// The purchase hit the gateway with idempotency key + bearer token.
expect(gateway.purchases).toEqual([ expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken }, { storeSlug: 'starter', offerSlug: 'pack-1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]); ]);
// The scenario took the onPurchase branch → "thanks", and the run finished.
expect(notifications).toHaveLength(1); expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Thanks for your purchase!'); expect(notifications[0].message).toBe('Thanks for your purchase!');
}); });
it('declining does NOT take the purchase branch (handles are isolated)', async () => { 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[] = []; const messages: string[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((effect) => { messages.push(effect.message); }); client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
await vi.advanceTimersByTimeAsync(MINUTE); await vi.advanceTimersByTimeAsync(MINUTE);
+32 -34
View File
@@ -2,39 +2,44 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js'; import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { createMemoryPlanStore, stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js'; import { effect } from '../helpers/plan.js';
import type { StoreSession, WaitSession } from '../../src/scenario/engine/sessions.js'; import type { StoreOfferEffect, WaitEffect } from '../../src/index.js';
const MINUTE = 60_000; const MINUTE = 60_000;
describe('E2E: scenario state survives a reload', () => { describe('E2E: pending effects resume after a new login', () => {
let gateway: FakeGateway; 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({ return new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: createMemoryPlanStore(backing), loginEvent: null }, runtime: { loginEvent: null },
}); });
} }
beforeEach(() => { beforeEach(() => {
stubDeterministicUuid(); stubDeterministicUuid();
vi.useFakeTimers(); vi.useFakeTimers();
backing.value = null; gateway = createFakeGateway({
gateway = createFakeGateway(); stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] }],
gateway.onEvent( });
'session_start', const now = Date.now();
plan('offer_flow') const wait = effect('wait', {}, {
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' }) scenarioSlug: 'offer_flow',
.node('offer', 'store', { message: 'Limited starter pack!' }) runId: 'offer_flow-run',
.edge('wait_intro', 'onComplete', 'offer') nodeId: 'wait_intro',
.build(), waitDeadline: new Date(now + MINUTE).toISOString(),
); });
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('session_start', wait);
gateway.onCallback('wait_intro', 'onComplete', offer);
gateway.install(); gateway.install();
}); });
@@ -43,33 +48,26 @@ describe('E2E: scenario state survives a reload', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('a wait started before reload resumes and fires the offer after reload', async () => { it('a wait started before reload resumes from GET pending after login', async () => {
// --- Session 1: trigger the scenario, then "close the tab" mid-wait --- const client1 = makeClient();
const client1 = clientWithSharedStore();
await client1.auth.loginWithDevice(); await client1.auth.loginWithDevice();
const runtime1 = await getScenarioRuntime(client1); const runtime1 = await getScenarioRuntime(client1);
await runtime1.send('session_start'); await runtime1.send('session_start');
expect(runtime1.isRunning).toBe(true); expect(runtime1.isRunning).toBe(true);
expect(backing.value).toBeTruthy(); // run was persisted
// --- Session 2: fresh client, same persisted state (page reload) --- const client2 = makeClient();
const client2 = clientWithSharedStore(); const resumedWaits: WaitEffect[] = [];
const runtime2 = await getScenarioRuntime(client2); let offer: StoreOfferEffect | undefined;
const resumedWaits: WaitSession[] = []; client2.effects.onWait((s) => { resumedWaits.push(s); });
let offer: StoreSession | undefined; client2.effects.onStoreOffer((s) => { offer = s; });
runtime2.onWait = (s) => resumedWaits.push(s);
runtime2.onStore = (s) => { offer = s; };
await client2.auth.loginWithDevice(); await client2.auth.loginWithDevice();
// The wait node was rehydrated and re-dispatched.
expect(runtime2.isRunning).toBe(true);
expect(resumedWaits).toHaveLength(1); expect(resumedWaits).toHaveLength(1);
expect(offer).toBeUndefined(); expect(offer).toBeUndefined();
// The remaining wait time still elapses → the offer surfaces on the new client.
await vi.advanceTimersByTimeAsync(MINUTE); await vi.advanceTimersByTimeAsync(MINUTE);
expect(offer).toBeDefined(); await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.get('message', '')).toBe('Limited starter pack!'); expect(offer!.message).toBe('Limited starter pack!');
}); });
}); });
+2 -25
View File
@@ -3,7 +3,6 @@ import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js'; import type { RemoteConfig } from '../../src/generated/remote-config.js';
interface GameRemoteConfig extends Record<string, unknown> { interface GameRemoteConfig extends Record<string, unknown> {
@@ -16,7 +15,7 @@ interface GameRemoteConfig extends Record<string, unknown> {
missing_declared: string; missing_declared: string;
} }
function cfg(key: string, value: string, valueType: string, active = true): RemoteConfig { function cfg(key: string, value: string, valueType: NonNullable<RemoteConfig['valueType']>, active = true): RemoteConfig {
return { key, value, valueType, active }; return { key, value, valueType, active };
} }
@@ -25,7 +24,7 @@ function makeClient(): RudderClient<GameRemoteConfig> {
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), 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.status).toBe('idle');
expect(client.remoteConfig.get('max_energy', 99)).toBe(99); expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
}); });
it('a remote_config_override scenario node patches the live cache', async () => {
client = new RudderClient<GameRemoteConfig>({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null },
});
gateway.onEvent(
'player_login',
plan('boost')
.node('override', 'remote_config_override', {
patches: [{ path: 'drop_rate', valueType: 'float', value: '0.9' }],
})
.build(),
);
await client.auth.loginWithDevice();
// The override is reflected immediately, without a reload.
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.9);
});
}); });
+1 -1
View File
@@ -8,7 +8,7 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
} }
+83 -60
View File
@@ -1,5 +1,5 @@
import { vi } from 'vitest'; 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 { RemoteConfig } from '../../src/generated/remote-config.js';
import type { Store } from '../../src/generated/stores.js'; import type { Store } from '../../src/generated/stores.js';
import type { StorageItem } from '../../src/generated/storage.js'; import type { StorageItem } from '../../src/generated/storage.js';
@@ -8,18 +8,6 @@ import type {
ListQuestsResponse, ListQuestsResponse,
} from '../../src/generated/quests.js'; } 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 { export interface RecordedRequest {
method: string; method: string;
path: string; path: string;
@@ -29,36 +17,28 @@ export interface RecordedRequest {
} }
export interface FakeGatewayState { export interface FakeGatewayState {
/** Plans returned by POST /scenarios/trigger, keyed by event name. */ scenarios: Map<string, PendingEffect[]>;
scenarios: Map<string, ExecutionPlan[]>; callbacks: Map<string, PendingEffect | null>;
/** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */ callbackErrors: Map<string, { status: number; code?: string }>;
callbacks: Map<string, ExecutionPlan>; pendingEffects: PendingEffect[];
/** HTTP status to fail a callback with, keyed by `nodeId:handle`. */ counterCompleted: boolean;
callbackErrors: Map<string, number>; counterEffect?: PendingEffect;
/** Optional response for POST /scenarios/counter. */
counterPlan?: ExecutionPlan;
remoteConfigs: Record<string, RemoteConfig>; remoteConfigs: Record<string, RemoteConfig>;
stores: Store[]; stores: Store[];
quests: ListQuestsResponse; quests: ListQuestsResponse;
questClaims: Map<string, ClaimQuestResponse>; questClaims: Map<string, ClaimQuestResponse>;
/** Player KV storage, keyed by item id. */
storage: Map<string, StorageItem>; storage: Map<string, StorageItem>;
} }
export interface FakeGateway { export interface FakeGateway {
state: FakeGatewayState; state: FakeGatewayState;
recorded: RecordedRequest[]; recorded: RecordedRequest[];
/** Purchases received, in order. */ purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>; questClaims: Array<{ questSlug: string; authToken: string | null }>;
/** Quest claim requests received, in order. */
questClaims: Array<{ questId: string; authToken: string | null }>;
install(): void; install(): void;
/** Convenience: register the plans returned for a trigger event. */ onEvent(event: string, ...effects: PendingEffect[]): void;
onEvent(event: string, ...plans: ExecutionPlan[]): void; onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
/** Convenience: register the plan returned when a boundary handle calls back. */ onCallbackError(nodeId: string, handle: string, status?: number, code?: string): void;
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;
} }
function json(body: unknown, status = 200): Response { 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 }); 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 { export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = { const state: FakeGatewayState = {
scenarios: seed?.scenarios ?? new Map(), scenarios: seed?.scenarios ?? new Map(),
callbacks: seed?.callbacks ?? new Map(), callbacks: seed?.callbacks ?? new Map(),
callbackErrors: seed?.callbackErrors ?? new Map(), callbackErrors: seed?.callbackErrors ?? new Map(),
counterPlan: seed?.counterPlan, pendingEffects: seed?.pendingEffects ?? [],
counterCompleted: seed?.counterCompleted ?? false,
counterEffect: seed?.counterEffect,
remoteConfigs: seed?.remoteConfigs ?? {}, remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [], stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] }, quests: seed?.quests ?? { quests: [] },
@@ -87,10 +74,32 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
const purchases: FakeGateway['purchases'] = []; const purchases: FakeGateway['purchases'] = [];
const questClaims: FakeGateway['questClaims'] = []; 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> { async function handle(req: RecordedRequest): Promise<Response> {
const { method, path, query, body } = req; const { method, path, query, body } = req;
// --- Auth ---
if (method === 'POST' && path === '/sdk/v1/authorization/device') { if (method === 'POST' && path === '/sdk/v1/authorization/device') {
const b = body as { deviceId?: string }; const b = body as { deviceId?: string };
return json({ return json({
@@ -99,39 +108,56 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
}); });
} }
// --- Player ---
if (method === 'GET' && path === '/sdk/v1/player/information') { if (method === 'GET' && path === '/sdk/v1/player/information') {
return json({ player: null, wallets: [] }); return json({ player: null, wallets: [] });
} }
// --- Sync (baseline revision poll after login) ---
if (method === 'GET' && path === '/sdk/v1/sync') { if (method === 'GET' && path === '/sdk/v1/sync') {
return json({}); return json({});
} }
// --- Catalog ---
if (method === 'GET' && path === '/sdk/v1/catalog') { if (method === 'GET' && path === '/sdk/v1/catalog') {
return json({ items: [] }); return json({ items: [] });
} }
// --- Scenarios ---
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') { if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
const event = (body as { event?: string }).event ?? ''; 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') { 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 key = `${b.nodeId}:${b.handle}`;
const errStatus = state.callbackErrors.get(key); const err = state.callbackErrors.get(key);
if (errStatus) return json({ error: 'callback failed' }, errStatus); if (err) {
const plan = state.callbacks.get(key); return json({ code: err.code ?? 'callback failed', error: 'callback failed' }, err.status);
return json(plan ? { plan } : {}); }
const next = state.callbacks.get(key);
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
if (next) {
state.pendingEffects = replaceRun(state.pendingEffects, [next]);
return json({ effect: next });
}
return json({});
} }
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') { if (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') { if (method === 'GET' && path === '/sdk/v1/remote-configs') {
return json({ configs: state.remoteConfigs }); 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); return cfg ? json(cfg) : json({ error: 'not found' }, 404);
} }
// --- Stores ---
if (method === 'GET' && path === '/sdk/v1/stores') { if (method === 'GET' && path === '/sdk/v1/stores') {
return json({ stores: state.stores, total: state.stores.length }); return json({ stores: state.stores, total: state.stores.length });
} }
@@ -150,7 +175,7 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
const b = body as { idempotencyKey?: string }; const b = body as { idempotencyKey?: string };
purchases.push({ purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]), storeSlug: decodeURIComponent(purchaseMatch[1]),
offerId: decodeURIComponent(purchaseMatch[2]), offerSlug: decodeURIComponent(purchaseMatch[2]),
idempotencyKey: b.idempotencyKey ?? '', idempotencyKey: b.idempotencyKey ?? '',
authToken: req.authToken, authToken: req.authToken,
}); });
@@ -163,18 +188,16 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return store ? json(store) : json({ error: 'not found' }, 404); return store ? json(store) : json({ error: 'not found' }, 404);
} }
// --- Quests ---
if (method === 'POST' && path === '/sdk/v1/quests/list') { if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests); return json(state.quests);
} }
if (method === 'POST' && path === '/sdk/v1/quests/claim') { if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questId?: string }; const b = body as { questSlug?: string };
const questId = b.questId ?? ''; const questSlug = b.questSlug ?? '';
questClaims.push({ questId, authToken: req.authToken }); questClaims.push({ questSlug, authToken: req.authToken });
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' }); return json(state.questClaims.get(questSlug) ?? { success: false, error: 'not found' });
} }
// --- Storage ---
if (path === '/sdk/v1/storage') { if (path === '/sdk/v1/storage') {
if (method === 'GET') { if (method === 'GET') {
const typeFilter = query.get('types'); const typeFilter = query.get('types');
@@ -228,14 +251,14 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
purchases, purchases,
questClaims, questClaims,
install, install,
onEvent(event, ...plans) { onEvent(event, ...effects) {
state.scenarios.set(event, plans); state.scenarios.set(event, effects);
}, },
onCallback(nodeId, handle, plan) { onCallback(nodeId, handle, next) {
state.callbacks.set(`${nodeId}:${handle}`, plan); state.callbacks.set(`${nodeId}:${handle}`, next);
}, },
onCallbackError(nodeId, handle, status = 500) { onCallbackError(nodeId, handle, status = 500, code) {
state.callbackErrors.set(`${nodeId}:${handle}`, status); state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
}, },
}; };
} }
-26
View File
@@ -1,29 +1,3 @@
import type { PlanStateStore } from '../../src/scenario/engine/IndexedDbPlanStore.js';
/**
* In-memory PlanStateStore that survives across RudderClient instances — lets a
* test simulate a page reload: drive scenario A on one client, construct a fresh
* client sharing the same `backing`, call `scenarios.restore()`, and assert the
* run resumed mid-DAG.
*/
export function createMemoryPlanStore(backing: { value: string | null } = { value: null }): PlanStateStore {
return {
get state() {
return backing.value;
},
set state(v: string | null) {
backing.value = v;
},
async load() {
/* state already in `backing` */
},
async save(s: string | null) {
backing.value = s;
},
};
}
/** Deterministic, collision-free crypto.randomUUID() stub for scenario run IDs. */
export function stubDeterministicUuid(): void { export function stubDeterministicUuid(): void {
let n = 0; let n = 0;
const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto); const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto);
+12 -68
View File
@@ -1,72 +1,16 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.js'; import type { PendingEffect } from '../../src/generated/scenarios.js';
/** export function effect(
* Small fluent builder for ExecutionPlans, so scenario journeys read like the type: string,
* DAGs they model rather than walls of object literals. data: Record<string, unknown> = {},
* overrides: Partial<PendingEffect> = {},
* plan('offer_flow') ): PendingEffect {
* .node('wait1', 'wait', { duration: 1, unit: 'minutes' })
* .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1' })
* .edge('wait1', 'onComplete', 'offer')
* .build();
*
* The first node added becomes the start node unless `.start(id)` is called.
*/
export class PlanBuilder {
private readonly nodes: ExecutionPlanNode[] = [];
private readonly edges: PlanEdge[] = [];
private readonly boundaryNodes: BoundaryNode[] = [];
private startNodeId?: string;
private runIdValue?: string;
constructor(
private readonly scenarioId: string,
private readonly opts: { planId?: string; userId?: string } = {},
) {}
/** Sets a specific run ID (default: auto-derived from scenarioId). */
runId(id: string): this {
this.runIdValue = id;
return this;
}
node(id: string, type: string, data?: Record<string, unknown>): this {
this.nodes.push({ id, type, data: data as ExecutionPlanNode['data'] });
if (!this.startNodeId) this.startNodeId = id;
return this;
}
edge(source: string, sourceHandle: string, target: string): this {
this.edges.push({ id: `e-${source}-${sourceHandle}-${target}`, source, sourceHandle, target });
return this;
}
/** Registers a server-side boundary node (handle that calls back to the gateway). */
boundary(sourceNodeId: string, sourceHandle: string, nodeId = `b-${sourceNodeId}-${sourceHandle}`): this {
this.boundaryNodes.push({ sourceNodeId, sourceHandle, nodeId });
return this;
}
start(id: string): this {
this.startNodeId = id;
return this;
}
build(): ExecutionPlan {
return { return {
planId: this.opts.planId ?? `${this.scenarioId}-plan`, runId: 'run-1',
scenarioId: this.scenarioId, scenarioSlug: 'scenario-1',
userId: this.opts.userId ?? 'player-1', nodeId: `${type}-1`,
startNodeId: this.startNodeId, type,
runId: this.runIdValue ?? `${this.scenarioId}-run`, data,
nodes: this.nodes, ...overrides,
edges: this.edges,
boundaryNodes: this.boundaryNodes,
context: undefined,
}; };
} }
}
export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder {
return new PlanBuilder(scenarioId, opts);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
target: 'es2022', target: 'es2022',
dts: true, dts: true,
clean: 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, splitting: true,
treeshake: true, treeshake: true,
platform: 'browser', platform: 'browser',