Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7271874cac | |||
| a7401b534a | |||
| e863cc3bf7 | |||
| 8863ef80e2 | |||
| 1491b5e357 | |||
| ecbbf93951 | |||
| 8753239fbd | |||
| 04e3565412 | |||
| 19a296ab9a | |||
| a72989402e | |||
| 3556c0b035 | |||
| fff971ad15 | |||
| e6128e54bd | |||
| 68287d212e | |||
| 1013f2395f |
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Player profile + wallets — `client.player`
|
||||||
|
|
||||||
|
`PlayerDomain` (source: `src/domains/PlayerDomain.ts`) — the observable player
|
||||||
|
profile: identity plus currency wallets. Synced under revision key `profile`.
|
||||||
|
|
||||||
|
There is no separate wallet service in this SDK. Wallet balances are read from
|
||||||
|
the profile and are credited/debited server-side (purchases, quest/battle pass
|
||||||
|
rewards, scenario nodes).
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
`client.player` is a `SyncedState<PlayerProfile>`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
client.player.data // PlayerProfile | undefined
|
||||||
|
client.player.status // 'idle' | 'loading' | 'ready' | 'error'
|
||||||
|
client.player.onChange((snapshot) => { /* fires immediately */ });
|
||||||
|
await client.player.load();
|
||||||
|
await client.player.reload(); // force refetch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface PlayerProfile {
|
||||||
|
player?: Player;
|
||||||
|
wallets?: Wallet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Player {
|
||||||
|
createdAt?: string;
|
||||||
|
id?: string;
|
||||||
|
language?: string;
|
||||||
|
nickname?: string;
|
||||||
|
projectId?: string;
|
||||||
|
region?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Wallet {
|
||||||
|
balance?: number;
|
||||||
|
currency?: string; // currency code configured in the dashboard
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior notes
|
||||||
|
|
||||||
|
- Warmed automatically at login (one of the four parallel warm loads).
|
||||||
|
- Invalidated (refetched) after every successful store purchase and after
|
||||||
|
scenario server callbacks — subscribers see fresh balances without waiting
|
||||||
|
for the revision poll.
|
||||||
|
- All fields are optional on the wire (`?`); code defensively.
|
||||||
|
- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK
|
||||||
|
README "React recipe").
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Quests — `client.quests`
|
||||||
|
|
||||||
|
`QuestsService` (source: `src/quests/QuestsService.ts`). The player's **global**
|
||||||
|
quests — list, claim, report metric progress. Plain call-and-response service,
|
||||||
|
NOT observable (no sync revision key): re-list after a claim or report.
|
||||||
|
|
||||||
|
Distinct from scenario quest nodes, which advance through
|
||||||
|
`client.effects.onQuest` (see `reference/scenarios.md`).
|
||||||
|
|
||||||
|
## Methods
|
||||||
|
|
||||||
|
```ts
|
||||||
|
list(): Promise<Quest[]>
|
||||||
|
claim(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.
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+354
-493
@@ -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);
|
|
||||||
} catch {
|
|
||||||
// Best-effort persistence.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
|
||||||
|
if (this.droppedRuns.has(runId)) return;
|
||||||
|
if (this.completedRuns.has(runId)) return;
|
||||||
|
for (const record of this.active.values()) {
|
||||||
|
if (record.runId === runId) return;
|
||||||
|
}
|
||||||
|
this.completedRuns.add(runId);
|
||||||
|
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../generated/common.js';
|
|
||||||
|
|
||||||
/** Finds a node by ID within a plan (linear scan of the nodes array). */
|
|
||||||
export function findNode(
|
|
||||||
plan: ExecutionPlan,
|
|
||||||
nodeId: string | undefined,
|
|
||||||
): ExecutionPlanNode | undefined {
|
|
||||||
if (!nodeId || !plan.nodes) return undefined;
|
|
||||||
return plan.nodes.find((n) => n.id === nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Yields all edges whose source node and sourceHandle match. */
|
|
||||||
export function* matchingEdges(
|
|
||||||
plan: ExecutionPlan,
|
|
||||||
sourceNodeId: string,
|
|
||||||
sourceHandle: string,
|
|
||||||
): Generator<PlanEdge> {
|
|
||||||
for (const edge of plan.edges ?? []) {
|
|
||||||
if (
|
|
||||||
edge.source === sourceNodeId &&
|
|
||||||
(edge.sourceHandle ?? '') === (sourceHandle ?? '')
|
|
||||||
) {
|
|
||||||
yield edge;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Yields all boundary nodes matching the completed node + handle. */
|
|
||||||
export function* matchingBoundaryNodes(
|
|
||||||
plan: ExecutionPlan,
|
|
||||||
sourceNodeId: string,
|
|
||||||
sourceHandle: string,
|
|
||||||
): Generator<BoundaryNode> {
|
|
||||||
for (const boundary of plan.boundaryNodes ?? []) {
|
|
||||||
if (
|
|
||||||
boundary.sourceNodeId === sourceNodeId &&
|
|
||||||
(boundary.sourceHandle ?? '') === (sourceHandle ?? '')
|
|
||||||
) {
|
|
||||||
yield boundary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns a deduplication key for completed node+handle pairs. */
|
|
||||||
export function completedHandleKey(nodeId: string, handle: string): string {
|
|
||||||
return `${nodeId}:${handle ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Converts wait duration+unit to milliseconds. Unit required; defaults to seconds when unit is unrecognized. */
|
|
||||||
export function durationToMs(duration: number, unit: string): number {
|
|
||||||
switch ((unit ?? 'seconds').toLowerCase()) {
|
|
||||||
case 'days':
|
|
||||||
case 'day':
|
|
||||||
case 'd':
|
|
||||||
return duration * 86_400_000;
|
|
||||||
case 'hours':
|
|
||||||
case 'hour':
|
|
||||||
case 'hr':
|
|
||||||
case 'h':
|
|
||||||
return duration * 3_600_000;
|
|
||||||
case 'minutes':
|
|
||||||
case 'minute':
|
|
||||||
case 'min':
|
|
||||||
case 'm':
|
|
||||||
return duration * 60_000;
|
|
||||||
case 'seconds':
|
|
||||||
case 'second':
|
|
||||||
case 'sec':
|
|
||||||
case 's':
|
|
||||||
return duration * 1000;
|
|
||||||
default:
|
|
||||||
return duration * 1000; // default: seconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* IndexedDB-backed plan state store for persisting scenario execution state.
|
|
||||||
*
|
|
||||||
* The C# SDK uses a synchronous `IPlanStateStore { string State { get; set; } }`.
|
|
||||||
* IndexedDB is inherently async, so we provide an async `load()` / `save()` API
|
|
||||||
* alongside a synchronous `state` property for immediate reads after load.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface PlanStateStore {
|
|
||||||
/** Current serialized state (available after `load()` or `save()`). */
|
|
||||||
state: string | null;
|
|
||||||
|
|
||||||
/** Loads persisted state from IndexedDB. Call once on SDK initialization. */
|
|
||||||
load(): Promise<void>;
|
|
||||||
|
|
||||||
/** Persists the current state to IndexedDB. */
|
|
||||||
save(state: string | null): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DB_NAME = 'RudderPlanState';
|
|
||||||
const STORE_NAME = 'state';
|
|
||||||
const KEY = 'active_runs';
|
|
||||||
const DB_VERSION = 1;
|
|
||||||
|
|
||||||
export function createIndexedDbPlanStateStore(): PlanStateStore {
|
|
||||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
|
||||||
let cachedState: string | null = null;
|
|
||||||
|
|
||||||
function getDb(): Promise<IDBDatabase> {
|
|
||||||
if (!dbPromise) {
|
|
||||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
||||||
request.onupgradeneeded = () => {
|
|
||||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
|
||||||
request.result.createObjectStore(STORE_NAME);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
request.onsuccess = () => resolve(request.result);
|
|
||||||
request.onerror = () => reject(request.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return dbPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
get state(): string | null {
|
|
||||||
return cachedState;
|
|
||||||
},
|
|
||||||
|
|
||||||
set state(value: string | null) {
|
|
||||||
cachedState = value;
|
|
||||||
// Best-effort async write — does not block the setter.
|
|
||||||
getDb()
|
|
||||||
.then((db) => {
|
|
||||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
||||||
if (value === null) {
|
|
||||||
tx.objectStore(STORE_NAME).delete(KEY);
|
|
||||||
} else {
|
|
||||||
tx.objectStore(STORE_NAME).put(value, KEY);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// IndexedDB write failed — state is still in memory.
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async load(): Promise<void> {
|
|
||||||
const db = await getDb();
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
||||||
const req = tx.objectStore(STORE_NAME).get(KEY);
|
|
||||||
req.onsuccess = () => {
|
|
||||||
cachedState = (req.result as string) ?? null;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async save(state: string | null): Promise<void> {
|
|
||||||
cachedState = state;
|
|
||||||
const db = await getDb();
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
||||||
const req =
|
|
||||||
state === null
|
|
||||||
? tx.objectStore(STORE_NAME).delete(KEY)
|
|
||||||
: tx.objectStore(STORE_NAME).put(state, KEY);
|
|
||||||
req.onsuccess = () => resolve();
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
import type { ExecutionPlanNode } from '../../generated/common.js';
|
|
||||||
import type { PlanRun } from './types.js';
|
|
||||||
import type { BuyOptions, OfferHandle, ShopHandle } from '../../state/shops.js';
|
|
||||||
import type { PurchaseOfferResponse } from '../../generated/stores.js';
|
|
||||||
import type { BattlePassService } from '../../battlepass/BattlePassService.js';
|
|
||||||
import type {
|
|
||||||
AddBattlePassXpResponse,
|
|
||||||
ClaimBattlePassRewardResponse,
|
|
||||||
GetBattlePassProgressResponse,
|
|
||||||
PurchaseBattlePassPremiumResponse,
|
|
||||||
} from '../../generated/battlepass.js';
|
|
||||||
|
|
||||||
/** Resolves a store handle by slug — satisfied by the stores domain. */
|
|
||||||
interface StoreResolver {
|
|
||||||
getBySlug(slug: string): Promise<ShopHandle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- ScenarioNodeContext ----
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context object passed to each session.
|
|
||||||
* Wraps the current run, plan node, and raw node data.
|
|
||||||
* The session calls `complete(handle)` to advance the DAG.
|
|
||||||
*/
|
|
||||||
export class ScenarioNodeContext {
|
|
||||||
constructor(
|
|
||||||
public readonly run: PlanRun,
|
|
||||||
public readonly node: ExecutionPlanNode,
|
|
||||||
private readonly onComplete: (runId: string, nodeId: string, handle: string) => Promise<void>,
|
|
||||||
private readonly onProgress: (runId: string, nodeId: string, counterKey: string, amount: number) => Promise<void>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Extracts a typed value from node data. */
|
|
||||||
get<T>(key: string, defaultValue: T): T {
|
|
||||||
const data = this.node.data as Record<string, unknown> | undefined;
|
|
||||||
const value = data?.[key];
|
|
||||||
if (value === undefined || value === null) return defaultValue;
|
|
||||||
return value as unknown as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full node data as a typed record. */
|
|
||||||
get data(): Record<string, unknown> {
|
|
||||||
return (this.node.data as Record<string, unknown>) ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Completes the current node with the given output handle. */
|
|
||||||
async complete(handle: string): Promise<void> {
|
|
||||||
await this.onComplete(this.run.runId, this.node.id!, handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Updates counter progress for server-managed scenario nodes. */
|
|
||||||
async updateProgress(counterKey: string, amount: number): Promise<void> {
|
|
||||||
await this.onProgress(this.run.runId, this.node.id!, counterKey, amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- NotificationSession ----
|
|
||||||
|
|
||||||
export class NotificationSession {
|
|
||||||
public readonly title: string;
|
|
||||||
public readonly message: string;
|
|
||||||
|
|
||||||
constructor(private readonly context: ScenarioNodeContext) {
|
|
||||||
this.title = context.get('title', '');
|
|
||||||
this.message = context.get('message', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
get<T>(key: string, defaultValue: T): T {
|
|
||||||
return this.context.get(key, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
get data(): Record<string, unknown> {
|
|
||||||
return this.context.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
get node(): ExecutionPlanNode {
|
|
||||||
return this.context.node;
|
|
||||||
}
|
|
||||||
|
|
||||||
async complete(): Promise<void> {
|
|
||||||
await this.context.complete('output');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- WaitSession ----
|
|
||||||
|
|
||||||
export class WaitSession {
|
|
||||||
constructor(
|
|
||||||
private readonly context: ScenarioNodeContext,
|
|
||||||
public readonly deadlineUtc: Date,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
get<T>(key: string, defaultValue: T): T {
|
|
||||||
return this.context.get(key, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
get data(): Record<string, unknown> {
|
|
||||||
return this.context.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
get node(): ExecutionPlanNode {
|
|
||||||
return this.context.node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- StoreSession ----
|
|
||||||
|
|
||||||
export class StoreSession {
|
|
||||||
private resolved = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly context: ScenarioNodeContext,
|
|
||||||
private readonly stores: StoreResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
get isResolved(): boolean {
|
|
||||||
return this.resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
get<T>(key: string, defaultValue: T): T {
|
|
||||||
return this.context.get(key, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
get data(): Record<string, unknown> {
|
|
||||||
return this.context.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
get node(): ExecutionPlanNode {
|
|
||||||
return this.context.node;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStore(): Promise<ShopHandle> {
|
|
||||||
return this.stores.getBySlug(this.get('storeSlug', ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
async buy(
|
|
||||||
offer: OfferHandle,
|
|
||||||
options?: BuyOptions,
|
|
||||||
): Promise<PurchaseOfferResponse> {
|
|
||||||
if (this.resolved) {
|
|
||||||
return { success: false, error: 'store session already resolved' };
|
|
||||||
}
|
|
||||||
const purchase = await offer.buy(options);
|
|
||||||
if (purchase.success) {
|
|
||||||
this.resolved = true;
|
|
||||||
await this.context.complete('onPurchase');
|
|
||||||
}
|
|
||||||
return purchase;
|
|
||||||
}
|
|
||||||
|
|
||||||
async decline(): Promise<void> {
|
|
||||||
if (this.resolved) return;
|
|
||||||
this.resolved = true;
|
|
||||||
await this.context.complete('onDecline');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- LeaderboardSession ----
|
|
||||||
|
|
||||||
export class LeaderboardSession {
|
|
||||||
private resolved = false;
|
|
||||||
|
|
||||||
constructor(private readonly context: ScenarioNodeContext) {}
|
|
||||||
|
|
||||||
get isResolved(): boolean {
|
|
||||||
return this.resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
get<T>(key: string, defaultValue: T): T {
|
|
||||||
return this.context.get(key, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
get data(): Record<string, unknown> {
|
|
||||||
return this.context.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
get node(): ExecutionPlanNode {
|
|
||||||
return this.context.node;
|
|
||||||
}
|
|
||||||
|
|
||||||
async end(): Promise<void> {
|
|
||||||
if (this.resolved) return;
|
|
||||||
this.resolved = true;
|
|
||||||
await this.context.complete('onEnd');
|
|
||||||
}
|
|
||||||
|
|
||||||
async rewardClaimed(): Promise<void> {
|
|
||||||
if (this.resolved) return;
|
|
||||||
this.resolved = true;
|
|
||||||
await this.context.complete('onRewardClaimed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- QuestSession ----
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A scenario quest node. The game reports objective progress; the server
|
|
||||||
* auto-completes the node (crossing `onComplete`, which grants rewards and
|
|
||||||
* advances the run) once every objective is satisfied.
|
|
||||||
*/
|
|
||||||
export class QuestSession {
|
|
||||||
constructor(private readonly context: ScenarioNodeContext) {}
|
|
||||||
|
|
||||||
get name(): string {
|
|
||||||
return this.context.get('name', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
get objectives(): Array<Record<string, unknown>> {
|
|
||||||
return this.context.get('objectives', [] as Array<Record<string, unknown>>);
|
|
||||||
}
|
|
||||||
|
|
||||||
get<T>(key: string, defaultValue: T): T {
|
|
||||||
return this.context.get(key, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
get data(): Record<string, unknown> {
|
|
||||||
return this.context.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
get node(): ExecutionPlanNode {
|
|
||||||
return this.context.node;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reports progress toward an objective. When the reported counter completes
|
|
||||||
* every objective, the server signals completion and the node crosses
|
|
||||||
* `onComplete` automatically.
|
|
||||||
*/
|
|
||||||
async reportProgress(objectiveId: string, amount = 1): Promise<void> {
|
|
||||||
await this.context.updateProgress(objectiveId, amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- BattlePassSession ----
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A scenario battlepass node. Exposes the battlepass operations (xp, premium,
|
|
||||||
* progress) bound to this node's scenario/node/run ids, plus explicit boundary
|
|
||||||
* crossings (`onLevelUp`, `onPremiumPurchase`) the game drives from its UI. The
|
|
||||||
* server validates each crossing (e.g. onLevelUp requires the level be reached).
|
|
||||||
*/
|
|
||||||
export class BattlePassSession {
|
|
||||||
constructor(
|
|
||||||
private readonly context: ScenarioNodeContext,
|
|
||||||
private readonly battlePass: BattlePassService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
get data(): Record<string, unknown> {
|
|
||||||
return this.context.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
get node(): ExecutionPlanNode {
|
|
||||||
return this.context.node;
|
|
||||||
}
|
|
||||||
|
|
||||||
get<T>(key: string, defaultValue: T): T {
|
|
||||||
return this.context.get(key, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ids(): { scenarioId: string; nodeId: string; runId: string } {
|
|
||||||
return {
|
|
||||||
scenarioId: this.context.run.scenarioId,
|
|
||||||
nodeId: this.context.node.id!,
|
|
||||||
runId: this.context.run.runId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current xp/level/premium/claimed-tiers for this node. */
|
|
||||||
getProgress(): Promise<GetBattlePassProgressResponse> {
|
|
||||||
const { scenarioId, nodeId } = this.ids();
|
|
||||||
return this.battlePass.getProgress(scenarioId, nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Credits XP from a configured source. */
|
|
||||||
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse> {
|
|
||||||
const { scenarioId, nodeId, runId } = this.ids();
|
|
||||||
return this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Claims a tier reward at a reached level. */
|
|
||||||
claimReward(level: number, track?: 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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!');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user