@@ -0,0 +1,31 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck (src + tests)
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Unit + local e2e tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
# Local seed artifact produced by the prod e2e suite (contains a throwaway operator token).
|
||||||
|
.e2e-prod.local.json
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
Breaking changes — the package was aligned with the Rudder SDK glossary and
|
||||||
|
the regenerated wire types (`src/generated`, apigen). No compatibility shims
|
||||||
|
are provided.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- `client.auth.loginViaDevice(region, language, nickname?)` replaced by
|
||||||
|
`client.auth.loginWithDevice(options?: { region?, language?, nickname? })`.
|
||||||
|
Default region is now `'global'` (was `'en'`); default language stays `'en'`.
|
||||||
|
- New `client.auth.onAuthStateChange(cb)` — fires immediately with the current
|
||||||
|
state, then on every `'signed-in' | 'signed-out'` transition (login, logout,
|
||||||
|
and unrecoverable session expiry in the transport).
|
||||||
|
- New `client.auth.isAuthenticated`.
|
||||||
|
|
||||||
|
### Client configuration
|
||||||
|
|
||||||
|
- `tokenStore` in `RudderClientOptions` is now optional. Default:
|
||||||
|
localStorage-backed store with a silent in-memory fallback where
|
||||||
|
localStorage is unavailable (SSR, private mode). New `createDefaultTokenStore()` export.
|
||||||
|
- Invalid client options (`baseUrl` / `projectKey` missing) now throw
|
||||||
|
`RudderError` with `code: 'sdk/invalid-options'` instead of a plain `Error`.
|
||||||
|
- New `onEffectError` option — called when an effect handler throws
|
||||||
|
(default: `console.error`); previously such errors were swallowed silently.
|
||||||
|
|
||||||
|
### Renames
|
||||||
|
|
||||||
|
- `client.config` → `client.remoteConfig` (canonical glossary name).
|
||||||
|
- `client.battlepass` → `client.battlePass`; `BattlepassService` → `BattlePassService`.
|
||||||
|
- Effects: `onBattlepass` / `BattlepassEffect` → `onBattlePass` / `BattlePassEffect`;
|
||||||
|
`onBattlepassLevel` / `BattlepassLevelEffect` → `onBattlePassLevel` / `BattlePassLevelEffect`.
|
||||||
|
- Generated types: `SdkQuest` → `Quest`; `*Battlepass*` request/response types → `*BattlePass*`.
|
||||||
|
- Internal transport `sendAsync` → `request` (not part of the public surface).
|
||||||
|
|
||||||
|
### Leaderboards
|
||||||
|
|
||||||
|
- Removed `LeaderboardsService.getRanking(slug, limit)` and
|
||||||
|
`LeaderboardsService.submitScore(slug, score)`. Single path:
|
||||||
|
`client.leaderboards.findBySlug(slug)` → `handle.list(limit?)` / `handle.submit(score)`.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
- `RudderNetworkError.inner` → standard `Error.cause`.
|
||||||
|
- `RudderHttpError.code` is typed as `RudderErrorCode | (string & {})`
|
||||||
|
(generated union of server error codes, plus room for unknown strings).
|
||||||
|
- New exports: `RudderErrorCode` (type), `RudderErrorCodes` (constants),
|
||||||
|
`SDK_ERROR_INVALID_OPTIONS`, `RudderErrorCodeLike` (type).
|
||||||
|
|
||||||
|
### Public surface
|
||||||
|
|
||||||
|
- The barrel no longer exports `getOrCreateDeviceId`, domain classes
|
||||||
|
(`PlayerDomain`, …) or service constructors (`AuthService`, …) as values —
|
||||||
|
they remain available as types. Instances live on the client.
|
||||||
|
- `SubmittedBy` is no longer re-exported (UGC is unsupported on web).
|
||||||
|
|
||||||
|
### Bundle
|
||||||
|
|
||||||
|
- The scenario engine and the IndexedDB plan store are loaded lazily (dynamic
|
||||||
|
import) on first login / first runtime access — a thin client never pulls
|
||||||
|
the scenario machinery into the initial chunk.
|
||||||
|
- The package now ships both ESM (`dist/index.js`) and CJS (`dist/index.cjs`)
|
||||||
|
builds with matching `exports` entries.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# @rudder/js-sdk
|
||||||
|
|
||||||
|
LiveOps Web SDK for browser games — device auth, observable player state,
|
||||||
|
stores, remote config, leaderboards, quests, battle pass, and a scenario runtime
|
||||||
|
surfaced as typed effects.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
The package is published to a private registry, not npmjs. Point the
|
||||||
|
`@rudder` scope at it in your project's `.npmrc`:
|
||||||
|
|
||||||
|
```
|
||||||
|
@rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install as usual (anonymous read access, no token needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @rudder/js-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { RudderClient } from '@rudder/js-sdk';
|
||||||
|
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.rudder.build',
|
||||||
|
projectKey: 'your-project-key',
|
||||||
|
// tokenStore is optional: defaults to localStorage with an in-memory
|
||||||
|
// fallback where localStorage is unavailable (SSR, private mode).
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice(); // region 'global', language 'en'
|
||||||
|
// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' });
|
||||||
|
|
||||||
|
// Observable domains — read `.data`, subscribe with `onChange`, force `reload()`.
|
||||||
|
const profile = client.player.data;
|
||||||
|
const speed = client.remoteConfig.get('player_speed', 200);
|
||||||
|
|
||||||
|
client.stores.onChange(({ data: stores }) => renderStores(stores ?? []));
|
||||||
|
|
||||||
|
// Scenario-driven UI arrives through effects.
|
||||||
|
client.effects.onStoreOffer((offer) => showOffer(offer));
|
||||||
|
client.effects.onScenarioFailed(({ error }) => console.error('scenario failed', error));
|
||||||
|
|
||||||
|
// Tear down background work (sync poll, wait timers) on unmount / HMR.
|
||||||
|
client.dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
| Area | Access |
|
||||||
|
|---|---|
|
||||||
|
| Auth | `client.auth.loginWithDevice()`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
|
||||||
|
| Player / wallets | `client.player` (observable) |
|
||||||
|
| Inventory | `client.inventory` (observable, catalog-merged) |
|
||||||
|
| Catalog | `client.catalog` (observable) |
|
||||||
|
| Stores | `client.stores` (observable) + `client.stores.purchase(slug, offerId)` |
|
||||||
|
| Remote config | `client.remoteConfig.get(key, default)` (observable, typed) |
|
||||||
|
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
|
||||||
|
| Leaderboards | `client.leaderboards.findBySlug(slug)` → `handle.submit(score)` / `handle.list(limit?)` |
|
||||||
|
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) |
|
||||||
|
| Quests | `client.quests.list()` / `client.quests.claim(id)` |
|
||||||
|
| Scenario effects | `client.effects.on*` |
|
||||||
|
|
||||||
|
Type the remote config for `client.remoteConfig`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface GameConfig extends Record<string, unknown> {
|
||||||
|
player_speed: number;
|
||||||
|
feature_x: boolean;
|
||||||
|
}
|
||||||
|
const client = new RudderClient<GameConfig>({ /* … */ });
|
||||||
|
client.remoteConfig.get('player_speed', 200); // number
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenario effects
|
||||||
|
|
||||||
|
The scenario runtime is not exposed directly; scenario nodes surface through
|
||||||
|
`client.effects`:
|
||||||
|
|
||||||
|
- `onNotification`, `onStoreOffer`, `onLeaderboard`, `onConfigChanged`
|
||||||
|
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
|
||||||
|
- `onScenarioCompleted`, `onScenarioFailed`
|
||||||
|
|
||||||
|
The scenario engine (and its IndexedDB persistence) loads lazily on first
|
||||||
|
login — a client that only reads domains never pulls it into the page.
|
||||||
|
|
||||||
|
Errors thrown inside effect handlers are reported via the `onEffectError`
|
||||||
|
client option (default: `console.error`).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
All SDK errors extend `RudderError` and carry an optional machine-readable
|
||||||
|
`code`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
RudderError,
|
||||||
|
RudderNetworkError,
|
||||||
|
RudderHttpError,
|
||||||
|
RudderAuthError,
|
||||||
|
RudderErrorCodes,
|
||||||
|
} from '@rudder/js-sdk';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.player.reload();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RudderAuthError) {
|
||||||
|
// 401 — session expired and the refresh failed; the client already
|
||||||
|
// cleared tokens and emitted 'signed-out' via onAuthStateChange.
|
||||||
|
showLogin();
|
||||||
|
} else if (error instanceof RudderHttpError) {
|
||||||
|
if (error.code === RudderErrorCodes.runExpired) {
|
||||||
|
// typed server error code
|
||||||
|
}
|
||||||
|
console.error(error.status, error.code, error.body);
|
||||||
|
} else if (error instanceof RudderNetworkError) {
|
||||||
|
// fetch failed / timeout — the underlying error is in error.cause
|
||||||
|
console.error('offline?', error.cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Invalid client options (missing `baseUrl` / `projectKey`) throw `RudderError`
|
||||||
|
with `code: 'sdk/invalid-options'` from the constructor.
|
||||||
|
|
||||||
|
Track the session lifecycle without polling the token store:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
client.auth.onAuthStateChange((state) => {
|
||||||
|
// fires immediately with the current state, then on every change
|
||||||
|
setSignedIn(state === 'signed-in');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data liveness model
|
||||||
|
|
||||||
|
Observable domains (`player`, `inventory`, `catalog`, `stores`, `remoteConfig`,
|
||||||
|
`storage`) are warmed once at login and then kept fresh by a revision poll:
|
||||||
|
the client asks the gateway for entity revisions every **30 s (±20% jitter)**
|
||||||
|
and reloads only the entities whose revision grew. Polling **pauses while the
|
||||||
|
tab is hidden** and resumes on `visibilitychange`. Mutations (purchases,
|
||||||
|
storage writes, scenario callbacks) invalidate the affected domains
|
||||||
|
immediately, so subscribers see fresh data without waiting for the next poll.
|
||||||
|
Expect data to be eventually consistent within one poll interval; call
|
||||||
|
`reload()` when you need a guarantee.
|
||||||
|
|
||||||
|
## React recipe
|
||||||
|
|
||||||
|
`SyncedState.onChange` fires immediately with the current snapshot, which maps
|
||||||
|
straight onto `useSyncExternalStore`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
function useSynced<T>(state: { onChange(cb: () => void): () => void; data: T | undefined }) {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(onStoreChange) => state.onChange(() => onStoreChange()),
|
||||||
|
() => state.data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wallet() {
|
||||||
|
const profile = useSynced(client.player);
|
||||||
|
return <div>{profile?.wallets?.map((w) => `${w.currency}: ${w.balance}`).join(', ')}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same pattern works for `client.remoteConfig`, `client.stores`, and
|
||||||
|
`client.auth.onAuthStateChange`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck # tsc over src and tests
|
||||||
|
npm test # unit + local e2e (jsdom)
|
||||||
|
npm run build # tsup → dist (ESM + CJS + d.ts)
|
||||||
|
npm run test:e2e-prod # drives a seeded project on the live gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated wire types (`src/generated`) come from the gateway's `apigen`; run
|
||||||
|
`npm run generate` to refresh them. Do not edit them by hand.
|
||||||
Generated
+3007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@rudder/js-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
|
||||||
|
},
|
||||||
|
"description": "LiveOps Web SDK for browser games — player authentication, leaderboards, stores, remote config, and typed runtime effects",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"sideEffects": false,
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://hub.rudder.build/rudder/rudder-js-sdk"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "vitest run test/e2e",
|
||||||
|
"test:e2e-prod": "vitest run --config vitest.e2e-prod.config.ts",
|
||||||
|
"e2e:prod": "vitest run --config vitest.e2e-prod.config.ts",
|
||||||
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
||||||
|
"check:package": "npm run build && npm pack --dry-run --json",
|
||||||
|
"generate": "make -C ../liveops-gateway generate-openapi"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* AuthService — device-based player authentication.
|
||||||
|
*
|
||||||
|
* Handles login via device ID (the primary auth flow for game clients),
|
||||||
|
* logout (token clearing), and auth state observation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
|
||||||
|
import { getOrCreateDeviceId } from '../device/DeviceId.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type { LoginViaDeviceResponse } from '../generated/auth.js';
|
||||||
|
|
||||||
|
export type AuthState = 'signed-in' | 'signed-out';
|
||||||
|
|
||||||
|
export type AuthStateListener = (state: AuthState) => void;
|
||||||
|
|
||||||
|
export interface LoginWithDeviceOptions {
|
||||||
|
/** 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> {
|
||||||
|
private readonly listeners = new Set<AuthStateListener>();
|
||||||
|
private state: AuthState;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ctx: RudderContext,
|
||||||
|
private readonly startRuntime: () => Promise<void>,
|
||||||
|
private readonly stopRuntime: () => void,
|
||||||
|
) {
|
||||||
|
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while an access token is present in the token store. */
|
||||||
|
get isAuthenticated(): boolean {
|
||||||
|
return this.ctx.options.tokenStore.getAccessToken() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to auth state changes. The listener fires immediately with the
|
||||||
|
* current state (like `SyncedState.onChange`). Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
onAuthStateChange(listener: AuthStateListener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
// Resync with the token store — tokens may have been written externally
|
||||||
|
// since construction.
|
||||||
|
this.state = this.isAuthenticated ? 'signed-in' : 'signed-out';
|
||||||
|
listener(this.state);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates the current device and returns access + refresh tokens.
|
||||||
|
*
|
||||||
|
* The device ID is auto-generated on first call and persisted in localStorage.
|
||||||
|
* On success, tokens are saved to the client's TokenStore.
|
||||||
|
*/
|
||||||
|
async loginWithDevice(options: LoginWithDeviceOptions = {}): Promise<LoginViaDeviceResponse> {
|
||||||
|
const { region = 'global', language = 'en', nickname } = options;
|
||||||
|
const response = await api.loginViaDevice(this.ctx, {
|
||||||
|
key: this.ctx.options.projectKey,
|
||||||
|
deviceId: getOrCreateDeviceId(),
|
||||||
|
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). */
|
||||||
|
logout(): void {
|
||||||
|
this.ctx.options.tokenStore.clear();
|
||||||
|
this.stopRuntime();
|
||||||
|
this.setState('signed-out');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the transport when the session is unrecoverable (refresh failed).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
notifySessionExpired(): void {
|
||||||
|
this.setState('signed-out');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: AuthState): void {
|
||||||
|
if (state === this.state) return;
|
||||||
|
this.state = state;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* BattlePassService — call-and-response access to the battle pass endpoints.
|
||||||
|
*
|
||||||
|
* Battle pass state is tied to a scenario battle pass node, so every call
|
||||||
|
* carries a `scenarioId` + `nodeId` (and a `runId` for the mutating calls).
|
||||||
|
* Battle pass has no sync-revision key, so this is a plain service, not an
|
||||||
|
* observable domain; re-fetch progress explicitly after a mutation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type {
|
||||||
|
AddBattlePassXpRequest,
|
||||||
|
AddBattlePassXpResponse,
|
||||||
|
ClaimBattlePassRewardRequest,
|
||||||
|
ClaimBattlePassRewardResponse,
|
||||||
|
PurchaseBattlePassPremiumRequest,
|
||||||
|
PurchaseBattlePassPremiumResponse,
|
||||||
|
GetBattlePassProgressResponse,
|
||||||
|
} from '../generated/battlepass.js';
|
||||||
|
|
||||||
|
export class BattlePassService {
|
||||||
|
constructor(private readonly ctx: RudderContext) {}
|
||||||
|
|
||||||
|
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */
|
||||||
|
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
|
||||||
|
return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Credits XP from a configured source. Returns the new xp/level. */
|
||||||
|
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse> {
|
||||||
|
return api.addBattlePassXp(this.ctx, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claims a tier reward at a reached level (idempotent server-side). */
|
||||||
|
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse> {
|
||||||
|
return api.claimBattlePassReward(this.ctx, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Purchases the premium track (charges the wallet, idempotent). */
|
||||||
|
purchasePremium(
|
||||||
|
request: PurchaseBattlePassPremiumRequest,
|
||||||
|
): Promise<PurchaseBattlePassPremiumResponse> {
|
||||||
|
return api.purchaseBattlePassPremium(this.ctx, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* RudderClient — the main entry point for the LiveOps Web SDK.
|
||||||
|
*
|
||||||
|
* Composes the observable domains, the scenario runtime, and the core HTTP
|
||||||
|
* transport that every service uses to reach the LiveOps API gateway.
|
||||||
|
*
|
||||||
|
* The client builds a {@link RudderContext} first and injects it into every
|
||||||
|
* domain and service, so they depend on that narrow surface rather than on the
|
||||||
|
* client (which keeps the module graph acyclic). Cross-domain wiring (the
|
||||||
|
* inventory↔catalog link, purchase invalidation) is done here at construction.
|
||||||
|
*
|
||||||
|
* The scenario runtime (engine + plan persistence) is loaded lazily on first
|
||||||
|
* use — a client that never logs in never pays for the scenario engine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ResolvedRudderClientOptions,
|
||||||
|
RudderClientOptions,
|
||||||
|
} from './RudderClientOptions.js';
|
||||||
|
import { RudderError, SDK_ERROR_INVALID_OPTIONS } from './RudderError.js';
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { request, type RequestContext } from '../transport/request.js';
|
||||||
|
import { createDefaultTokenStore } from '../token/TokenStore.js';
|
||||||
|
import { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
|
||||||
|
import { BattlePassService } from '../battlepass/BattlePassService.js';
|
||||||
|
import { QuestsService } from '../quests/QuestsService.js';
|
||||||
|
import type { ScenarioService } from '../scenario/ScenarioService.js';
|
||||||
|
import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
|
||||||
|
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
|
||||||
|
import { SyncEngine } from '../state/SyncEngine.js';
|
||||||
|
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
|
||||||
|
import { PlayerDomain } from '../domains/PlayerDomain.js';
|
||||||
|
import { CatalogDomain } from '../domains/CatalogDomain.js';
|
||||||
|
import { InventoryDomain } from '../domains/InventoryDomain.js';
|
||||||
|
import { ConfigDomain } from '../domains/ConfigDomain.js';
|
||||||
|
import { StorageDomain } from '../domains/StorageDomain.js';
|
||||||
|
import { StoresDomain } from '../domains/StoresDomain.js';
|
||||||
|
|
||||||
|
/** A domain the client can bulk-invalidate (re-login) or reset (logout). */
|
||||||
|
type ManagedDomain = { invalidate(): void; reset(): void };
|
||||||
|
|
||||||
|
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
|
||||||
|
public readonly options: ResolvedRudderClientOptions;
|
||||||
|
|
||||||
|
public readonly auth: AuthService<TConfig>;
|
||||||
|
public readonly leaderboards: LeaderboardsService;
|
||||||
|
public readonly battlePass: BattlePassService;
|
||||||
|
public readonly quests: QuestsService;
|
||||||
|
public readonly effects: Effects;
|
||||||
|
|
||||||
|
// Observable domains: subscribe via `onChange`, read `.data`, or `reload()`.
|
||||||
|
public readonly player: PlayerDomain;
|
||||||
|
public readonly catalog: CatalogDomain;
|
||||||
|
public readonly inventory: InventoryDomain;
|
||||||
|
public readonly remoteConfig: ConfigDomain<TConfig>;
|
||||||
|
public readonly storage: StorageDomain;
|
||||||
|
public readonly stores: StoresDomain;
|
||||||
|
|
||||||
|
private readonly effectsCenter: EffectsCenter;
|
||||||
|
private scenarioRuntime: ScenarioService<TConfig> | null = null;
|
||||||
|
private scenarioRuntimePromise: Promise<ScenarioService<TConfig>> | null = null;
|
||||||
|
private readonly syncEngine: SyncEngine;
|
||||||
|
private readonly loginEvent: string | null;
|
||||||
|
private readonly ctx: RudderContext;
|
||||||
|
private readonly transportContext: RequestContext;
|
||||||
|
private runtimeStarted = false;
|
||||||
|
|
||||||
|
constructor(options: RudderClientOptions) {
|
||||||
|
if (!options.baseUrl) {
|
||||||
|
throw new RudderError('RudderClient: baseUrl is required', {
|
||||||
|
code: SDK_ERROR_INVALID_OPTIONS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!options.projectKey) {
|
||||||
|
throw new RudderError('RudderClient: projectKey is required', {
|
||||||
|
code: SDK_ERROR_INVALID_OPTIONS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() };
|
||||||
|
this.loginEvent =
|
||||||
|
options.runtime?.loginEvent === undefined ? 'player_login' : options.runtime.loginEvent;
|
||||||
|
|
||||||
|
this.transportContext = {
|
||||||
|
baseUrl: this.options.baseUrl,
|
||||||
|
tokenStore: this.options.tokenStore,
|
||||||
|
requestTimeoutMs: this.options.requestTimeoutMs,
|
||||||
|
refreshState: {},
|
||||||
|
onAuthFailure: () => {
|
||||||
|
this.auth.notifySessionExpired();
|
||||||
|
this.stopRuntimeAfterLogout();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.effectsCenter = new EffectsCenter(this.options.onEffectError);
|
||||||
|
this.effects = this.effectsCenter;
|
||||||
|
|
||||||
|
this.ctx = {
|
||||||
|
options: this.options,
|
||||||
|
effects: this.effectsCenter,
|
||||||
|
request: <TResponse>(method: string, path: string, body?: unknown) =>
|
||||||
|
request<TResponse>(method, path, body, this.transportContext),
|
||||||
|
};
|
||||||
|
const ctx = this.ctx;
|
||||||
|
|
||||||
|
this.player = new PlayerDomain(ctx);
|
||||||
|
this.catalog = new CatalogDomain(ctx);
|
||||||
|
this.inventory = new InventoryDomain(ctx, this.catalog);
|
||||||
|
this.remoteConfig = new ConfigDomain<TConfig>(ctx);
|
||||||
|
this.storage = new StorageDomain(ctx);
|
||||||
|
this.stores = new StoresDomain(ctx, () => {
|
||||||
|
this.player.invalidate();
|
||||||
|
this.inventory.invalidate();
|
||||||
|
this.stores.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.leaderboards = new LeaderboardsService(ctx);
|
||||||
|
this.battlePass = new BattlePassService(ctx);
|
||||||
|
this.quests = new QuestsService(ctx);
|
||||||
|
this.syncEngine = new SyncEngine(ctx, [
|
||||||
|
this.player,
|
||||||
|
this.catalog,
|
||||||
|
this.inventory,
|
||||||
|
this.remoteConfig,
|
||||||
|
this.storage,
|
||||||
|
this.stores,
|
||||||
|
]);
|
||||||
|
this.auth = new AuthService<TConfig>(
|
||||||
|
ctx,
|
||||||
|
() => this.startRuntimeAfterLogin(),
|
||||||
|
() => this.stopRuntimeAfterLogout(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down all background work: stops the sync poll (and its
|
||||||
|
* visibilitychange listener), cancels scenario wait timers, and drops cached
|
||||||
|
* state. Call when disposing the client (e.g. on unmount / HMR) to avoid leaks.
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
this.syncEngine.stop();
|
||||||
|
this.scenarioRuntime?.clear();
|
||||||
|
this.resetAll();
|
||||||
|
this.runtimeStarted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
async startRuntimeAfterLogin(): Promise<void> {
|
||||||
|
const runtimePromise = this.ensureScenarioRuntime();
|
||||||
|
if (this.runtimeStarted) {
|
||||||
|
this.scenarioRuntime?.clear();
|
||||||
|
this.invalidateAll();
|
||||||
|
}
|
||||||
|
this.runtimeStarted = true;
|
||||||
|
// These four warms are independent — fetch them concurrently so login
|
||||||
|
// doesn't pay a serial round-trip per entity.
|
||||||
|
await Promise.all([
|
||||||
|
this.remoteConfig.load(),
|
||||||
|
this.player.load(),
|
||||||
|
this.stores.load(),
|
||||||
|
this.catalog.load(),
|
||||||
|
]);
|
||||||
|
const runtime = await runtimePromise;
|
||||||
|
await runtime.restore();
|
||||||
|
if (this.loginEvent) {
|
||||||
|
await this.sendRuntimeEvent(runtime, this.loginEvent);
|
||||||
|
}
|
||||||
|
this.syncEngine.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the scenario engine and plan persistence on first use and caches the
|
||||||
|
* runtime. Dynamic imports keep the engine out of the initial module graph,
|
||||||
|
* so a thin client never pulls the scenario machinery.
|
||||||
|
*/
|
||||||
|
private ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> {
|
||||||
|
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
|
||||||
|
return this.scenarioRuntimePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createScenarioRuntime(): Promise<ScenarioService<TConfig>> {
|
||||||
|
const { ScenarioService } = await import('../scenario/ScenarioService.js');
|
||||||
|
const configured = this.options.runtime?.planStateStore;
|
||||||
|
let planStore: PlanStateStore | null;
|
||||||
|
if (configured !== undefined) {
|
||||||
|
planStore = configured;
|
||||||
|
} else {
|
||||||
|
const { createIndexedDbPlanStateStore } = await import(
|
||||||
|
'../scenario/engine/IndexedDbPlanStore.js'
|
||||||
|
);
|
||||||
|
planStore = createIndexedDbPlanStateStore();
|
||||||
|
}
|
||||||
|
this.scenarioRuntime = new ScenarioService<TConfig>(
|
||||||
|
this.ctx,
|
||||||
|
{
|
||||||
|
player: this.player,
|
||||||
|
inventory: this.inventory,
|
||||||
|
config: this.remoteConfig,
|
||||||
|
stores: this.stores,
|
||||||
|
},
|
||||||
|
this.battlePass,
|
||||||
|
planStore,
|
||||||
|
);
|
||||||
|
return this.scenarioRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendRuntimeEvent(
|
||||||
|
runtime: ScenarioService<TConfig>,
|
||||||
|
event: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await runtime.send(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`RudderClient: scenario event "${event}" failed after login`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
stopRuntimeAfterLogout(): void {
|
||||||
|
this.runtimeStarted = false;
|
||||||
|
this.syncEngine.stop();
|
||||||
|
this.scenarioRuntime?.clear();
|
||||||
|
this.resetAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get managedDomains(): ManagedDomain[] {
|
||||||
|
return [
|
||||||
|
this.player,
|
||||||
|
this.catalog,
|
||||||
|
this.inventory,
|
||||||
|
this.remoteConfig,
|
||||||
|
this.storage,
|
||||||
|
this.stores,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-login: refetch every domain that is in use. */
|
||||||
|
private invalidateAll(): void {
|
||||||
|
for (const domain of this.managedDomains) domain.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logout: drop all cached data. */
|
||||||
|
private resetAll(): void {
|
||||||
|
for (const domain of this.managedDomains) domain.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test/internal access to the scenario runtime, which is deliberately not part
|
||||||
|
* of the public client surface (it is driven through `client.effects`).
|
||||||
|
* Lazily loads the scenario engine on first call.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||||
|
client: RudderClient<TConfig>,
|
||||||
|
): Promise<ScenarioService<TConfig>> {
|
||||||
|
return (
|
||||||
|
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> }
|
||||||
|
).ensureScenarioRuntime();
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { TokenStore } from '../token/TokenStore.js';
|
||||||
|
import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced runtime knobs. Mainly for tests and non-browser hosts; browser
|
||||||
|
* consumers can ignore these and take the defaults.
|
||||||
|
*/
|
||||||
|
export interface RudderRuntimeOptions {
|
||||||
|
/**
|
||||||
|
* Scenario plan persistence backend. `undefined` uses the default
|
||||||
|
* IndexedDB store (created lazily with the scenario runtime); pass `null`
|
||||||
|
* to disable persistence entirely.
|
||||||
|
*/
|
||||||
|
planStateStore?: PlanStateStore | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario event fired automatically after login. `undefined` uses
|
||||||
|
* `'player_login'`; pass `null` to fire nothing.
|
||||||
|
*/
|
||||||
|
loginEvent?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RudderClientOptions {
|
||||||
|
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
|
||||||
|
baseUrl: string;
|
||||||
|
|
||||||
|
/** Project key that identifies the game project. */
|
||||||
|
projectKey: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token store for persisting and retrieving authentication tokens.
|
||||||
|
* Defaults to localStorage with a silent in-memory fallback where
|
||||||
|
* localStorage is unavailable (SSR, private mode).
|
||||||
|
*/
|
||||||
|
tokenStore?: TokenStore;
|
||||||
|
|
||||||
|
/** Per-request timeout in milliseconds (default: 10000). */
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
|
||||||
|
/** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */
|
||||||
|
syncIntervalMs?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when an effect handler (or effect preparation) throws.
|
||||||
|
* Defaults to `console.error`.
|
||||||
|
*/
|
||||||
|
onEffectError?: (error: unknown) => void;
|
||||||
|
|
||||||
|
/** Advanced runtime knobs (plan persistence, login event). */
|
||||||
|
runtime?: RudderRuntimeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client options with all defaults resolved — what `client.options` exposes. */
|
||||||
|
export interface ResolvedRudderClientOptions extends RudderClientOptions {
|
||||||
|
tokenStore: TokenStore;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Typed error hierarchy for the LiveOps Web SDK.
|
||||||
|
*
|
||||||
|
* All errors extend `RudderError`. Network errors, HTTP errors, and
|
||||||
|
* authentication errors have dedicated subclasses for granular handling.
|
||||||
|
* The machine-readable `code` is the server-provided {@link RudderErrorCode}
|
||||||
|
* when known, or any other string for unknown servers / SDK-local codes
|
||||||
|
* (e.g. `sdk/invalid-options` for client configuration errors).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RudderErrorCode } from '../generated/errors.js';
|
||||||
|
|
||||||
|
/** Code carried by SDK errors: a known server code, or any other string. */
|
||||||
|
export type RudderErrorCodeLike = RudderErrorCode | (string & {});
|
||||||
|
|
||||||
|
/** Code for client-side configuration validation failures. */
|
||||||
|
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
|
||||||
|
|
||||||
|
/** Base error class for all SDK errors. */
|
||||||
|
export class RudderError extends Error {
|
||||||
|
public readonly code?: RudderErrorCodeLike;
|
||||||
|
|
||||||
|
constructor(message: string, options?: { cause?: unknown; code?: RudderErrorCodeLike }) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = 'RudderError';
|
||||||
|
this.code = options?.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when a network request fails (fetch throws, timeout, DNS, etc.). */
|
||||||
|
export class RudderNetworkError extends RudderError {
|
||||||
|
constructor(cause: unknown) {
|
||||||
|
super(`Network error: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
||||||
|
this.name = 'RudderNetworkError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when the server returns a non-2xx HTTP status (except 401). */
|
||||||
|
export class RudderHttpError extends RudderError {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly statusText: string,
|
||||||
|
public readonly body: string,
|
||||||
|
code?: RudderErrorCodeLike,
|
||||||
|
) {
|
||||||
|
super(`HTTP ${status} ${statusText}`, { code });
|
||||||
|
this.name = 'RudderHttpError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when the server returns HTTP 401 Unauthorized. */
|
||||||
|
export class RudderAuthError extends RudderHttpError {
|
||||||
|
constructor(statusText: string, body: string, code?: RudderErrorCodeLike) {
|
||||||
|
super(401, statusText, body, code);
|
||||||
|
this.name = 'RudderAuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js';
|
||||||
|
import type { EffectsCenter } from '../effects/EffectsCenter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RudderContext — the low-level surface every feature service depends on:
|
||||||
|
* the HTTP transport, client options, and the effects bus.
|
||||||
|
*
|
||||||
|
* Built once by {@link RudderClient} and injected into each service. Services
|
||||||
|
* depend on this narrow interface instead of on the client itself, so the
|
||||||
|
* module graph stays acyclic (client → services → context, never back).
|
||||||
|
*/
|
||||||
|
export interface RudderContext {
|
||||||
|
readonly options: ResolvedRudderClientOptions;
|
||||||
|
readonly effects: EffectsCenter;
|
||||||
|
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Device ID provider — generates and persists a unique device identifier.
|
||||||
|
*
|
||||||
|
* Uses `crypto.randomUUID()` (available in all evergreen browsers) and
|
||||||
|
* stores the generated UUID in localStorage under `rudder_device_id`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'rudder_device_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored device ID or creates a new one if none exists.
|
||||||
|
* The device ID is persisted in localStorage and survives page reloads.
|
||||||
|
*/
|
||||||
|
export function getOrCreateDeviceId(): string {
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable — generate ephemeral ID.
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable — ID will be ephemeral for this session.
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { SyncedState } from '../state/SyncedState.js';
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type { CatalogItem } from '../generated/catalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CatalogDomain — the item catalog, keyed by slug. Synced under revision
|
||||||
|
* key `catalog`. Feeds the merged {@link InventoryDomain} view.
|
||||||
|
*/
|
||||||
|
export class CatalogDomain extends SyncedState<Map<string, CatalogItem>> {
|
||||||
|
readonly syncKey = 'catalog';
|
||||||
|
|
||||||
|
constructor(ctx: RudderContext) {
|
||||||
|
super(async () => {
|
||||||
|
const response = await api.listCatalogItems(ctx);
|
||||||
|
const map = new Map<string, CatalogItem>();
|
||||||
|
for (const item of response.items ?? []) {
|
||||||
|
if (!item.slug) continue;
|
||||||
|
map.set(item.slug, item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { RemoteConfigState, type RemoteConfigShape } from '../state/RemoteConfigState.js';
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type { RemoteConfig } from '../generated/remote-config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConfigDomain — typed remote configuration as an observable entity. Synced
|
||||||
|
* under revision key `config`. Extends {@link RemoteConfigState}, which adds
|
||||||
|
* typed `get()` and scenario override support.
|
||||||
|
*/
|
||||||
|
export class ConfigDomain<
|
||||||
|
TConfig extends RemoteConfigShape = RemoteConfigShape,
|
||||||
|
> extends RemoteConfigState<TConfig> {
|
||||||
|
readonly syncKey = 'config';
|
||||||
|
|
||||||
|
constructor(ctx: RudderContext) {
|
||||||
|
super(async () => {
|
||||||
|
const response = await api.listSdkRemoteConfigs(ctx);
|
||||||
|
const map = new Map<string, RemoteConfig>();
|
||||||
|
if (response.configs) {
|
||||||
|
for (const [key, config] of Object.entries(response.configs)) {
|
||||||
|
// The server already filters inactive configs out (the game cache skips
|
||||||
|
// !active) and may omit `active` on the wire, so keep anything that is
|
||||||
|
// not explicitly inactive instead of dropping configs that carry no flag.
|
||||||
|
if (config && config.active !== false) {
|
||||||
|
map.set(config.key ?? key, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { SyncedState } from '../state/SyncedState.js';
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import { mergeInventory, type InventoryItem } from '../state/inventory.js';
|
||||||
|
import type { CatalogDomain } from './CatalogDomain.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InventoryDomain — owned items merged with their catalog entries. Synced
|
||||||
|
* under revision key `inventory`; also refreshes when the catalog changes.
|
||||||
|
*/
|
||||||
|
export class InventoryDomain extends SyncedState<InventoryItem[]> {
|
||||||
|
readonly syncKey = 'inventory';
|
||||||
|
|
||||||
|
constructor(ctx: RudderContext, catalog: CatalogDomain) {
|
||||||
|
super(async () => {
|
||||||
|
const [response, catalogItems] = await Promise.all([
|
||||||
|
api.getInventory(ctx),
|
||||||
|
catalog.load(),
|
||||||
|
]);
|
||||||
|
return mergeInventory(response.items ?? [], catalogItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catalog changes flow into the merged inventory view.
|
||||||
|
let previousCatalog = catalog.data;
|
||||||
|
catalog.onChange((snapshot) => {
|
||||||
|
if (snapshot.data === previousCatalog) return;
|
||||||
|
previousCatalog = snapshot.data;
|
||||||
|
this.invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { SyncedState } from '../state/SyncedState.js';
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type { PlayerProfile } from '../generated/player.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayerDomain — the observable player profile (identity + wallets).
|
||||||
|
* Synced under revision key `profile`.
|
||||||
|
*/
|
||||||
|
export class PlayerDomain extends SyncedState<PlayerProfile> {
|
||||||
|
readonly syncKey = 'profile';
|
||||||
|
|
||||||
|
constructor(ctx: RudderContext) {
|
||||||
|
super(() => api.getPlayerInformation(ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { SyncedState } from '../state/SyncedState.js';
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type { GetStorageResponse, StorageItem } from '../generated/storage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StorageDomain — player key/value storage as an observable entity, plus its
|
||||||
|
* mutations. Synced under revision key `storage`; mutations invalidate it so
|
||||||
|
* subscribers observe fresh data.
|
||||||
|
*/
|
||||||
|
export class StorageDomain extends SyncedState<GetStorageResponse> {
|
||||||
|
readonly syncKey = 'storage';
|
||||||
|
|
||||||
|
constructor(private readonly ctx: RudderContext) {
|
||||||
|
super(() => api.getStorage(ctx, { limit: 100 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves player storage items, then invalidates. */
|
||||||
|
async save(items: StorageItem[]): Promise<void> {
|
||||||
|
await api.updateStorage(this.ctx, { items });
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes player storage items of the given type, then invalidates. */
|
||||||
|
async delete(type: string): Promise<void> {
|
||||||
|
await api.deleteStorage(this.ctx, { type });
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { SyncedState } from '../state/SyncedState.js';
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import { ShopHandle, type BuyOptions, type PurchaseOffer } from '../state/shops.js';
|
||||||
|
import type { PurchaseOfferResponse } from '../generated/stores.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StoresDomain — the observable list of stores, plus the purchase executor
|
||||||
|
* that every offer/store handle shares. Synced under revision key `stores`.
|
||||||
|
*
|
||||||
|
* A successful purchase runs `onPurchase`, which the client wires to invalidate
|
||||||
|
* the profile, inventory, and store list so subscribers observe fresh data.
|
||||||
|
*/
|
||||||
|
export class StoresDomain extends SyncedState<ShopHandle[]> {
|
||||||
|
readonly syncKey = 'stores';
|
||||||
|
|
||||||
|
/** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */
|
||||||
|
readonly purchase: PurchaseOffer;
|
||||||
|
|
||||||
|
private readonly ctx: RudderContext;
|
||||||
|
|
||||||
|
constructor(ctx: RudderContext, onPurchase: () => void) {
|
||||||
|
// Built before super() so it captures params, not `this`; the store/offer
|
||||||
|
// handles created in the loader all delegate to this one executor.
|
||||||
|
const purchase: PurchaseOffer = async (
|
||||||
|
storeSlug: string,
|
||||||
|
offerId: string,
|
||||||
|
options: BuyOptions = {},
|
||||||
|
): Promise<PurchaseOfferResponse> => {
|
||||||
|
const response = await api.purchaseOffer(
|
||||||
|
ctx,
|
||||||
|
{ storeSlug, offerId },
|
||||||
|
{
|
||||||
|
storeSlug,
|
||||||
|
offerId,
|
||||||
|
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
onPurchase();
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
super(async () => {
|
||||||
|
const response = await api.listSdkStores(ctx);
|
||||||
|
return (response.stores ?? []).map((store) => new ShopHandle(purchase, store));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.purchase = purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a store by slug (cache-first). Used by scenario store nodes.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
async getBySlug(slug: string): Promise<ShopHandle> {
|
||||||
|
const cached = this.data?.find((store) => store.slug === slug);
|
||||||
|
if (cached) return cached;
|
||||||
|
const store = await api.getStore(this.ctx, { slug });
|
||||||
|
return new ShopHandle(this.purchase, store);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import type { PurchaseOfferResponse } from '../generated/stores.js';
|
||||||
|
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js';
|
||||||
|
import type {
|
||||||
|
BattlePassLevelSession,
|
||||||
|
BattlePassSession,
|
||||||
|
LeaderboardSession,
|
||||||
|
NotificationSession,
|
||||||
|
QuestSession,
|
||||||
|
StoreSession,
|
||||||
|
WaitSession,
|
||||||
|
} from '../scenario/engine/sessions.js';
|
||||||
|
import type { PlanRun, ScenarioRunFailedEvent } from '../scenario/engine/types.js';
|
||||||
|
import type {
|
||||||
|
AddBattlePassXpResponse,
|
||||||
|
ClaimBattlePassRewardResponse,
|
||||||
|
GetBattlePassProgressResponse,
|
||||||
|
PurchaseBattlePassPremiumResponse,
|
||||||
|
} from '../generated/battlepass.js';
|
||||||
|
|
||||||
|
export type EffectUnsubscribe = () => void;
|
||||||
|
|
||||||
|
export interface NotificationEffect {
|
||||||
|
readonly title: string;
|
||||||
|
readonly message: string;
|
||||||
|
done(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreOfferEffect {
|
||||||
|
readonly store: ShopHandle;
|
||||||
|
readonly offers: readonly OfferHandle[];
|
||||||
|
readonly message?: string;
|
||||||
|
buy(offer: OfferHandle, options?: BuyOptions): Promise<PurchaseOfferResponse>;
|
||||||
|
dismiss(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardEffect {
|
||||||
|
end(): Promise<void>;
|
||||||
|
rewardClaimed(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigChangedEffect {
|
||||||
|
readonly key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scenario wait node became active; the run resumes at `deadlineUtc`. */
|
||||||
|
export interface WaitEffect {
|
||||||
|
readonly deadlineUtc: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scenario run reached a terminal end successfully. */
|
||||||
|
export interface ScenarioCompletedEffect {
|
||||||
|
readonly runId: string;
|
||||||
|
readonly scenarioId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scenario run failed at a node (transport gave up, or the node type is not
|
||||||
|
* supported by this SDK). Surfaced so the game can react instead of the failure
|
||||||
|
* being swallowed into a console warning.
|
||||||
|
*/
|
||||||
|
export interface ScenarioFailedEffect {
|
||||||
|
readonly runId: string;
|
||||||
|
readonly scenarioId: string;
|
||||||
|
readonly nodeId: string;
|
||||||
|
readonly error: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scenario quest node became active. Report objective progress; the node
|
||||||
|
* auto-completes server-side once every objective is satisfied.
|
||||||
|
*/
|
||||||
|
export interface QuestEffect {
|
||||||
|
readonly name: string;
|
||||||
|
readonly objectives: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
reportProgress(objectiveId: string, amount?: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scenario battle pass node became active. */
|
||||||
|
export interface BattlePassEffect {
|
||||||
|
getProgress(): Promise<GetBattlePassProgressResponse>;
|
||||||
|
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
|
||||||
|
claimReward(level: number, track?: string): Promise<ClaimBattlePassRewardResponse>;
|
||||||
|
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
|
||||||
|
levelUp(): Promise<void>;
|
||||||
|
end(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scenario battlepass_level node became active (a single claimable tier). */
|
||||||
|
export interface BattlePassLevelEffect {
|
||||||
|
readonly level: number;
|
||||||
|
claim(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
|
||||||
|
|
||||||
|
export interface Effects {
|
||||||
|
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
|
||||||
|
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe;
|
||||||
|
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe;
|
||||||
|
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe;
|
||||||
|
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
|
||||||
|
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
|
||||||
|
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
|
||||||
|
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe;
|
||||||
|
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe;
|
||||||
|
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EffectsCenter implements Effects {
|
||||||
|
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
|
||||||
|
private readonly storeOfferHandlers = new Set<EffectHandler<StoreOfferEffect>>();
|
||||||
|
private readonly leaderboardHandlers = new Set<EffectHandler<LeaderboardEffect>>();
|
||||||
|
private readonly configChangedHandlers = new Set<EffectHandler<ConfigChangedEffect>>();
|
||||||
|
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
|
||||||
|
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
|
||||||
|
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
|
||||||
|
private readonly questHandlers = new Set<EffectHandler<QuestEffect>>();
|
||||||
|
private readonly battlePassHandlers = new Set<EffectHandler<BattlePassEffect>>();
|
||||||
|
private readonly battlePassLevelHandlers = new Set<EffectHandler<BattlePassLevelEffect>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly onError: (error: unknown) => void = (error) =>
|
||||||
|
console.error('[Rudder] Effect handler failed', error),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.notificationHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.storeOfferHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.leaderboardHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.configChangedHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.waitHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.scenarioCompletedHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.scenarioFailedHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.questHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.battlePassHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe {
|
||||||
|
return this.addHandler(this.battlePassLevelHandlers, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitNotification(session: NotificationSession): void {
|
||||||
|
this.emit(this.notificationHandlers, {
|
||||||
|
title: session.title,
|
||||||
|
message: session.message,
|
||||||
|
done: () => session.complete(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitStoreOffer(session: StoreSession): void {
|
||||||
|
session.getStore()
|
||||||
|
.then((store) => {
|
||||||
|
this.emit(this.storeOfferHandlers, {
|
||||||
|
store,
|
||||||
|
offers: store.offers,
|
||||||
|
message: session.get('message', undefined as string | undefined),
|
||||||
|
buy: (offer, options) => session.buy(offer, options),
|
||||||
|
dismiss: () => session.decline(),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => this.onError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitLeaderboard(session: LeaderboardSession): void {
|
||||||
|
this.emit(this.leaderboardHandlers, {
|
||||||
|
end: () => session.end(),
|
||||||
|
rewardClaimed: () => session.rewardClaimed(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitConfigChanged(key: string): void {
|
||||||
|
this.emit(this.configChangedHandlers, { key });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitWait(session: WaitSession): void {
|
||||||
|
this.emit(this.waitHandlers, { deadlineUtc: session.deadlineUtc });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitScenarioCompleted(run: PlanRun): void {
|
||||||
|
this.emit(this.scenarioCompletedHandlers, {
|
||||||
|
runId: run.runId,
|
||||||
|
scenarioId: run.scenarioId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitScenarioFailed(event: ScenarioRunFailedEvent): void {
|
||||||
|
this.emit(this.scenarioFailedHandlers, {
|
||||||
|
runId: event.run.runId,
|
||||||
|
scenarioId: event.run.scenarioId,
|
||||||
|
nodeId: event.nodeId,
|
||||||
|
error: event.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitQuest(session: QuestSession): void {
|
||||||
|
this.emit(this.questHandlers, {
|
||||||
|
name: session.name,
|
||||||
|
objectives: session.objectives,
|
||||||
|
reportProgress: (objectiveId, amount) => session.reportProgress(objectiveId, amount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitBattlePass(session: BattlePassSession): void {
|
||||||
|
this.emit(this.battlePassHandlers, {
|
||||||
|
getProgress: () => session.getProgress(),
|
||||||
|
addXp: (source, amount) => session.addXp(source, amount),
|
||||||
|
claimReward: (level, track) => session.claimReward(level, track),
|
||||||
|
purchasePremium: () => session.purchasePremium(),
|
||||||
|
levelUp: () => session.levelUp(),
|
||||||
|
end: () => session.end(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitBattlePassLevel(session: BattlePassLevelSession): void {
|
||||||
|
this.emit(this.battlePassLevelHandlers, {
|
||||||
|
level: session.level,
|
||||||
|
claim: () => session.claim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addHandler<TEffect>(
|
||||||
|
handlers: Set<EffectHandler<TEffect>>,
|
||||||
|
handler: EffectHandler<TEffect>,
|
||||||
|
): EffectUnsubscribe {
|
||||||
|
handlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<TEffect>(
|
||||||
|
handlers: Set<EffectHandler<TEffect>>,
|
||||||
|
effect: TEffect,
|
||||||
|
): void {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
Promise.resolve(handler(effect)).catch((error) => this.onError(error));
|
||||||
|
} catch (error) {
|
||||||
|
this.onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
import type { LoginViaDeviceRequest, LoginViaDeviceResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse } from './auth.js';
|
||||||
|
import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js';
|
||||||
|
import type { ListCatalogItemsResponse } from './catalog.js';
|
||||||
|
import type { GetInventoryResponse } from './inventory.js';
|
||||||
|
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
|
||||||
|
import type { PlayerProfile } from './player.js';
|
||||||
|
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsRequest, ListQuestsResponse } from './quests.js';
|
||||||
|
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
|
||||||
|
import type { GetScenarioRunRequest, GetScenarioRunResponse, HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
|
||||||
|
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
|
||||||
|
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
|
||||||
|
import type { DeleteUgcResponse, GetDownloadUrlResponse, GetUploadUrlResponse, ListUgcResponse, SubmitUgcRequest, UgcSubmission } from './ugc.js';
|
||||||
|
|
||||||
|
/** The minimal transport contract the generated API functions call through. */
|
||||||
|
export interface Transport {
|
||||||
|
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(value: string | number): string {
|
||||||
|
return encodeURIComponent(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function qs(
|
||||||
|
params: Array<[string, string | number | boolean | null | undefined]>,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [key, value] of params) {
|
||||||
|
if (value !== null && value !== undefined && value !== '') {
|
||||||
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length === 0 ? '' : `?${parts.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed request functions, one per SDK operation. */
|
||||||
|
export const api = {
|
||||||
|
addBattlePassXp: (
|
||||||
|
t: Transport,
|
||||||
|
body: AddBattlePassXpRequest,
|
||||||
|
): Promise<AddBattlePassXpResponse> =>
|
||||||
|
t.request<AddBattlePassXpResponse>('POST', '/sdk/v1/battlepass/xp', body),
|
||||||
|
|
||||||
|
claimBattlePassReward: (
|
||||||
|
t: Transport,
|
||||||
|
body: ClaimBattlePassRewardRequest,
|
||||||
|
): Promise<ClaimBattlePassRewardResponse> =>
|
||||||
|
t.request<ClaimBattlePassRewardResponse>('POST', '/sdk/v1/battlepass/claim', body),
|
||||||
|
|
||||||
|
claimQuest: (
|
||||||
|
t: Transport,
|
||||||
|
body: ClaimQuestRequest,
|
||||||
|
): Promise<ClaimQuestResponse> =>
|
||||||
|
t.request<ClaimQuestResponse>('POST', '/sdk/v1/quests/claim', body),
|
||||||
|
|
||||||
|
deleteStorage: (
|
||||||
|
t: Transport,
|
||||||
|
query?: { type?: string },
|
||||||
|
): Promise<void> =>
|
||||||
|
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
|
||||||
|
|
||||||
|
deleteUgc: (
|
||||||
|
t: Transport,
|
||||||
|
path: { id: string },
|
||||||
|
): Promise<DeleteUgcResponse> =>
|
||||||
|
t.request<DeleteUgcResponse>('DELETE', `/sdk/v1/ugc/${enc(path.id)}`),
|
||||||
|
|
||||||
|
getBattlePassProgress: (
|
||||||
|
t: Transport,
|
||||||
|
body: GetBattlePassProgressRequest,
|
||||||
|
): Promise<GetBattlePassProgressResponse> =>
|
||||||
|
t.request<GetBattlePassProgressResponse>('POST', '/sdk/v1/battlepass/progress', body),
|
||||||
|
|
||||||
|
getDownloadUrl: (
|
||||||
|
t: Transport,
|
||||||
|
path: { id: string },
|
||||||
|
): Promise<GetDownloadUrlResponse> =>
|
||||||
|
t.request<GetDownloadUrlResponse>('GET', `/sdk/v1/ugc/${enc(path.id)}/download`),
|
||||||
|
|
||||||
|
getInventory: (
|
||||||
|
t: Transport,
|
||||||
|
): Promise<GetInventoryResponse> =>
|
||||||
|
t.request<GetInventoryResponse>('GET', '/sdk/v1/inventory'),
|
||||||
|
|
||||||
|
getPlayerInformation: (
|
||||||
|
t: Transport,
|
||||||
|
): Promise<PlayerProfile> =>
|
||||||
|
t.request<PlayerProfile>('GET', '/sdk/v1/player/information'),
|
||||||
|
|
||||||
|
getRanking: (
|
||||||
|
t: Transport,
|
||||||
|
path: { slug: string },
|
||||||
|
query?: { limit?: number },
|
||||||
|
): Promise<GetRankingResponse> =>
|
||||||
|
t.request<GetRankingResponse>('GET', `/sdk/v1/leaderboards/${enc(path.slug)}/ranking${qs([['limit', query?.limit]])}`),
|
||||||
|
|
||||||
|
getRemoteConfig: (
|
||||||
|
t: Transport,
|
||||||
|
path: { key: string },
|
||||||
|
): Promise<RemoteConfig> =>
|
||||||
|
t.request<RemoteConfig>('GET', `/sdk/v1/remote-configs/${enc(path.key)}`),
|
||||||
|
|
||||||
|
getRevisions: (
|
||||||
|
t: Transport,
|
||||||
|
): Promise<{ [key: string]: number }> =>
|
||||||
|
t.request<{ [key: string]: number }>('GET', '/sdk/v1/sync'),
|
||||||
|
|
||||||
|
getScenarioRun: (
|
||||||
|
t: Transport,
|
||||||
|
body: GetScenarioRunRequest,
|
||||||
|
): Promise<GetScenarioRunResponse> =>
|
||||||
|
t.request<GetScenarioRunResponse>('POST', '/sdk/v1/scenarios/run', body),
|
||||||
|
|
||||||
|
getStorage: (
|
||||||
|
t: Transport,
|
||||||
|
query?: { types?: string; limit?: number; cursor?: string },
|
||||||
|
): Promise<GetStorageResponse> =>
|
||||||
|
t.request<GetStorageResponse>('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
||||||
|
|
||||||
|
getStore: (
|
||||||
|
t: Transport,
|
||||||
|
path: { slug: string },
|
||||||
|
): Promise<Store> =>
|
||||||
|
t.request<Store>('GET', `/sdk/v1/stores/${enc(path.slug)}`),
|
||||||
|
|
||||||
|
getUgc: (
|
||||||
|
t: Transport,
|
||||||
|
path: { id: string },
|
||||||
|
): Promise<UgcSubmission> =>
|
||||||
|
t.request<UgcSubmission>('GET', `/sdk/v1/ugc/${enc(path.id)}`),
|
||||||
|
|
||||||
|
getUploadUrl: (
|
||||||
|
t: Transport,
|
||||||
|
query?: { filename?: string },
|
||||||
|
): Promise<GetUploadUrlResponse> =>
|
||||||
|
t.request<GetUploadUrlResponse>('GET', `/sdk/v1/ugc/upload-url${qs([['filename', query?.filename]])}`),
|
||||||
|
|
||||||
|
handleScenarioCallback: (
|
||||||
|
t: Transport,
|
||||||
|
body: HandleScenarioCallbackRequest,
|
||||||
|
): Promise<HandleScenarioCallbackResponse> =>
|
||||||
|
t.request<HandleScenarioCallbackResponse>('POST', '/sdk/v1/scenarios/callback', body),
|
||||||
|
|
||||||
|
listCatalogItems: (
|
||||||
|
t: Transport,
|
||||||
|
): Promise<ListCatalogItemsResponse> =>
|
||||||
|
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
|
||||||
|
|
||||||
|
listQuests: (
|
||||||
|
t: Transport,
|
||||||
|
body: ListQuestsRequest,
|
||||||
|
): Promise<ListQuestsResponse> =>
|
||||||
|
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list', body),
|
||||||
|
|
||||||
|
listSdkRemoteConfigs: (
|
||||||
|
t: Transport,
|
||||||
|
): Promise<ListRemoteConfigsResponse> =>
|
||||||
|
t.request<ListRemoteConfigsResponse>('GET', '/sdk/v1/remote-configs'),
|
||||||
|
|
||||||
|
listSdkStores: (
|
||||||
|
t: Transport,
|
||||||
|
): Promise<ListStoresResponse> =>
|
||||||
|
t.request<ListStoresResponse>('GET', '/sdk/v1/stores'),
|
||||||
|
|
||||||
|
listUgc: (
|
||||||
|
t: Transport,
|
||||||
|
query?: { status?: string; limit?: number; cursor?: string },
|
||||||
|
): Promise<ListUgcResponse> =>
|
||||||
|
t.request<ListUgcResponse>('GET', `/sdk/v1/ugc${qs([['status', query?.status], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
||||||
|
|
||||||
|
loginViaDevice: (
|
||||||
|
t: Transport,
|
||||||
|
body: LoginViaDeviceRequest,
|
||||||
|
): Promise<LoginViaDeviceResponse> =>
|
||||||
|
t.request<LoginViaDeviceResponse>('POST', '/sdk/v1/authorization/device', body),
|
||||||
|
|
||||||
|
purchaseBattlePassPremium: (
|
||||||
|
t: Transport,
|
||||||
|
body: PurchaseBattlePassPremiumRequest,
|
||||||
|
): Promise<PurchaseBattlePassPremiumResponse> =>
|
||||||
|
t.request<PurchaseBattlePassPremiumResponse>('POST', '/sdk/v1/battlepass/premium', body),
|
||||||
|
|
||||||
|
purchaseOffer: (
|
||||||
|
t: Transport,
|
||||||
|
path: { storeSlug: string; offerId: string },
|
||||||
|
body: PurchaseOfferRequest,
|
||||||
|
): Promise<PurchaseOfferResponse> =>
|
||||||
|
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerId)}/purchase`, body),
|
||||||
|
|
||||||
|
refreshAccessToken: (
|
||||||
|
t: Transport,
|
||||||
|
body: RefreshAccessTokenRequest,
|
||||||
|
): Promise<RefreshAccessTokenResponse> =>
|
||||||
|
t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body),
|
||||||
|
|
||||||
|
submitScore: (
|
||||||
|
t: Transport,
|
||||||
|
path: { slug: string },
|
||||||
|
body: SubmitScoreRequest,
|
||||||
|
): Promise<void> =>
|
||||||
|
t.request<void>('POST', `/sdk/v1/leaderboards/${enc(path.slug)}/submit-score`, body),
|
||||||
|
|
||||||
|
submitUgc: (
|
||||||
|
t: Transport,
|
||||||
|
body: SubmitUgcRequest,
|
||||||
|
): Promise<UgcSubmission> =>
|
||||||
|
t.request<UgcSubmission>('POST', '/sdk/v1/ugc', body),
|
||||||
|
|
||||||
|
triggerScenario: (
|
||||||
|
t: Transport,
|
||||||
|
body: TriggerScenarioRequest,
|
||||||
|
): Promise<TriggerScenarioResponse> =>
|
||||||
|
t.request<TriggerScenarioResponse>('POST', '/sdk/v1/scenarios/trigger', body),
|
||||||
|
|
||||||
|
updateScenarioCounter: (
|
||||||
|
t: Transport,
|
||||||
|
body: UpdateScenarioCounterRequest,
|
||||||
|
): Promise<UpdateScenarioCounterResponse> =>
|
||||||
|
t.request<UpdateScenarioCounterResponse>('POST', '/sdk/v1/scenarios/counter', body),
|
||||||
|
|
||||||
|
updateStorage: (
|
||||||
|
t: Transport,
|
||||||
|
body: UpdateStorageRequest,
|
||||||
|
): Promise<void> =>
|
||||||
|
t.request<void>('PUT', '/sdk/v1/storage', body),
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface LoginViaDeviceRequest {
|
||||||
|
"deviceId"?: string;
|
||||||
|
"key"?: string;
|
||||||
|
"language"?: string;
|
||||||
|
"nickname"?: string;
|
||||||
|
"region"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginViaDeviceResponse {
|
||||||
|
"accessToken"?: string;
|
||||||
|
"refreshToken"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshAccessTokenRequest {
|
||||||
|
"refreshToken"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshAccessTokenResponse {
|
||||||
|
"accessToken"?: string;
|
||||||
|
"refreshToken"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
import type { ExecutionPlan } from './common.js';
|
||||||
|
|
||||||
|
export interface AddBattlePassXpRequest {
|
||||||
|
"amount"?: number;
|
||||||
|
"nodeId"?: string;
|
||||||
|
"runId"?: string;
|
||||||
|
"scenarioId"?: string;
|
||||||
|
"source"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddBattlePassXpResponse {
|
||||||
|
"level"?: number;
|
||||||
|
"leveledUp"?: boolean;
|
||||||
|
"maxLevel"?: boolean;
|
||||||
|
"plan"?: ExecutionPlan;
|
||||||
|
"xp"?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattlePassReward {
|
||||||
|
"amount"?: number;
|
||||||
|
"currency"?: string;
|
||||||
|
"itemId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimBattlePassRewardRequest {
|
||||||
|
"level"?: number;
|
||||||
|
"nodeId"?: string;
|
||||||
|
"runId"?: string;
|
||||||
|
"scenarioId"?: string;
|
||||||
|
"track"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimBattlePassRewardResponse {
|
||||||
|
"alreadyClaimed"?: boolean;
|
||||||
|
"error"?: string;
|
||||||
|
"granted"?: BattlePassReward[];
|
||||||
|
"success"?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimedTier {
|
||||||
|
"level"?: number;
|
||||||
|
"track"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetBattlePassProgressRequest {
|
||||||
|
"nodeId"?: string;
|
||||||
|
"scenarioId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetBattlePassProgressResponse {
|
||||||
|
"claimedTiers"?: ClaimedTier[];
|
||||||
|
"level"?: number;
|
||||||
|
"premiumOwned"?: boolean;
|
||||||
|
"xp"?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseBattlePassPremiumRequest {
|
||||||
|
"idempotencyKey"?: string;
|
||||||
|
"nodeId"?: string;
|
||||||
|
"runId"?: string;
|
||||||
|
"scenarioId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseBattlePassPremiumResponse {
|
||||||
|
"error"?: string;
|
||||||
|
"plan"?: ExecutionPlan;
|
||||||
|
"success"?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface CatalogItem {
|
||||||
|
"name"?: string;
|
||||||
|
"properties"?: { [key: string]: unknown };
|
||||||
|
"slug"?: string;
|
||||||
|
"tags"?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListCatalogItemsResponse {
|
||||||
|
"items"?: CatalogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface BoundaryNode {
|
||||||
|
"callbackUrl"?: string;
|
||||||
|
"enforcement"?: "client" | "server";
|
||||||
|
"enteredAt"?: string;
|
||||||
|
"nodeId"?: string;
|
||||||
|
"sourceHandle"?: string;
|
||||||
|
"sourceNodeId"?: string;
|
||||||
|
"waitDeadline"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
"code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
|
||||||
|
"error"?: string;
|
||||||
|
"requestId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionPlan {
|
||||||
|
"boundaryNodes"?: BoundaryNode[];
|
||||||
|
"context"?: { [key: string]: unknown };
|
||||||
|
"edges"?: PlanEdge[];
|
||||||
|
"nodes"?: ExecutionPlanNode[];
|
||||||
|
"planId"?: string;
|
||||||
|
"runId"?: string;
|
||||||
|
"scenarioId"?: string;
|
||||||
|
"startNodeId"?: string;
|
||||||
|
"userId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionPlanNode {
|
||||||
|
"data"?: { [key: string]: unknown };
|
||||||
|
"id"?: string;
|
||||||
|
"type"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanEdge {
|
||||||
|
"id"?: string;
|
||||||
|
"source"?: string;
|
||||||
|
"sourceHandle"?: string;
|
||||||
|
"target"?: string;
|
||||||
|
"targetHandle"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export type RudderErrorCode = "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
|
||||||
|
|
||||||
|
export const RudderErrorCodes = {
|
||||||
|
earlyCompletion: "early_completion",
|
||||||
|
forbidden: "forbidden",
|
||||||
|
levelNotReached: "level_not_reached",
|
||||||
|
nodeNotActive: "node_not_active",
|
||||||
|
objectivesIncomplete: "objectives_incomplete",
|
||||||
|
runExpired: "run_expired",
|
||||||
|
runNotActive: "run_not_active",
|
||||||
|
scenarioNotActive: "scenario_not_active",
|
||||||
|
unknownRun: "unknown_run",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export * from './auth.js';
|
||||||
|
export * from './battlepass.js';
|
||||||
|
export * from './catalog.js';
|
||||||
|
export * from './common.js';
|
||||||
|
export * from './inventory.js';
|
||||||
|
export * from './leaderboards.js';
|
||||||
|
export * from './player.js';
|
||||||
|
export * from './quests.js';
|
||||||
|
export * from './remote-config.js';
|
||||||
|
export * from './scenarios.js';
|
||||||
|
export * from './storage.js';
|
||||||
|
export * from './stores.js';
|
||||||
|
export * from './ugc.js';
|
||||||
|
export * from './api.js';
|
||||||
|
export * from './errors.js';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface GetInventoryResponse {
|
||||||
|
"items"?: PlayerInventoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerInventoryItem {
|
||||||
|
"amount"?: number;
|
||||||
|
"nameOverride"?: string;
|
||||||
|
"propertiesOverride"?: { [key: string]: unknown };
|
||||||
|
"slug"?: string;
|
||||||
|
"updatedAt"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface GetRankingResponse {
|
||||||
|
"entries"?: RankEntry[];
|
||||||
|
"total"?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankEntry {
|
||||||
|
"playerId"?: string;
|
||||||
|
"playerName"?: string;
|
||||||
|
"rank"?: number;
|
||||||
|
"score"?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitScoreRequest {
|
||||||
|
"score"?: number;
|
||||||
|
"slug"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
"createdAt"?: string;
|
||||||
|
"id"?: string;
|
||||||
|
"language"?: string;
|
||||||
|
"nickname"?: string;
|
||||||
|
"projectId"?: string;
|
||||||
|
"region"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerProfile {
|
||||||
|
"player"?: Player;
|
||||||
|
"wallets"?: Wallet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wallet {
|
||||||
|
"balance"?: number;
|
||||||
|
"currency"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface ClaimQuestRequest {
|
||||||
|
"questId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimQuestResponse {
|
||||||
|
"alreadyClaimed"?: boolean;
|
||||||
|
"error"?: string;
|
||||||
|
"granted"?: QuestReward[];
|
||||||
|
"success"?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListQuestsRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListQuestsResponse {
|
||||||
|
"quests"?: Quest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quest {
|
||||||
|
"id"?: string;
|
||||||
|
"name"?: string;
|
||||||
|
"objectives"?: QuestObjectiveProgress[];
|
||||||
|
"rewards"?: QuestReward[];
|
||||||
|
"status"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestObjectiveProgress {
|
||||||
|
"completed"?: boolean;
|
||||||
|
"current"?: number;
|
||||||
|
"metric"?: string;
|
||||||
|
"objectiveId"?: string;
|
||||||
|
"target"?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestReward {
|
||||||
|
"amount"?: number;
|
||||||
|
"currency"?: string;
|
||||||
|
"itemId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface ListRemoteConfigsResponse {
|
||||||
|
"configs"?: { [key: string]: RemoteConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteConfig {
|
||||||
|
"active"?: boolean;
|
||||||
|
"createdAt"?: string;
|
||||||
|
"description"?: string;
|
||||||
|
"environment"?: string;
|
||||||
|
"id"?: string;
|
||||||
|
"key"?: string;
|
||||||
|
"projectId"?: string;
|
||||||
|
"updatedAt"?: string;
|
||||||
|
"value"?: string;
|
||||||
|
"valueType"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// 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 {
|
||||||
|
"handle"?: string;
|
||||||
|
"nodeId"?: string;
|
||||||
|
"runId"?: string;
|
||||||
|
"scenarioId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandleScenarioCallbackResponse {
|
||||||
|
"plan"?: ExecutionPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerScenarioRequest {
|
||||||
|
"event"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerScenarioResponse {
|
||||||
|
"plans"?: ExecutionPlan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScenarioCounterRequest {
|
||||||
|
"amount"?: number;
|
||||||
|
"counterKey"?: string;
|
||||||
|
"nodeId"?: string;
|
||||||
|
"runId"?: string;
|
||||||
|
"scenarioId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScenarioCounterResponse {
|
||||||
|
"completed"?: boolean;
|
||||||
|
"plan"?: ExecutionPlan;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface GetStorageResponse {
|
||||||
|
"items"?: StorageItem[];
|
||||||
|
"nextCursor"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageItem {
|
||||||
|
"data"?: string;
|
||||||
|
"id"?: string;
|
||||||
|
"type"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStorageRequest {
|
||||||
|
"idempotencyKey"?: string;
|
||||||
|
"items"?: StorageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface ListStoresResponse {
|
||||||
|
"stores"?: Store[];
|
||||||
|
"total"?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Offer {
|
||||||
|
"contents"?: OfferContent[];
|
||||||
|
"createdAt"?: string;
|
||||||
|
"id"?: string;
|
||||||
|
"maxPurchases"?: number;
|
||||||
|
"name"?: string;
|
||||||
|
"price"?: OfferPrice;
|
||||||
|
"updatedAt"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferContent {
|
||||||
|
"amount"?: number;
|
||||||
|
"itemId"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferPrice {
|
||||||
|
"amount"?: number;
|
||||||
|
"currency"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOfferRequest {
|
||||||
|
"idempotencyKey"?: string;
|
||||||
|
"offerId"?: string;
|
||||||
|
"storeSlug"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOfferResponse {
|
||||||
|
"error"?: string;
|
||||||
|
"purchaseId"?: string;
|
||||||
|
"success"?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
export interface DeleteUgcResponse {
|
||||||
|
"success"?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDownloadUrlResponse {
|
||||||
|
"downloadUrl"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetUploadUrlResponse {
|
||||||
|
"fileKey"?: string;
|
||||||
|
"uploadUrl"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListUgcResponse {
|
||||||
|
"items"?: UgcSubmission[];
|
||||||
|
"nextCursor"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitUgcRequest {
|
||||||
|
"description"?: string;
|
||||||
|
"fileKey"?: string;
|
||||||
|
"fileSize"?: number;
|
||||||
|
"metadata"?: { [key: string]: unknown };
|
||||||
|
"name"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmittedBy {
|
||||||
|
"userId"?: string;
|
||||||
|
"username"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UgcSubmission {
|
||||||
|
"description"?: string;
|
||||||
|
"fileSize"?: number;
|
||||||
|
"fileUrl"?: string;
|
||||||
|
"id"?: string;
|
||||||
|
"metadata"?: { [key: string]: unknown };
|
||||||
|
"name"?: string;
|
||||||
|
"reviewedAt"?: string;
|
||||||
|
"reviewedBy"?: string;
|
||||||
|
"status"?: string;
|
||||||
|
"submittedAt"?: string;
|
||||||
|
"submittedBy"?: SubmittedBy;
|
||||||
|
}
|
||||||
|
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
// @rudder/web-sdk — LiveOps Web SDK for browser games
|
||||||
|
|
||||||
|
// Client
|
||||||
|
export { RudderClient } from './client/RudderClient.js';
|
||||||
|
export type { RudderClientOptions } from './client/RudderClientOptions.js';
|
||||||
|
export {
|
||||||
|
RudderError,
|
||||||
|
RudderNetworkError,
|
||||||
|
RudderHttpError,
|
||||||
|
RudderAuthError,
|
||||||
|
SDK_ERROR_INVALID_OPTIONS,
|
||||||
|
} from './client/RudderError.js';
|
||||||
|
export type { RudderErrorCodeLike } from './client/RudderError.js';
|
||||||
|
|
||||||
|
// Token management
|
||||||
|
export type { TokenStore } from './token/TokenStore.js';
|
||||||
|
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
|
||||||
|
|
||||||
|
// Services — types only; instances live on the client
|
||||||
|
// (client.auth, client.leaderboards, client.battlePass, client.quests).
|
||||||
|
export type {
|
||||||
|
AuthService,
|
||||||
|
AuthState,
|
||||||
|
AuthStateListener,
|
||||||
|
LoginWithDeviceOptions,
|
||||||
|
} from './auth/AuthService.js';
|
||||||
|
export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js';
|
||||||
|
export type { BattlePassService } from './battlepass/BattlePassService.js';
|
||||||
|
export type { QuestsService } from './quests/QuestsService.js';
|
||||||
|
|
||||||
|
// Observable state primitives
|
||||||
|
export { SyncedState } from './state/SyncedState.js';
|
||||||
|
export type {
|
||||||
|
SyncedSnapshot,
|
||||||
|
SyncedStatus,
|
||||||
|
SyncedListener,
|
||||||
|
} from './state/SyncedState.js';
|
||||||
|
export { RemoteConfigState } from './state/RemoteConfigState.js';
|
||||||
|
export type { RemoteConfigShape } from './state/RemoteConfigState.js';
|
||||||
|
export { ShopHandle, OfferHandle } from './state/shops.js';
|
||||||
|
export type { BuyOptions } from './state/shops.js';
|
||||||
|
export type { InventoryItem } from './state/inventory.js';
|
||||||
|
|
||||||
|
// Domains — types only; instances live on the client
|
||||||
|
// (client.player, client.stores, client.remoteConfig, …).
|
||||||
|
export type { PlayerDomain } from './domains/PlayerDomain.js';
|
||||||
|
export type { CatalogDomain } from './domains/CatalogDomain.js';
|
||||||
|
export type { InventoryDomain } from './domains/InventoryDomain.js';
|
||||||
|
export type { ConfigDomain } from './domains/ConfigDomain.js';
|
||||||
|
export type { StorageDomain } from './domains/StorageDomain.js';
|
||||||
|
export type { StoresDomain } from './domains/StoresDomain.js';
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
export type {
|
||||||
|
ConfigChangedEffect,
|
||||||
|
Effects,
|
||||||
|
EffectUnsubscribe,
|
||||||
|
LeaderboardEffect,
|
||||||
|
NotificationEffect,
|
||||||
|
StoreOfferEffect,
|
||||||
|
WaitEffect,
|
||||||
|
ScenarioCompletedEffect,
|
||||||
|
ScenarioFailedEffect,
|
||||||
|
QuestEffect,
|
||||||
|
BattlePassEffect,
|
||||||
|
BattlePassLevelEffect,
|
||||||
|
} from './effects/EffectsCenter.js';
|
||||||
|
|
||||||
|
// Generated wire types — the shapes returned by the observable state and
|
||||||
|
// handles, so consumers can name them instead of re-declaring mirrors.
|
||||||
|
export type { Player, PlayerProfile, Wallet } from './generated/player.js';
|
||||||
|
export type { CatalogItem } from './generated/catalog.js';
|
||||||
|
export type { PlayerInventoryItem } from './generated/inventory.js';
|
||||||
|
export type {
|
||||||
|
Store,
|
||||||
|
Offer,
|
||||||
|
OfferContent,
|
||||||
|
OfferPrice,
|
||||||
|
PurchaseOfferResponse,
|
||||||
|
} from './generated/stores.js';
|
||||||
|
export type { RankEntry } from './generated/leaderboards.js';
|
||||||
|
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
|
||||||
|
export type { RemoteConfig } from './generated/remote-config.js';
|
||||||
|
export type {
|
||||||
|
Quest,
|
||||||
|
QuestObjectiveProgress,
|
||||||
|
QuestReward,
|
||||||
|
ClaimQuestResponse,
|
||||||
|
} from './generated/quests.js';
|
||||||
|
export type {
|
||||||
|
BattlePassReward,
|
||||||
|
ClaimedTier,
|
||||||
|
AddBattlePassXpRequest,
|
||||||
|
AddBattlePassXpResponse,
|
||||||
|
ClaimBattlePassRewardRequest,
|
||||||
|
ClaimBattlePassRewardResponse,
|
||||||
|
PurchaseBattlePassPremiumRequest,
|
||||||
|
PurchaseBattlePassPremiumResponse,
|
||||||
|
GetBattlePassProgressRequest,
|
||||||
|
GetBattlePassProgressResponse,
|
||||||
|
} from './generated/battlepass.js';
|
||||||
|
export type { RudderErrorCode } from './generated/errors.js';
|
||||||
|
export { RudderErrorCodes } from './generated/errors.js';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type { RankEntry } from '../generated/leaderboards.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cached handle for a specific leaderboard.
|
||||||
|
*
|
||||||
|
* Created by `LeaderboardsService.findBySlug()` — subsequent calls with
|
||||||
|
* the same slug return the same handle, avoiding redundant fetches.
|
||||||
|
*/
|
||||||
|
export class LeaderboardHandle {
|
||||||
|
private entries: RankEntry[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly slug: string,
|
||||||
|
private readonly ctx: RudderContext,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getEntries(): readonly RankEntry[] {
|
||||||
|
return this.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(score: number): Promise<void> {
|
||||||
|
return api.submitScore(this.ctx, { slug: this.slug }, { slug: this.slug, score });
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(limit = 100): Promise<readonly RankEntry[]> {
|
||||||
|
const response = await api.getRanking(
|
||||||
|
this.ctx,
|
||||||
|
{ slug: this.slug },
|
||||||
|
{ limit: limit > 0 ? limit : undefined },
|
||||||
|
);
|
||||||
|
this.entries = response.entries ?? [];
|
||||||
|
return this.entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeaderboardsService {
|
||||||
|
private readonly cache = new Map<string, LeaderboardHandle>();
|
||||||
|
|
||||||
|
constructor(private readonly ctx: RudderContext) {}
|
||||||
|
|
||||||
|
/** Returns a cached handle for the given leaderboard slug. */
|
||||||
|
findBySlug(slug: string): LeaderboardHandle {
|
||||||
|
let handle = this.cache.get(slug);
|
||||||
|
if (!handle) {
|
||||||
|
handle = new LeaderboardHandle(slug, this.ctx);
|
||||||
|
this.cache.set(slug, handle);
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* QuestsService — call-and-response access to the global quest endpoints.
|
||||||
|
*
|
||||||
|
* These are the player's global quests (list + claim), distinct from scenario
|
||||||
|
* quest nodes (which advance via {@link QuestSession}). Global quests have no
|
||||||
|
* sync-revision key, so this is a plain service; re-list after a claim.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type { Quest, ClaimQuestResponse } from '../generated/quests.js';
|
||||||
|
|
||||||
|
export class QuestsService {
|
||||||
|
constructor(private readonly ctx: RudderContext) {}
|
||||||
|
|
||||||
|
/** Lists the player's quests with per-objective progress and rewards. */
|
||||||
|
async list(): Promise<Quest[]> {
|
||||||
|
const response = await api.listQuests(this.ctx, {});
|
||||||
|
return response.quests ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claims a completed quest's rewards (idempotent server-side). */
|
||||||
|
claim(questId: string): Promise<ClaimQuestResponse> {
|
||||||
|
return api.claimQuest(this.ctx, { questId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import type { RemoteConfigShape, RemoteConfigState } from '../state/RemoteConfigState.js';
|
||||||
|
import type { ShopHandle } from '../state/shops.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
import type { TriggerScenarioResponse } from '../generated/scenarios.js';
|
||||||
|
import type { ExecutionPlan } from '../generated/common.js';
|
||||||
|
import type { PlanStateStore } from './engine/IndexedDbPlanStore.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of domains the scenario runtime drives: it invalidates the player
|
||||||
|
* and inventory after server mutations, patches remote config for override
|
||||||
|
* nodes, and resolves stores for store nodes.
|
||||||
|
*/
|
||||||
|
export interface ScenarioDomains {
|
||||||
|
readonly player: { invalidate(): void };
|
||||||
|
readonly inventory: { invalidate(): void };
|
||||||
|
readonly config: RemoteConfigState;
|
||||||
|
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
|
||||||
|
}
|
||||||
|
import {
|
||||||
|
RudderNetworkError,
|
||||||
|
RudderHttpError,
|
||||||
|
} from '../client/RudderError.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findNode,
|
||||||
|
matchingEdges,
|
||||||
|
matchingBoundaryNodes,
|
||||||
|
completedHandleKey,
|
||||||
|
durationToMs,
|
||||||
|
} from './engine/DagWalker.js';
|
||||||
|
import { RuntimeRun, PlanRun, type ActiveNodeState, type ScenarioRunFailedEvent } from './engine/types.js';
|
||||||
|
import {
|
||||||
|
ScenarioNodeContext,
|
||||||
|
NotificationSession,
|
||||||
|
WaitSession,
|
||||||
|
StoreSession,
|
||||||
|
LeaderboardSession,
|
||||||
|
QuestSession,
|
||||||
|
BattlePassSession,
|
||||||
|
BattlePassLevelSession,
|
||||||
|
} from './engine/sessions.js';
|
||||||
|
import type { BattlePassService } from '../battlepass/BattlePassService.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when a boundary HTTP call fails with a transient error
|
||||||
|
* (network failure or server 5xx). The caller should NOT advance the run;
|
||||||
|
* the node stays active and the handle stays pending for retry on reconnect.
|
||||||
|
*/
|
||||||
|
class TransientBoundaryError extends Error {
|
||||||
|
constructor(public readonly inner: unknown) {
|
||||||
|
super('Transient boundary error');
|
||||||
|
this.name = 'TransientBoundaryError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true for network / 5xx errors that may succeed on retry. */
|
||||||
|
function isTransientHttpError(err: unknown): boolean {
|
||||||
|
if (err instanceof RudderNetworkError) return true;
|
||||||
|
if (err instanceof RudderHttpError && err.status >= 500) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
|
||||||
|
private readonly runs = new Map<string, RuntimeRun>();
|
||||||
|
private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ctx: RudderContext,
|
||||||
|
private readonly domains: ScenarioDomains,
|
||||||
|
private readonly battlePass: BattlePassService,
|
||||||
|
private readonly planStore: PlanStateStore | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---- Public Events (subscribe to receive node dispatches) ----
|
||||||
|
|
||||||
|
onNotification?: (session: NotificationSession) => void;
|
||||||
|
onWait?: (session: WaitSession) => void;
|
||||||
|
onStore?: (session: StoreSession) => void;
|
||||||
|
onLeaderboard?: (session: LeaderboardSession) => void;
|
||||||
|
onRunCompleted?: (run: PlanRun) => void;
|
||||||
|
onRunFailed?: (event: ScenarioRunFailedEvent) => void;
|
||||||
|
onCompleted?: () => void;
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this.runs.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeRuns(): readonly PlanRun[] {
|
||||||
|
return [...this.runs.values()].map((r) => this.toPlanRun(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Public API ----
|
||||||
|
|
||||||
|
/** Triggers scenarios by event name. Returns all started plans. */
|
||||||
|
async send(eventName: string): Promise<TriggerScenarioResponse> {
|
||||||
|
const response = await api.triggerScenario(this.ctx, { event: eventName });
|
||||||
|
this.domains.player.invalidate();
|
||||||
|
this.domains.inventory.invalidate();
|
||||||
|
this.startPlans(response.plans ?? []);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Completes the current active node with the given handle. */
|
||||||
|
async respond(handle: string): Promise<void> {
|
||||||
|
for (const run of this.runs.values()) {
|
||||||
|
for (const nodeId of run.activeNodes.keys()) {
|
||||||
|
await this.completeNodeAsync(run.runId, nodeId, handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
for (const timer of this.waitTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
this.waitTimers.clear();
|
||||||
|
this.runs.clear();
|
||||||
|
this.persist().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal: Plan lifecycle ----
|
||||||
|
|
||||||
|
private startPlans(plans: ExecutionPlan[]): void {
|
||||||
|
for (const plan of plans ?? []) {
|
||||||
|
this.startPlan(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPlan(plan: ExecutionPlan): void {
|
||||||
|
if (!plan.nodes?.length) return;
|
||||||
|
if (plan.boundaryNodes?.length && !plan.runId) {
|
||||||
|
throw new Error('ExecutionPlan has boundaryNodes but missing runId');
|
||||||
|
}
|
||||||
|
// Dedup: server returned a plan with a runId already active — skip without
|
||||||
|
// restarting wait timers or re-dispatching node sessions.
|
||||||
|
if (plan.runId && this.runs.has(plan.runId)) return;
|
||||||
|
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0];
|
||||||
|
if (!startNode?.id) return;
|
||||||
|
const runId = plan.runId ?? crypto.randomUUID().replace(/-/g, '');
|
||||||
|
const run = new RuntimeRun(runId, plan);
|
||||||
|
this.runs.set(run.runId, run);
|
||||||
|
this.activateNode(run, startNode.id);
|
||||||
|
this.persist().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private activateNode(
|
||||||
|
run: RuntimeRun,
|
||||||
|
nodeId: string,
|
||||||
|
restoredState?: ActiveNodeState,
|
||||||
|
): void {
|
||||||
|
const node = findNode(run.plan, nodeId);
|
||||||
|
if (!node?.id) return;
|
||||||
|
const state = restoredState ?? { nodeId };
|
||||||
|
run.activeNodes.set(nodeId, state);
|
||||||
|
this.dispatchActiveNode(run, state, restoredState !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal: Node Dispatch ----
|
||||||
|
|
||||||
|
private dispatchActiveNode(
|
||||||
|
run: RuntimeRun,
|
||||||
|
state: ActiveNodeState,
|
||||||
|
restored: boolean,
|
||||||
|
): void {
|
||||||
|
const node = findNode(run.plan, state.nodeId);
|
||||||
|
if (!node) {
|
||||||
|
run.activeNodes.delete(state.nodeId);
|
||||||
|
this.checkRunCompleted(run);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = new ScenarioNodeContext(
|
||||||
|
this.toPlanRun(run),
|
||||||
|
node,
|
||||||
|
(rid, nid, h) => this.completeNodeInternal(rid, nid, h, true),
|
||||||
|
(rid, nid, ck, amt) => this.updateProgressInternal(rid, nid, ck, amt),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'wait':
|
||||||
|
this.dispatchWait(run, state, ctx);
|
||||||
|
break;
|
||||||
|
case 'remote_config_override':
|
||||||
|
this.dispatchRemoteConfigOverride(run, state, ctx);
|
||||||
|
break;
|
||||||
|
case 'notification':
|
||||||
|
{
|
||||||
|
const session = new NotificationSession(ctx);
|
||||||
|
this.ctx.effects.emitNotification(session);
|
||||||
|
this.onNotification?.(session);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'store':
|
||||||
|
{
|
||||||
|
const session = new StoreSession(ctx, this.domains.stores);
|
||||||
|
this.ctx.effects.emitStoreOffer(session);
|
||||||
|
this.onStore?.(session);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'leaderboard':
|
||||||
|
{
|
||||||
|
const session = new LeaderboardSession(ctx);
|
||||||
|
this.ctx.effects.emitLeaderboard(session);
|
||||||
|
this.onLeaderboard?.(session);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'quest':
|
||||||
|
this.ctx.effects.emitQuest(new QuestSession(ctx));
|
||||||
|
break;
|
||||||
|
case 'battlepass':
|
||||||
|
this.ctx.effects.emitBattlePass(new BattlePassSession(ctx, this.battlePass));
|
||||||
|
break;
|
||||||
|
case 'battlepass_level':
|
||||||
|
this.ctx.effects.emitBattlePassLevel(new BattlePassLevelSession(ctx));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unsupported node type — fail the run (surfaced via onScenarioFailed)
|
||||||
|
// instead of leaving it stalled on a node no handler will complete.
|
||||||
|
console.warn(
|
||||||
|
`[Rudder] Unsupported scenario node type '${node.type}' (${node.id})`,
|
||||||
|
);
|
||||||
|
this.failRun(
|
||||||
|
run,
|
||||||
|
state.nodeId,
|
||||||
|
new Error(`Unsupported scenario node type '${node.type}'`),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchWait(
|
||||||
|
run: RuntimeRun,
|
||||||
|
state: ActiveNodeState,
|
||||||
|
ctx: ScenarioNodeContext,
|
||||||
|
): void {
|
||||||
|
// Prefer server-provided waitDeadline from the plan boundary over local calculation.
|
||||||
|
// The server stamps waitDeadline on server-enforced wait boundaries (see StampBoundaries).
|
||||||
|
if (!state.waitDeadlineUtc) {
|
||||||
|
const boundary = (run.plan.boundaryNodes ?? []).find(
|
||||||
|
b => b.sourceNodeId === state.nodeId && b.waitDeadline,
|
||||||
|
);
|
||||||
|
if (boundary?.waitDeadline) {
|
||||||
|
state.waitDeadlineUtc = boundary.waitDeadline;
|
||||||
|
} else {
|
||||||
|
const data = ctx.data;
|
||||||
|
const duration = (data.duration as number) ?? 0;
|
||||||
|
const unit = (data.unit as string) ?? 'seconds';
|
||||||
|
const ms = durationToMs(duration, unit);
|
||||||
|
state.waitDeadlineUtc = new Date(Date.now() + ms).toISOString();
|
||||||
|
}
|
||||||
|
this.persist().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = new Date(state.waitDeadlineUtc).getTime();
|
||||||
|
const session = new WaitSession(ctx, new Date(deadline));
|
||||||
|
this.onWait?.(session);
|
||||||
|
this.ctx.effects.emitWait(session);
|
||||||
|
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
this.completeNode(run.runId, state.nodeId, 'onComplete');
|
||||||
|
} else {
|
||||||
|
const timerKey = `${run.runId}:${state.nodeId}`;
|
||||||
|
clearTimeout(this.waitTimers.get(timerKey));
|
||||||
|
this.waitTimers.set(
|
||||||
|
timerKey,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.completeNode(run.runId, state.nodeId, 'onComplete');
|
||||||
|
this.waitTimers.delete(timerKey);
|
||||||
|
}, remaining),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchRemoteConfigOverride(
|
||||||
|
run: RuntimeRun,
|
||||||
|
state: ActiveNodeState,
|
||||||
|
ctx: ScenarioNodeContext,
|
||||||
|
): void {
|
||||||
|
const patches = ctx.data.patches as Array<{
|
||||||
|
path?: string;
|
||||||
|
valueType?: string;
|
||||||
|
value?: string;
|
||||||
|
}> | undefined;
|
||||||
|
|
||||||
|
if (patches) {
|
||||||
|
for (const patch of patches) {
|
||||||
|
if (patch.path) {
|
||||||
|
this.domains.config.applyOverride(
|
||||||
|
patch.path,
|
||||||
|
patch.value ?? '',
|
||||||
|
patch.valueType ?? 'json',
|
||||||
|
);
|
||||||
|
this.ctx.effects.emitConfigChanged(patch.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.completeNode(run.runId, state.nodeId, 'output');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal: Node completion ----
|
||||||
|
|
||||||
|
/** Synchronous fire-and-forget completion. */
|
||||||
|
private completeNode(runId: string, nodeId: string, handle: string): void {
|
||||||
|
this.completeNodeInternal(runId, nodeId, handle, true).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Async completion — called by sessions. */
|
||||||
|
async completeNodeAsync(
|
||||||
|
runId: string,
|
||||||
|
nodeId: string,
|
||||||
|
handle: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.completeNodeInternal(runId, nodeId, handle, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeNodeInternal(
|
||||||
|
runId: string,
|
||||||
|
nodeId: string,
|
||||||
|
handle: string,
|
||||||
|
continueOnBoundary: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const run = this.runs.get(runId);
|
||||||
|
if (!run) return;
|
||||||
|
|
||||||
|
const key = completedHandleKey(nodeId, handle);
|
||||||
|
if (run.completedHandles.has(key)) return; // idempotent
|
||||||
|
|
||||||
|
// Mark this transition as in flight so that a transiently-empty
|
||||||
|
// activeNodes (e.g. between this node and an auto-completing successor)
|
||||||
|
// does not cause the run to be reported complete prematurely.
|
||||||
|
run.pendingTransitions++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let boundaryContinued = false;
|
||||||
|
if (continueOnBoundary) {
|
||||||
|
try {
|
||||||
|
boundaryContinued = await this.continueBoundary(run, nodeId, handle);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof TransientBoundaryError) {
|
||||||
|
// Transient error — don't advance the run.
|
||||||
|
// Node stays active, handle stays pending for retry on reconnect.
|
||||||
|
run.pendingTransitions--;
|
||||||
|
await this.persist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err; // terminal — handled by outer catch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only now — after the server has confirmed — mark the handle and node.
|
||||||
|
run.completedHandles.add(key);
|
||||||
|
run.activeNodes.delete(nodeId);
|
||||||
|
await this.persist();
|
||||||
|
|
||||||
|
if (!boundaryContinued) {
|
||||||
|
for (const edge of matchingEdges(run.plan, nodeId, handle)) {
|
||||||
|
if (edge.target) {
|
||||||
|
this.activateNode(run, edge.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run.pendingTransitions--;
|
||||||
|
this.checkRunCompleted(run);
|
||||||
|
await this.persist();
|
||||||
|
} catch (err) {
|
||||||
|
run.pendingTransitions--;
|
||||||
|
this.failRun(
|
||||||
|
run,
|
||||||
|
nodeId,
|
||||||
|
err instanceof Error ? err : new Error(String(err)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async continueBoundary(
|
||||||
|
run: RuntimeRun,
|
||||||
|
nodeId: string,
|
||||||
|
handle: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const boundaries = [...matchingBoundaryNodes(run.plan, nodeId, handle)];
|
||||||
|
if (boundaries.length === 0) return false;
|
||||||
|
|
||||||
|
for (const boundary of boundaries) {
|
||||||
|
try {
|
||||||
|
const response = await api.handleScenarioCallback(this.ctx, {
|
||||||
|
scenarioId: run.plan.scenarioId,
|
||||||
|
nodeId: boundary.sourceNodeId,
|
||||||
|
handle: boundary.sourceHandle,
|
||||||
|
runId: run.runId,
|
||||||
|
});
|
||||||
|
this.domains.player.invalidate();
|
||||||
|
this.domains.inventory.invalidate();
|
||||||
|
if (response?.plan) {
|
||||||
|
this.startPlan(response.plan);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Boundary call failed — try to reconcile with server.
|
||||||
|
let reconciled = false;
|
||||||
|
try {
|
||||||
|
const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId });
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// If reconciled, the boundary was handled (run corrected or removed).
|
||||||
|
// Fall through to continue to the next boundary.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal: Counter progress ----
|
||||||
|
|
||||||
|
async updateProgressInternal(
|
||||||
|
runId: string,
|
||||||
|
nodeId: string,
|
||||||
|
counterKey: string,
|
||||||
|
amount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const run = this.runs.get(runId);
|
||||||
|
try {
|
||||||
|
const response = await api.updateScenarioCounter(this.ctx, {
|
||||||
|
scenarioId: run?.plan.scenarioId ?? '',
|
||||||
|
nodeId,
|
||||||
|
counterKey,
|
||||||
|
amount,
|
||||||
|
runId,
|
||||||
|
});
|
||||||
|
// The server reports objective completion; it no longer returns a plan from the
|
||||||
|
// counter endpoint. On completion, cross the quest's onComplete boundary (which
|
||||||
|
// grants rewards + advances the run) — idempotent if the consumer also calls complete().
|
||||||
|
if (response?.completed) {
|
||||||
|
await this.completeNodeInternal(runId, nodeId, 'onComplete', true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Counter update failure does not fail the run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal: Run lifecycle ----
|
||||||
|
|
||||||
|
private checkRunCompleted(run: RuntimeRun): void {
|
||||||
|
if (run.activeNodes.size > 0) return;
|
||||||
|
// A node transition is still settling (e.g. an auto-completing
|
||||||
|
// remote_config_override node is about to activate its successor).
|
||||||
|
// Wait for it to finish before declaring the run complete.
|
||||||
|
if (run.pendingTransitions > 0) return;
|
||||||
|
if (!this.runs.has(run.runId)) return; // already completed/removed
|
||||||
|
this.runs.delete(run.runId);
|
||||||
|
const planRun = this.toPlanRun(run);
|
||||||
|
this.onRunCompleted?.(planRun);
|
||||||
|
this.onCompleted?.();
|
||||||
|
this.ctx.effects.emitScenarioCompleted(planRun);
|
||||||
|
this.persist().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private failRun(
|
||||||
|
run: RuntimeRun,
|
||||||
|
nodeId: string,
|
||||||
|
error: Error,
|
||||||
|
): void {
|
||||||
|
console.warn(
|
||||||
|
`[Rudder] Scenario run ${run.runId} failed at node ${nodeId}: ${error.message}`,
|
||||||
|
);
|
||||||
|
this.runs.delete(run.runId);
|
||||||
|
const event: ScenarioRunFailedEvent = { run: this.toPlanRun(run), nodeId, error };
|
||||||
|
this.onRunFailed?.(event);
|
||||||
|
this.ctx.effects.emitScenarioFailed(event);
|
||||||
|
this.persist().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal: Helpers ----
|
||||||
|
|
||||||
|
private toPlanRun(run: RuntimeRun): PlanRun {
|
||||||
|
return new PlanRun(
|
||||||
|
run.runId,
|
||||||
|
run.plan.planId ?? '',
|
||||||
|
run.plan.scenarioId ?? '',
|
||||||
|
run.plan.userId ?? '',
|
||||||
|
[...run.activeNodes.keys()],
|
||||||
|
run.plan,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(): Promise<void> {
|
||||||
|
const store = this.planStore;
|
||||||
|
if (!store) return;
|
||||||
|
const state = JSON.stringify({
|
||||||
|
runs: [...this.runs.values()].map((run) => ({
|
||||||
|
runId: run.runId,
|
||||||
|
plan: run.plan,
|
||||||
|
activeNodes: [...run.activeNodes.values()],
|
||||||
|
completedHandles: [...run.completedHandles],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
store.state = state;
|
||||||
|
try {
|
||||||
|
await store.save(state);
|
||||||
|
} catch {
|
||||||
|
// Best-effort persistence.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
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('level', 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { RemoteConfig } from '../generated/remote-config.js';
|
||||||
|
import { SyncedState } from './SyncedState.js';
|
||||||
|
|
||||||
|
export type RemoteConfigShape = Record<string, unknown>;
|
||||||
|
type RemoteConfigKey<TConfig extends RemoteConfigShape> = Extract<keyof TConfig, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RemoteConfigState — typed remote configuration as an observable entity.
|
||||||
|
*
|
||||||
|
* `get()` parses values synchronously from the loaded snapshot based on their
|
||||||
|
* declared `valueType` and returns the default value while not loaded.
|
||||||
|
*/
|
||||||
|
export class RemoteConfigState<
|
||||||
|
TConfig extends RemoteConfigShape = RemoteConfigShape,
|
||||||
|
> extends SyncedState<Map<string, RemoteConfig>> {
|
||||||
|
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey): TConfig[TKey] | undefined;
|
||||||
|
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey, defaultValue: TConfig[TKey]): TConfig[TKey];
|
||||||
|
get<TKey extends RemoteConfigKey<TConfig>>(
|
||||||
|
key: TKey,
|
||||||
|
defaultValue?: TConfig[TKey],
|
||||||
|
): TConfig[TKey] | undefined {
|
||||||
|
const config = this.data?.get(key);
|
||||||
|
if (!config || config.value == null) return defaultValue;
|
||||||
|
return parseValue(config.value, config.valueType, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a scenario remote_config_override patch to the current snapshot.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
applyOverride(key: string, value: string, valueType = 'json'): void {
|
||||||
|
const next = new Map(this.data ?? []);
|
||||||
|
next.set(key, {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
valueType,
|
||||||
|
active: true,
|
||||||
|
} as RemoteConfig);
|
||||||
|
this.set(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue<T>(
|
||||||
|
value: string,
|
||||||
|
valueType: string | undefined,
|
||||||
|
defaultValue: T | undefined,
|
||||||
|
): T | undefined {
|
||||||
|
try {
|
||||||
|
switch ((valueType ?? '').toLowerCase()) {
|
||||||
|
case 'int':
|
||||||
|
case 'integer':
|
||||||
|
return Number.parseInt(value, 10) as unknown as T;
|
||||||
|
case 'float':
|
||||||
|
case 'double':
|
||||||
|
case 'number':
|
||||||
|
return Number.parseFloat(value) as unknown as T;
|
||||||
|
case 'bool':
|
||||||
|
case 'boolean':
|
||||||
|
return (value === 'true') as unknown as T;
|
||||||
|
case 'json':
|
||||||
|
case 'object':
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
default:
|
||||||
|
return value as unknown as T;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { RudderContext } from '../core/context.js';
|
||||||
|
import { api } from '../generated/api.js';
|
||||||
|
|
||||||
|
export const DEFAULT_SYNC_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
const REVISIONS_STORAGE_KEY = 'rudder_revisions';
|
||||||
|
const JITTER_RATIO = 0.2;
|
||||||
|
|
||||||
|
/** A domain the sync engine can refresh when its server revision grows. */
|
||||||
|
export interface SyncableDomain {
|
||||||
|
readonly syncKey: string;
|
||||||
|
invalidate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncEngine — polls GET /sdk/v1/sync and invalidates state entities whose
|
||||||
|
* server-side revision grew.
|
||||||
|
*
|
||||||
|
* The first successful sync after login only fixes the baseline (entities
|
||||||
|
* were just warmed); revisions are persisted to localStorage and cleared on
|
||||||
|
* logout. Polling pauses while the document is hidden, and poll errors never
|
||||||
|
* stop the loop (401s are handled by the transport refresh flow).
|
||||||
|
*/
|
||||||
|
export class SyncEngine {
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private revisions: Record<string, number> = {};
|
||||||
|
private baselineEstablished = false;
|
||||||
|
private running = false;
|
||||||
|
private polling = false;
|
||||||
|
|
||||||
|
private readonly byKey: Map<string, SyncableDomain>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ctx: RudderContext,
|
||||||
|
domains: readonly SyncableDomain[],
|
||||||
|
private readonly intervalMs: number = DEFAULT_SYNC_INTERVAL_MS,
|
||||||
|
) {
|
||||||
|
this.byKey = new Map(domains.map((domain) => [domain.syncKey, domain]));
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
this.revisions = readPersistedRevisions();
|
||||||
|
this.baselineEstablished = false;
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||||
|
}
|
||||||
|
void this.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.running = false;
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||||
|
}
|
||||||
|
this.revisions = {};
|
||||||
|
this.baselineEstablished = false;
|
||||||
|
clearPersistedRevisions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onVisibilityChange = (): void => {
|
||||||
|
if (this.running && !document.hidden) {
|
||||||
|
void this.poll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private schedule(): void {
|
||||||
|
if (!this.running) return;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
void this.poll();
|
||||||
|
}, this.intervalMs * jitter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async poll(): Promise<void> {
|
||||||
|
if (!this.running || this.polling) return;
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) {
|
||||||
|
this.schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.polling = true;
|
||||||
|
try {
|
||||||
|
const revisions = await api.getRevisions(this.ctx);
|
||||||
|
this.applyRevisions(revisions ?? {});
|
||||||
|
} catch {
|
||||||
|
// Network/server errors must not stop the loop.
|
||||||
|
} finally {
|
||||||
|
this.polling = false;
|
||||||
|
}
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRevisions(next: Record<string, number>): void {
|
||||||
|
// On the first sync of a session, only establish the baseline UNLESS we
|
||||||
|
// restored revisions persisted by a previous session — in that case diff
|
||||||
|
// against them so entities changed while the tab was gone are refreshed.
|
||||||
|
const skipDiff = !this.baselineEstablished && Object.keys(this.revisions).length === 0;
|
||||||
|
this.baselineEstablished = true;
|
||||||
|
if (!skipDiff) {
|
||||||
|
for (const [key, revision] of Object.entries(next)) {
|
||||||
|
if (typeof revision !== 'number') continue;
|
||||||
|
if (revision > (this.revisions[key] ?? 0)) {
|
||||||
|
this.invalidate(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.revisions = next;
|
||||||
|
persistRevisions(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
private invalidate(key: string): void {
|
||||||
|
// Unknown keys have no registered domain and are ignored.
|
||||||
|
this.byKey.get(key)?.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPersistedRevisions(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(REVISIONS_STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed && typeof parsed === 'object'
|
||||||
|
? (parsed as Record<string, number>)
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRevisions(revisions: Record<string, number>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(REVISIONS_STORAGE_KEY, JSON.stringify(revisions));
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable — best effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPersistedRevisions(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(REVISIONS_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable — silently ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* SyncedState — an observable cell for a single server-synced entity.
|
||||||
|
*
|
||||||
|
* Tracks `{ status, data, error }`, deduplicates parallel loads, and notifies
|
||||||
|
* listeners on every data/status change. `onChange` calls the callback
|
||||||
|
* immediately with the current snapshot, which makes syncing into external
|
||||||
|
* stores (React/zustand) trivial.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SyncedStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
export interface SyncedSnapshot<T> {
|
||||||
|
status: SyncedStatus;
|
||||||
|
data?: T;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncedListener<T> = (snapshot: SyncedSnapshot<T>) => void;
|
||||||
|
|
||||||
|
export class SyncedState<T> {
|
||||||
|
private snapshot: SyncedSnapshot<T> = { status: 'idle' };
|
||||||
|
private pending: Promise<T> | undefined;
|
||||||
|
private generation = 0;
|
||||||
|
private invalidateScheduled = false;
|
||||||
|
private readonly listeners = new Set<SyncedListener<T>>();
|
||||||
|
|
||||||
|
constructor(private readonly loader: () => Promise<T>) {}
|
||||||
|
|
||||||
|
/** Current data, or undefined when not loaded yet. */
|
||||||
|
get data(): T | undefined {
|
||||||
|
return this.snapshot.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): SyncedStatus {
|
||||||
|
return this.snapshot.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get error(): Error | undefined {
|
||||||
|
return this.snapshot.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to snapshot changes. The callback fires immediately with the
|
||||||
|
* current snapshot. Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
onChange(callback: SyncedListener<T>): () => void {
|
||||||
|
this.listeners.add(callback);
|
||||||
|
callback(this.snapshot);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads the entity if needed; deduplicates parallel loads. */
|
||||||
|
load(): Promise<T> {
|
||||||
|
if (this.snapshot.status === 'ready') {
|
||||||
|
return Promise.resolve(this.snapshot.data as T);
|
||||||
|
}
|
||||||
|
return this.startLoad(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forces a (re)load even when data is already present. */
|
||||||
|
reload(): Promise<T> {
|
||||||
|
return this.startLoad(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refetches when the entity is in use (has listeners or was loaded before);
|
||||||
|
* otherwise no-ops. Coalesced: multiple invalidations within the same tick
|
||||||
|
* (e.g. one mutation touching several boundaries) trigger a single reload.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
invalidate(): void {
|
||||||
|
if (this.invalidateScheduled) return;
|
||||||
|
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
||||||
|
this.invalidateScheduled = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
// A reset() or set() between scheduling and flushing cancels the reload
|
||||||
|
// (fresh data arrived, or the entity was dropped on logout).
|
||||||
|
if (!this.invalidateScheduled) return;
|
||||||
|
this.invalidateScheduled = false;
|
||||||
|
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
||||||
|
this.startLoad(true).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies externally-provided data (e.g. scenario config patches).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
set(data: T): void {
|
||||||
|
this.generation++;
|
||||||
|
this.pending = undefined;
|
||||||
|
this.invalidateScheduled = false;
|
||||||
|
this.setSnapshot({ status: 'ready', data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops all state back to idle (logout).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.generation++;
|
||||||
|
this.pending = undefined;
|
||||||
|
this.invalidateScheduled = false;
|
||||||
|
this.setSnapshot({ status: 'idle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private startLoad(force: boolean): Promise<T> {
|
||||||
|
if (this.pending && !force) return this.pending;
|
||||||
|
const generation = ++this.generation;
|
||||||
|
this.setSnapshot({ status: 'loading', data: this.snapshot.data });
|
||||||
|
this.pending = this.loader()
|
||||||
|
.then((data) => {
|
||||||
|
if (generation === this.generation) {
|
||||||
|
this.setSnapshot({ status: 'ready', data });
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
if (generation === this.generation) {
|
||||||
|
this.setSnapshot({ status: 'error', error, data: this.snapshot.data });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === this.generation) {
|
||||||
|
this.pending = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSnapshot(next: SyncedSnapshot<T>): void {
|
||||||
|
const previous = this.snapshot;
|
||||||
|
if (
|
||||||
|
previous.status === next.status &&
|
||||||
|
previous.data === next.data &&
|
||||||
|
previous.error === next.error
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.snapshot = next;
|
||||||
|
for (const callback of this.listeners) {
|
||||||
|
callback(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { CatalogItem } from '../generated/catalog.js';
|
||||||
|
import type { PlayerInventoryItem } from '../generated/inventory.js';
|
||||||
|
|
||||||
|
/** A fully-resolved owned item: player inventory row merged with its catalog entry. */
|
||||||
|
export interface InventoryItem {
|
||||||
|
slug: string;
|
||||||
|
amount: number;
|
||||||
|
name: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Merges owned inventory rows with their catalog entries. */
|
||||||
|
export function mergeInventory(
|
||||||
|
owned: PlayerInventoryItem[],
|
||||||
|
catalog: Map<string, CatalogItem>,
|
||||||
|
): InventoryItem[] {
|
||||||
|
return owned.map((item) => {
|
||||||
|
const slug = item.slug ?? '';
|
||||||
|
const entry = catalog.get(slug);
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
amount: item.amount ?? 0,
|
||||||
|
name: item.nameOverride || entry?.name || slug,
|
||||||
|
properties: { ...(entry?.properties ?? {}), ...(item.propertiesOverride ?? {}) },
|
||||||
|
tags: entry?.tags ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type {
|
||||||
|
Offer,
|
||||||
|
OfferContent,
|
||||||
|
OfferPrice,
|
||||||
|
Store,
|
||||||
|
PurchaseOfferResponse,
|
||||||
|
} from '../generated/stores.js';
|
||||||
|
|
||||||
|
export interface BuyOptions {
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire-level purchase executor provided by the stores domain
|
||||||
|
* (`StoresDomain.purchase`).
|
||||||
|
*/
|
||||||
|
export type PurchaseOffer = (
|
||||||
|
storeSlug: string,
|
||||||
|
offerId: string,
|
||||||
|
options?: BuyOptions,
|
||||||
|
) => Promise<PurchaseOfferResponse>;
|
||||||
|
|
||||||
|
export class OfferHandle {
|
||||||
|
public readonly id: string;
|
||||||
|
public readonly name?: string;
|
||||||
|
public readonly price?: OfferPrice;
|
||||||
|
public readonly contents: OfferContent[];
|
||||||
|
public readonly maxPurchases?: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly purchase: PurchaseOffer,
|
||||||
|
private readonly storeSlug: string,
|
||||||
|
private readonly offer: Offer,
|
||||||
|
) {
|
||||||
|
if (!offer.id) {
|
||||||
|
throw new Error('Rudder Stores: offer.id is required');
|
||||||
|
}
|
||||||
|
this.id = offer.id;
|
||||||
|
this.name = offer.name;
|
||||||
|
this.price = offer.price;
|
||||||
|
this.contents = offer.contents ?? [];
|
||||||
|
this.maxPurchases = offer.maxPurchases;
|
||||||
|
}
|
||||||
|
|
||||||
|
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
|
||||||
|
return this.purchase(this.storeSlug, this.id, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShopHandle {
|
||||||
|
public readonly slug: string;
|
||||||
|
public readonly name?: string;
|
||||||
|
public readonly description?: string;
|
||||||
|
public readonly data?: { [key: string]: unknown };
|
||||||
|
public readonly offers: OfferHandle[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
purchase: PurchaseOffer,
|
||||||
|
store: Store,
|
||||||
|
) {
|
||||||
|
if (!store.slug) {
|
||||||
|
throw new Error('Rudder Stores: store.slug is required');
|
||||||
|
}
|
||||||
|
this.slug = store.slug;
|
||||||
|
this.name = store.name;
|
||||||
|
this.description = store.description;
|
||||||
|
this.data = store.data;
|
||||||
|
this.offers = (store.offers ?? []).map(
|
||||||
|
(offer) => new OfferHandle(purchase, this.slug, offer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Token persistence interface and localStorage-backed default implementation.
|
||||||
|
*
|
||||||
|
* The TokenStore is injected via RudderClientOptions. When none is provided
|
||||||
|
* the client uses {@link createDefaultTokenStore}: localStorage in browsers,
|
||||||
|
* with a silent in-memory fallback where localStorage is unavailable
|
||||||
|
* (SSR, private mode). Consumers can provide a custom implementation for
|
||||||
|
* alternative storage backends (e.g., sessionStorage, secure cookies).
|
||||||
|
*/
|
||||||
|
export interface TokenStore {
|
||||||
|
/** Returns the current access token, or null if not authenticated. */
|
||||||
|
getAccessToken(): string | null;
|
||||||
|
|
||||||
|
/** Returns the current refresh token, or null if not available. */
|
||||||
|
getRefreshToken(): string | null;
|
||||||
|
|
||||||
|
/** Persists access and refresh tokens after a successful login. */
|
||||||
|
saveTokens(accessToken: string, refreshToken: string): void;
|
||||||
|
|
||||||
|
/** Removes all stored tokens (logout). */
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_ACCESS = 'rudder_access_token';
|
||||||
|
const STORAGE_KEY_REFRESH = 'rudder_refresh_token';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the default TokenStore: localStorage-backed when usable, otherwise
|
||||||
|
* a silent in-memory fallback (tokens live for the page session only).
|
||||||
|
*/
|
||||||
|
export function createDefaultTokenStore(): TokenStore {
|
||||||
|
try {
|
||||||
|
const probe = 'rudder_storage_probe';
|
||||||
|
localStorage.setItem(probe, '1');
|
||||||
|
localStorage.removeItem(probe);
|
||||||
|
return createLocalStorageTokenStore();
|
||||||
|
} catch {
|
||||||
|
return createMemoryTokenStore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a TokenStore backed by the browser's localStorage.
|
||||||
|
*
|
||||||
|
* Uses keys `rudder_access_token` and `rudder_refresh_token`.
|
||||||
|
*/
|
||||||
|
export function createLocalStorageTokenStore(): TokenStore {
|
||||||
|
return {
|
||||||
|
getAccessToken(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY_ACCESS);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getRefreshToken(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY_REFRESH);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveTokens(accessToken: string, refreshToken: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_ACCESS, accessToken);
|
||||||
|
localStorage.setItem(STORAGE_KEY_REFRESH, refreshToken);
|
||||||
|
} catch {
|
||||||
|
// Storage full or unavailable — silently ignore.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_ACCESS);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_REFRESH);
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable — silently ignore.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemoryTokenStore(): TokenStore {
|
||||||
|
let accessToken: string | null = null;
|
||||||
|
let refreshToken: string | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAccessToken: () => accessToken,
|
||||||
|
getRefreshToken: () => refreshToken,
|
||||||
|
saveTokens(access, refresh) {
|
||||||
|
accessToken = access;
|
||||||
|
refreshToken = refresh;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
accessToken = null;
|
||||||
|
refreshToken = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Core HTTP transport — fetch-based with auth injection, timeouts, GET retries,
|
||||||
|
* single-flight token refresh, and typed error mapping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RudderNetworkError, RudderHttpError, RudderAuthError } from '../client/RudderError.js';
|
||||||
|
import type { TokenStore } from '../token/TokenStore.js';
|
||||||
|
import type {
|
||||||
|
RefreshAccessTokenRequest,
|
||||||
|
RefreshAccessTokenResponse,
|
||||||
|
} from '../generated/auth.js';
|
||||||
|
|
||||||
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
const MAX_GET_RETRIES = 2;
|
||||||
|
const RETRY_BASE_DELAY_MS = 300;
|
||||||
|
|
||||||
|
export interface RequestContext {
|
||||||
|
baseUrl: string;
|
||||||
|
tokenStore: TokenStore;
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
/** Single-flight refresh state shared by all requests of one client. */
|
||||||
|
refreshState?: { pending?: Promise<boolean> };
|
||||||
|
/** Called when the refresh flow fails — lets the client stop its runtime. */
|
||||||
|
onAuthFailure?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an HTTP request to the LiveOps API.
|
||||||
|
*
|
||||||
|
* - Builds absolute URL from `baseUrl + path`
|
||||||
|
* - Injects `Authorization: Bearer <token>` header (if token is available)
|
||||||
|
* - Serializes body as JSON, deserializes response as JSON
|
||||||
|
* - Aborts the request after `requestTimeoutMs` (default 10s)
|
||||||
|
* - Retries GET requests up to 2 times (300ms × 2^n backoff) on network errors and 5xx
|
||||||
|
* - On 401 runs a single-flight token refresh and retries the request once;
|
||||||
|
* when the refresh fails, clears tokens and throws RudderAuthError
|
||||||
|
* - Maps HTTP errors to typed exceptions (RudderNetworkError, RudderHttpError, RudderAuthError)
|
||||||
|
* - Returns undefined for 204 No Content responses
|
||||||
|
*/
|
||||||
|
export async function request<TResponse>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown | undefined,
|
||||||
|
ctx: RequestContext,
|
||||||
|
): Promise<TResponse> {
|
||||||
|
let response = await requestWithRetry(method, path, body, ctx);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
const refreshed = await refreshTokens(ctx);
|
||||||
|
if (refreshed) {
|
||||||
|
response = await requestWithRetry(method, path, body, ctx);
|
||||||
|
} else {
|
||||||
|
ctx.tokenStore.clear();
|
||||||
|
ctx.onAuthFailure?.();
|
||||||
|
const bodyText = await response.text().catch(() => '');
|
||||||
|
throw new RudderAuthError(response.statusText, bodyText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const bodyText = await response.text().catch(() => '');
|
||||||
|
|
||||||
|
// Try to extract machine-readable code from error body.
|
||||||
|
let code: string | undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(bodyText);
|
||||||
|
if (parsed && typeof parsed.code === 'string') {
|
||||||
|
code = parsed.code;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-JSON body — ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new RudderAuthError(response.statusText, bodyText);
|
||||||
|
}
|
||||||
|
throw new RudderHttpError(response.status, response.statusText, bodyText, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content — no body to parse.
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as unknown as TResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<TResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches with per-attempt timeout; retries GET on network errors and 5xx. */
|
||||||
|
async function requestWithRetry(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown | undefined,
|
||||||
|
ctx: RequestContext,
|
||||||
|
): Promise<Response> {
|
||||||
|
const retryable = method === 'GET';
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetchOnce(method, path, body, ctx);
|
||||||
|
if (retryable && response.status >= 500 && attempt < MAX_GET_RETRIES) {
|
||||||
|
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
if (retryable && attempt < MAX_GET_RETRIES) {
|
||||||
|
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new RudderNetworkError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnce(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown | undefined,
|
||||||
|
ctx: RequestContext,
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = `${ctx.baseUrl.replace(/\/+$/, '')}${path}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = ctx.tokenStore.getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
|
try {
|
||||||
|
return await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-flight refresh: concurrent 401s share one refresh request.
|
||||||
|
* Resolves to true when a new token pair was stored.
|
||||||
|
*/
|
||||||
|
async function refreshTokens(ctx: RequestContext): Promise<boolean> {
|
||||||
|
const state = (ctx.refreshState ??= {});
|
||||||
|
state.pending ??= doRefreshTokens(ctx).finally(() => {
|
||||||
|
state.pending = undefined;
|
||||||
|
});
|
||||||
|
return state.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
|
||||||
|
const refreshToken = ctx.tokenStore.getRefreshToken();
|
||||||
|
if (!refreshToken) return false;
|
||||||
|
|
||||||
|
const url = `${ctx.baseUrl.replace(/\/+$/, '')}/sdk/v1/authorization/refresh`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = ctx.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken } satisfies RefreshAccessTokenRequest),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const tokens = (await response.json()) as RefreshAccessTokenResponse;
|
||||||
|
if (!tokens.accessToken || !tokens.refreshToken) return false;
|
||||||
|
ctx.tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* URL utility helpers for encoding and query string construction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** URL-encodes a single value for use in path/query segments. */
|
||||||
|
export function encodeUrl(value: string): string {
|
||||||
|
return encodeURIComponent(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a query string from an array of key-value pairs.
|
||||||
|
* Null/undefined/empty values are skipped.
|
||||||
|
* Returns an empty string if no valid params, or a leading `?` followed by the query.
|
||||||
|
*/
|
||||||
|
export function buildQuery(params: Array<[string, string | null | undefined]>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [key, value] of params) {
|
||||||
|
if (value != null && value !== '') {
|
||||||
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length === 0 ? '' : `?${parts.join('&')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { createTestClient } from './helpers/createClient.js';
|
||||||
|
import { RudderNetworkError, RudderAuthError } from '../src/client/RudderError.js';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock crypto.randomUUID for deterministic device ID.
|
||||||
|
vi.stubGlobal('crypto', {
|
||||||
|
randomUUID: () => '00000000-0000-4000-a000-000000000001',
|
||||||
|
});
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginWithDevice sends correct request body', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/sdk/v1/remote-configs')) {
|
||||||
|
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({
|
||||||
|
accessToken: 'access-token-123',
|
||||||
|
refreshToken: 'refresh-token-456',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(8);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toContain('/sdk/v1/authorization/device');
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
expect(body.key).toBe('test-project-key');
|
||||||
|
expect(body.deviceId).toBe('00000000-0000-4000-a000-000000000001');
|
||||||
|
expect(body.region).toBe('us');
|
||||||
|
expect(body.language).toBe('en');
|
||||||
|
expect(response.accessToken).toBe('access-token-123');
|
||||||
|
expect(response.refreshToken).toBe('refresh-token-456');
|
||||||
|
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
|
||||||
|
'/sdk/v1/authorization/device',
|
||||||
|
'/sdk/v1/remote-configs',
|
||||||
|
'/sdk/v1/player/information',
|
||||||
|
'/sdk/v1/stores',
|
||||||
|
'/sdk/v1/catalog',
|
||||||
|
'/sdk/v1/scenarios/trigger',
|
||||||
|
// The login event invalidates the warmed profile → refetch.
|
||||||
|
'/sdk/v1/player/information',
|
||||||
|
// Sync engine baseline poll, fired right after login.
|
||||||
|
'/sdk/v1/sync',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginWithDevice saves tokens on success', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/sdk/v1/remote-configs')) {
|
||||||
|
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({
|
||||||
|
accessToken: 'at',
|
||||||
|
refreshToken: 'rt',
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
expect(client.options.tokenStore.getAccessToken()).toBe('at');
|
||||||
|
expect(client.options.tokenStore.getRefreshToken()).toBe('rt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginWithDevice throws RudderNetworkError on network failure', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Offline')));
|
||||||
|
await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderNetworkError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginWithDevice throws RudderAuthError on 401', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
|
||||||
|
));
|
||||||
|
await expect(client.auth.loginWithDevice()).rejects.toThrow(RudderAuthError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logout clears tokens', () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
client.options.tokenStore.saveTokens('at', 'rt');
|
||||||
|
client.auth.logout();
|
||||||
|
expect(client.options.tokenStore.getAccessToken()).toBeNull();
|
||||||
|
expect(client.options.tokenStore.getRefreshToken()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginWithDevice sends token in Authorization header after login', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/sdk/v1/authorization/device')) {
|
||||||
|
return Promise.resolve(jsonResponse({
|
||||||
|
accessToken: 'at2',
|
||||||
|
refreshToken: 'rt2',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (url.includes('/sdk/v1/remote-configs')) {
|
||||||
|
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
|
||||||
|
// Subsequent authenticated calls should include the token.
|
||||||
|
const response = await client.player.reload();
|
||||||
|
expect(response).toEqual({ player: null, wallets: [] });
|
||||||
|
const playerCall = fetchMock.mock.calls.find(([url]) =>
|
||||||
|
String(url).includes('/sdk/v1/player/information'),
|
||||||
|
);
|
||||||
|
const headers = playerCall?.[1].headers;
|
||||||
|
expect(headers['Authorization']).toBe('Bearer at2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginWithDevice only fires player_login from the client runtime', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/sdk/v1/authorization/device')) {
|
||||||
|
return Promise.resolve(jsonResponse({
|
||||||
|
accessToken: 'at-new',
|
||||||
|
refreshToken: 'rt-new',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (url.includes('/sdk/v1/remote-configs')) {
|
||||||
|
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||||
|
}
|
||||||
|
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
||||||
|
return Promise.resolve(jsonResponse({ plans: [] }));
|
||||||
|
}
|
||||||
|
if (url.includes('/sdk/v1/stores')) {
|
||||||
|
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
|
||||||
|
const triggerBodies = fetchMock.mock.calls
|
||||||
|
.filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger'))
|
||||||
|
.map(([, init]) => JSON.parse(init.body));
|
||||||
|
expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginWithDevice isolates post-login scenario trigger failures', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/sdk/v1/authorization/device')) {
|
||||||
|
return Promise.resolve(jsonResponse({
|
||||||
|
accessToken: 'at',
|
||||||
|
refreshToken: 'rt',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (url.includes('/sdk/v1/remote-configs')) {
|
||||||
|
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||||
|
}
|
||||||
|
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
||||||
|
return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' }));
|
||||||
|
}
|
||||||
|
if (url.includes('/sdk/v1/stores')) {
|
||||||
|
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' });
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginWithDevice defaults to region global and language en', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/sdk/v1/remote-configs')) {
|
||||||
|
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
const [, init] = fetchMock.mock.calls[0];
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
expect(body.region).toBe('global');
|
||||||
|
expect(body.language).toBe('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auth state', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('crypto', {
|
||||||
|
randomUUID: () => '00000000-0000-4000-a000-000000000001',
|
||||||
|
});
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubLoginFlow(): void {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/sdk/v1/remote-configs')) {
|
||||||
|
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||||
|
}
|
||||||
|
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
||||||
|
return Promise.resolve(jsonResponse({ plans: [] }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('onAuthStateChange fires immediately, then on login and logout', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
stubLoginFlow();
|
||||||
|
|
||||||
|
const states: string[] = [];
|
||||||
|
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
|
||||||
|
expect(states).toEqual(['signed-out']);
|
||||||
|
expect(client.auth.isAuthenticated).toBe(false);
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
expect(states).toEqual(['signed-out', 'signed-in']);
|
||||||
|
expect(client.auth.isAuthenticated).toBe(true);
|
||||||
|
|
||||||
|
client.auth.logout();
|
||||||
|
expect(states).toEqual(['signed-out', 'signed-in', 'signed-out']);
|
||||||
|
expect(client.auth.isAuthenticated).toBe(false);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
client.auth.logout();
|
||||||
|
expect(states).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts signed-in when the token store already holds a token', () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
client.options.tokenStore.saveTokens('at', 'rt');
|
||||||
|
const states: string[] = [];
|
||||||
|
client.auth.onAuthStateChange((state) => states.push(state));
|
||||||
|
expect(states).toEqual(['signed-in']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits signed-out when the transport session expires (refresh fails)', async () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
client.options.tokenStore.saveTokens('expired-at', 'expired-rt');
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
|
||||||
|
));
|
||||||
|
|
||||||
|
const states: string[] = [];
|
||||||
|
client.auth.onAuthStateChange((state) => states.push(state));
|
||||||
|
expect(states).toEqual(['signed-in']);
|
||||||
|
|
||||||
|
await expect(client.player.reload()).rejects.toThrow(RudderAuthError);
|
||||||
|
expect(states).toEqual(['signed-in', 'signed-out']);
|
||||||
|
expect(client.auth.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as sdk from '../src/index.js';
|
||||||
|
import { RudderClient } from '../src/index.js';
|
||||||
|
|
||||||
|
describe('public API surface', () => {
|
||||||
|
it('does not expose scenario runtime types from the root package', () => {
|
||||||
|
expect('ScenarioService' in sdk).toBe(false);
|
||||||
|
expect('NotificationSession' in sdk).toBe(false);
|
||||||
|
expect('StoreSession' in sdk).toBe(false);
|
||||||
|
expect('WaitSession' in sdk).toBe(false);
|
||||||
|
expect('LeaderboardSession' in sdk).toBe(false);
|
||||||
|
expect('PlanRun' in sdk).toBe(false);
|
||||||
|
expect('createIndexedDbPlanStateStore' in sdk).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
|
||||||
|
// Device id helper — internal.
|
||||||
|
expect('getOrCreateDeviceId' in sdk).toBe(false);
|
||||||
|
// Domain classes — exported as types only.
|
||||||
|
for (const name of [
|
||||||
|
'PlayerDomain',
|
||||||
|
'CatalogDomain',
|
||||||
|
'InventoryDomain',
|
||||||
|
'ConfigDomain',
|
||||||
|
'StorageDomain',
|
||||||
|
'StoresDomain',
|
||||||
|
]) {
|
||||||
|
expect(name in sdk).toBe(false);
|
||||||
|
}
|
||||||
|
// Service constructors — exported as types only.
|
||||||
|
for (const name of [
|
||||||
|
'AuthService',
|
||||||
|
'LeaderboardsService',
|
||||||
|
'LeaderboardHandle',
|
||||||
|
'BattlePassService',
|
||||||
|
'BattlepassService',
|
||||||
|
'QuestsService',
|
||||||
|
]) {
|
||||||
|
expect(name in sdk).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the expected value exports', () => {
|
||||||
|
for (const name of [
|
||||||
|
'RudderClient',
|
||||||
|
'RudderError',
|
||||||
|
'RudderNetworkError',
|
||||||
|
'RudderHttpError',
|
||||||
|
'RudderAuthError',
|
||||||
|
'SDK_ERROR_INVALID_OPTIONS',
|
||||||
|
'RudderErrorCodes',
|
||||||
|
'createDefaultTokenStore',
|
||||||
|
'createLocalStorageTokenStore',
|
||||||
|
'SyncedState',
|
||||||
|
'RemoteConfigState',
|
||||||
|
'ShopHandle',
|
||||||
|
'OfferHandle',
|
||||||
|
]) {
|
||||||
|
expect(name in sdk, name).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes effects without a public scenarios service on the client', () => {
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project',
|
||||||
|
runtime: { planStateStore: null, loginEvent: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.effects).toBeDefined();
|
||||||
|
expect('scenarios' in client).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the canonical glossary property names on the client', () => {
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project',
|
||||||
|
runtime: { planStateStore: null, loginEvent: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.remoteConfig).toBeDefined();
|
||||||
|
expect('config' in client).toBe(false);
|
||||||
|
expect(client.battlePass).toBeDefined();
|
||||||
|
expect('battlepass' in client).toBe(false);
|
||||||
|
expect(typeof client.effects.onBattlePass).toBe('function');
|
||||||
|
expect(typeof client.effects.onBattlePassLevel).toBe('function');
|
||||||
|
expect('onBattlepass' in client.effects).toBe(false);
|
||||||
|
expect('onBattlepassLevel' in client.effects).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { RudderClient } from '../src/client/RudderClient.js';
|
||||||
|
import {
|
||||||
|
RudderError,
|
||||||
|
RudderNetworkError,
|
||||||
|
RudderHttpError,
|
||||||
|
RudderAuthError,
|
||||||
|
SDK_ERROR_INVALID_OPTIONS,
|
||||||
|
} from '../src/client/RudderError.js';
|
||||||
|
import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
|
||||||
|
|
||||||
|
describe('RudderClient', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructs with valid options', () => {
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
projectKey: 'proj_123',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
});
|
||||||
|
expect(client.auth).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws RudderError with sdk/invalid-options if baseUrl is missing', () => {
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
new RudderClient({ baseUrl: '', projectKey: 'proj_123' });
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(RudderError);
|
||||||
|
expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS);
|
||||||
|
expect((caught as Error).message).toContain('baseUrl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws RudderError with sdk/invalid-options if projectKey is missing', () => {
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
new RudderClient({ baseUrl: 'https://api.example.com', projectKey: '' });
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(RudderError);
|
||||||
|
expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS);
|
||||||
|
expect((caught as Error).message).toContain('projectKey');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a default localStorage-backed token store when tokenStore is omitted', () => {
|
||||||
|
localStorage.clear();
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
projectKey: 'proj_123',
|
||||||
|
});
|
||||||
|
client.options.tokenStore.saveTokens('at', 'rt');
|
||||||
|
expect(localStorage.getItem('rudder_access_token')).toBe('at');
|
||||||
|
expect(client.options.tokenStore.getAccessToken()).toBe('at');
|
||||||
|
client.options.tokenStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to an in-memory token store when localStorage is unavailable', () => {
|
||||||
|
vi.stubGlobal('localStorage', undefined);
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
projectKey: 'proj_123',
|
||||||
|
});
|
||||||
|
client.options.tokenStore.saveTokens('at', 'rt');
|
||||||
|
expect(client.options.tokenStore.getAccessToken()).toBe('at');
|
||||||
|
expect(client.options.tokenStore.getRefreshToken()).toBe('rt');
|
||||||
|
client.options.tokenStore.clear();
|
||||||
|
expect(client.options.tokenStore.getAccessToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transport error mapping', () => {
|
||||||
|
let client: RudderClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
projectKey: 'proj_123',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws RudderNetworkError with the underlying error as cause', async () => {
|
||||||
|
const underlying = new Error('Network down');
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(underlying));
|
||||||
|
let caught: unknown;
|
||||||
|
await client.player.reload().catch((error) => {
|
||||||
|
caught = error;
|
||||||
|
});
|
||||||
|
expect(caught).toBeInstanceOf(RudderNetworkError);
|
||||||
|
expect((caught as RudderNetworkError).cause).toBe(underlying);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws RudderAuthError on 401', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }),
|
||||||
|
));
|
||||||
|
await expect(client.player.reload())
|
||||||
|
.rejects.toThrow(RudderAuthError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws RudderHttpError with the server error code on non-401 HTTP error', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ code: 'run_expired', error: 'gone' }), {
|
||||||
|
status: 410,
|
||||||
|
statusText: 'Gone',
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
let caught: unknown;
|
||||||
|
await client.player.reload().catch((error) => {
|
||||||
|
caught = error;
|
||||||
|
});
|
||||||
|
expect(caught).toBeInstanceOf(RudderHttpError);
|
||||||
|
expect((caught as RudderHttpError).code).toBe('run_expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for 204 No Content', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(null, { status: 204, statusText: 'No Content' }),
|
||||||
|
));
|
||||||
|
const result = await client.storage.save([]);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns parsed JSON for 200 responses', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ player: { id: 'player-1' }, wallets: [] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
const result = await client.player.reload();
|
||||||
|
expect(result).toEqual({ player: { id: 'player-1' }, wallets: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lazy scenario runtime', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create the scenario runtime until first use', async () => {
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
projectKey: 'proj_123',
|
||||||
|
runtime: { planStateStore: null, loginEvent: null },
|
||||||
|
});
|
||||||
|
const internals = client as unknown as {
|
||||||
|
scenarioRuntime: unknown;
|
||||||
|
ensureScenarioRuntime(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
expect(internals.scenarioRuntime).toBeNull();
|
||||||
|
await internals.ensureScenarioRuntime();
|
||||||
|
expect(internals.scenarioRuntime).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers effects to handlers subscribed before the runtime is ready', async () => {
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
projectKey: 'proj_123',
|
||||||
|
runtime: { planStateStore: null }, // default loginEvent: player_login
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifications: string[] = [];
|
||||||
|
client.effects.onNotification((effect) => {
|
||||||
|
notifications.push(effect.message);
|
||||||
|
return effect.done();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
if (path === '/sdk/v1/authorization/device') {
|
||||||
|
return Promise.resolve(new Response(
|
||||||
|
JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }),
|
||||||
|
{ status: 200 },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (path === '/sdk/v1/scenarios/trigger') {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
plans: [{
|
||||||
|
planId: 'plan-1',
|
||||||
|
scenarioId: 'scenario-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
startNodeId: 'start',
|
||||||
|
runId: 'run-1',
|
||||||
|
nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }],
|
||||||
|
edges: [],
|
||||||
|
boundaryNodes: [],
|
||||||
|
}],
|
||||||
|
}), { status: 200 }));
|
||||||
|
}
|
||||||
|
if (path === '/sdk/v1/remote-configs') {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
|
||||||
|
}
|
||||||
|
if (path === '/sdk/v1/stores') {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 }));
|
||||||
|
}
|
||||||
|
if (path === '/sdk/v1/catalog') {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||||
|
}
|
||||||
|
if (path === '/sdk/v1/sync') {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
||||||
|
}
|
||||||
|
void init;
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 }));
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
await vi.waitFor(() => expect(notifications).toEqual(['Welcome!']));
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { ScenarioService } from '../src/scenario/ScenarioService.js';
|
||||||
|
import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js';
|
||||||
|
import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
|
||||||
|
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge } from '../src/generated/common.js';
|
||||||
|
import { NotificationSession, StoreSession, WaitSession, LeaderboardSession } from '../src/scenario/engine/sessions.js';
|
||||||
|
|
||||||
|
/** Builds a simple execution plan with the given nodes and edges. */
|
||||||
|
function makePlan(overrides?: Partial<ExecutionPlan>): ExecutionPlan {
|
||||||
|
return {
|
||||||
|
planId: 'plan-1',
|
||||||
|
scenarioId: 'scenario-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
startNodeId: 'start',
|
||||||
|
runId: 'run-1',
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
boundaryNodes: [],
|
||||||
|
context: undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode(id: string, type: string, data?: Record<string, unknown>): ExecutionPlanNode {
|
||||||
|
return { id, type, data: data as ExecutionPlanNode['data'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEdge(source: string, sourceHandle: string, target: string): PlanEdge {
|
||||||
|
return { id: `edge-${source}-${target}`, source, sourceHandle, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> {
|
||||||
|
const client = new RudderClient({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-key',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
runtime: { planStateStore: null, loginEvent: null }, // Disable IndexedDB for tests
|
||||||
|
});
|
||||||
|
return { client, scenarios: await getScenarioRuntime(client) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScenarioService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('crypto', {
|
||||||
|
randomUUID: () => '00000000-0000-4000-a000-000000000001',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('send (trigger)', () => {
|
||||||
|
it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => {
|
||||||
|
const { client, scenarios } = await createClientWithScenario();
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'notification')],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
const onNotification = vi.fn();
|
||||||
|
scenarios.onNotification = onNotification;
|
||||||
|
|
||||||
|
const response = await scenarios.send('level_complete');
|
||||||
|
|
||||||
|
expect(response.plans).toHaveLength(1);
|
||||||
|
expect(scenarios.isRunning).toBe(true);
|
||||||
|
expect(onNotification).toHaveBeenCalledOnce();
|
||||||
|
expect(onNotification.mock.calls[0][0]).toBeInstanceOf(NotificationSession);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('node dispatch', () => {
|
||||||
|
it('notification node fires onNotification', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onNotif = vi.fn();
|
||||||
|
scenarios.onNotification = onNotif;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'notification')],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onNotif).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('store node fires onStore', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onStore = vi.fn();
|
||||||
|
scenarios.onStore = onStore;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'store')],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onStore).toHaveBeenCalledOnce();
|
||||||
|
expect(onStore.mock.calls[0][0]).toBeInstanceOf(StoreSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wait node fires onWait', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onWait = vi.fn();
|
||||||
|
scenarios.onWait = onWait;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'wait', { duration: 5, unit: 'minutes' })],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onWait).toHaveBeenCalledOnce();
|
||||||
|
expect(onWait.mock.calls[0][0]).toBeInstanceOf(WaitSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaderboard node fires onLeaderboard', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onLb = vi.fn();
|
||||||
|
scenarios.onLeaderboard = onLb;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'leaderboard')],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onLb).toHaveBeenCalledOnce();
|
||||||
|
expect(onLb.mock.calls[0][0]).toBeInstanceOf(LeaderboardSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quest node dispatches onQuest instead of stalling', async () => {
|
||||||
|
const { client, scenarios } = await createClientWithScenario();
|
||||||
|
const onQuest = vi.fn();
|
||||||
|
client.effects.onQuest(onQuest);
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [
|
||||||
|
makeNode('start', 'quest', {
|
||||||
|
name: 'Daily',
|
||||||
|
objectives: [{ objectiveId: 'kills', target: 10 }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onQuest).toHaveBeenCalledOnce();
|
||||||
|
expect(onQuest.mock.calls[0][0].name).toBe('Daily');
|
||||||
|
// Active and waiting for progress — not stalled, not failed.
|
||||||
|
expect(scenarios.isRunning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('battlepass node dispatches onBattlePass', async () => {
|
||||||
|
const { client, scenarios } = await createClientWithScenario();
|
||||||
|
const onBp = vi.fn();
|
||||||
|
client.effects.onBattlePass(onBp);
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'battlepass', { premiumPrice: 100 })],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onBp).toHaveBeenCalledOnce();
|
||||||
|
expect(scenarios.isRunning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('battlepass_level node dispatches onBattlePassLevel', async () => {
|
||||||
|
const { client, scenarios } = await createClientWithScenario();
|
||||||
|
const onLevel = vi.fn();
|
||||||
|
client.effects.onBattlePassLevel(onLevel);
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'battlepass_level', { level: 3 })],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onLevel).toHaveBeenCalledOnce();
|
||||||
|
expect(onLevel.mock.calls[0][0].level).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remote_config_override node applies patches internally and auto-completes', async () => {
|
||||||
|
const { client, scenarios } = await createClientWithScenario();
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [
|
||||||
|
makeNode('start', 'remote_config_override', {
|
||||||
|
patches: [
|
||||||
|
{ path: 'difficulty', valueType: 'string', value: 'hard' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(client.remoteConfig.get('difficulty', '')).toBe('hard');
|
||||||
|
// RemoteConfigOverride auto-completes asynchronously — wait for the promise.
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(scenarios.isRunning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsupported node type fails the run and surfaces onScenarioFailed', async () => {
|
||||||
|
const { client, scenarios } = await createClientWithScenario();
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const onFailed = vi.fn();
|
||||||
|
client.effects.onScenarioFailed(onFailed);
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'unknown_type')],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Unsupported scenario node type'),
|
||||||
|
);
|
||||||
|
// The run must NOT stall: it fails and the game is notified.
|
||||||
|
expect(onFailed).toHaveBeenCalledOnce();
|
||||||
|
expect(scenarios.isRunning).toBe(false);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DAG traversal', () => {
|
||||||
|
it('completing a node follows matching edges', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
|
||||||
|
const onNotif = vi.fn();
|
||||||
|
scenarios.onNotification = onNotif;
|
||||||
|
|
||||||
|
// start(notif) → complete("output") → node2(notif)
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [
|
||||||
|
makeNode('start', 'notification'),
|
||||||
|
makeNode('node2', 'notification'),
|
||||||
|
],
|
||||||
|
edges: [makeEdge('start', 'output', 'node2')],
|
||||||
|
});
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
||||||
|
}));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onNotif).toHaveBeenCalledOnce(); // start node dispatched
|
||||||
|
|
||||||
|
// Complete the notification node with handle "output"
|
||||||
|
const session = onNotif.mock.calls[0][0] as NotificationSession;
|
||||||
|
await session.complete();
|
||||||
|
expect(onNotif).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completing a node with already-completed handle is idempotent', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
|
||||||
|
const onNotif = vi.fn();
|
||||||
|
scenarios.onNotification = onNotif;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [
|
||||||
|
makeNode('start', 'notification'),
|
||||||
|
makeNode('node2', 'notification'),
|
||||||
|
],
|
||||||
|
edges: [makeEdge('start', 'output', 'node2')],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(scenarios.activeRuns).toHaveLength(1);
|
||||||
|
|
||||||
|
const session = onNotif.mock.calls[0][0] as NotificationSession;
|
||||||
|
await session.complete();
|
||||||
|
|
||||||
|
// node2 should now be active
|
||||||
|
expect(scenarios.activeRuns).toHaveLength(1);
|
||||||
|
|
||||||
|
// Complete node2
|
||||||
|
const session2 = onNotif.mock.calls[1][0] as NotificationSession;
|
||||||
|
await session2.complete();
|
||||||
|
|
||||||
|
// Run should be completed
|
||||||
|
expect(scenarios.isRunning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('run completion fires onRunCompleted and onCompleted', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
|
||||||
|
const onCompleted = vi.fn();
|
||||||
|
const onRunCompleted = vi.fn();
|
||||||
|
scenarios.onCompleted = onCompleted;
|
||||||
|
scenarios.onRunCompleted = onRunCompleted;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'notification')],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
const session = (scenarios as unknown as { onNotification?: (s: NotificationSession) => void }).onNotification?.(
|
||||||
|
// Use the mock calls to get the session
|
||||||
|
vi.mocked(vi.fn()).mock.calls[0]?.[0] as NotificationSession,
|
||||||
|
);
|
||||||
|
// We need to get the session from the mock spy
|
||||||
|
// Actually let's trigger via respond()
|
||||||
|
scenarios.respond('output');
|
||||||
|
// Fire-and-forget — wait a tick
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
expect(onRunCompleted).toHaveBeenCalled();
|
||||||
|
expect(onCompleted).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wait nodes', () => {
|
||||||
|
it('sets a deadline and fires onWait', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onWait = vi.fn();
|
||||||
|
scenarios.onWait = onWait;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onWait).toHaveBeenCalledOnce();
|
||||||
|
const session = onWait.mock.calls[0][0] as WaitSession;
|
||||||
|
expect(session.deadlineUtc).toBeInstanceOf(Date);
|
||||||
|
// Deadline should be ~10 minutes from now.
|
||||||
|
const diff = session.deadlineUtc.getTime() - Date.now();
|
||||||
|
expect(diff).toBeGreaterThan(9 * 60 * 1000);
|
||||||
|
expect(diff).toBeLessThan(11 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes immediately if deadline has already passed', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onWait = vi.fn();
|
||||||
|
const onCompleted = vi.fn();
|
||||||
|
scenarios.onWait = onWait;
|
||||||
|
scenarios.onCompleted = onCompleted;
|
||||||
|
|
||||||
|
// Duration of 0 should result in an immediate completion.
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(onWait).toHaveBeenCalledOnce();
|
||||||
|
// Wait for the setTimeout(0) to fire.
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(onCompleted).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('store session', () => {
|
||||||
|
it('buy() purchases the selected offer and completes with onPurchase', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onStore = vi.fn();
|
||||||
|
scenarios.onStore = onStore;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'store', { storeSlug: 'starter' })],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/scenarios/trigger') {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
|
||||||
|
}
|
||||||
|
if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
name: 'Starter',
|
||||||
|
slug: 'starter',
|
||||||
|
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
|
||||||
|
}), { status: 200 }));
|
||||||
|
}
|
||||||
|
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/stores/starter/offers/pack_1/purchase') {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ success: true, purchaseId: 'purchase-1' }), { status: 200 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
||||||
|
}));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
const session = onStore.mock.calls[0][0] as StoreSession;
|
||||||
|
|
||||||
|
const onCompleted = vi.fn();
|
||||||
|
scenarios.onCompleted = onCompleted;
|
||||||
|
|
||||||
|
const store = await session.getStore();
|
||||||
|
const purchase = await session.buy(store.offers[0]);
|
||||||
|
expect(purchase.success).toBe(true);
|
||||||
|
expect(session.isResolved).toBe(true);
|
||||||
|
|
||||||
|
// Second call should be a no-op.
|
||||||
|
const duplicate = await session.buy(store.offers[0]);
|
||||||
|
expect(duplicate.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decline() completes with onDecline', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onStore = vi.fn();
|
||||||
|
scenarios.onStore = onStore;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'store')],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
const session = onStore.mock.calls[0][0] as StoreSession;
|
||||||
|
await session.decline();
|
||||||
|
expect(session.isResolved).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('respond / clear', () => {
|
||||||
|
it('respond completes the first active node', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
const onNotif = vi.fn();
|
||||||
|
scenarios.onNotification = onNotif;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [
|
||||||
|
makeNode('start', 'notification'),
|
||||||
|
makeNode('node2', 'notification'),
|
||||||
|
],
|
||||||
|
edges: [makeEdge('start', 'output', 'node2')],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
|
||||||
|
// respond() completes the first active node with the given handle.
|
||||||
|
scenarios.respond('output');
|
||||||
|
|
||||||
|
// Wait for async completion.
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
// node2 should now be active (second notification dispatched).
|
||||||
|
expect(scenarios.isRunning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear removes all runs', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
nodes: [makeNode('start', 'notification')],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
|
||||||
|
));
|
||||||
|
|
||||||
|
await scenarios.send('test');
|
||||||
|
expect(scenarios.isRunning).toBe(true);
|
||||||
|
|
||||||
|
scenarios.clear();
|
||||||
|
expect(scenarios.isRunning).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runId filtering', () => {
|
||||||
|
it('rejects plans with duplicate runId', async () => {
|
||||||
|
const { scenarios } = await createClientWithScenario();
|
||||||
|
|
||||||
|
const onNotification = vi.fn();
|
||||||
|
scenarios.onNotification = onNotification;
|
||||||
|
|
||||||
|
const plan = makePlan({
|
||||||
|
runId: 'run-1',
|
||||||
|
nodes: [makeNode('start', 'notification')],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
|
||||||
|
Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })),
|
||||||
|
));
|
||||||
|
|
||||||
|
// First send creates a run
|
||||||
|
await scenarios.send('first-event');
|
||||||
|
expect(scenarios.activeRuns).toHaveLength(1);
|
||||||
|
expect(onNotification).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Second send with same runId is rejected by startPlan dedup
|
||||||
|
await scenarios.send('second-event');
|
||||||
|
expect(scenarios.activeRuns).toHaveLength(1);
|
||||||
|
expect(onNotification).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Minimal fetch wrapper for the LiveOps platform/admin HTTP API used by the seed
|
||||||
|
// and by tests' admin-side assertions. Retries network errors and 5xx with backoff;
|
||||||
|
// fails fast on 4xx.
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly path: string,
|
||||||
|
public readonly body: string,
|
||||||
|
) {
|
||||||
|
super(`HTTP ${status} on ${path}: ${body.slice(0, 400)}`);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminClient {
|
||||||
|
setToken(token: string): void;
|
||||||
|
readonly token: string | undefined;
|
||||||
|
get<T>(path: string, opts?: { auth?: boolean }): Promise<T>;
|
||||||
|
post<T>(path: string, body?: unknown, opts?: { auth?: boolean }): Promise<T>;
|
||||||
|
put<T>(path: string, body?: unknown, opts?: { auth?: boolean }): Promise<T>;
|
||||||
|
del<T>(path: string, opts?: { auth?: boolean }): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminClient(baseUrl: string): AdminClient {
|
||||||
|
let token: string | undefined;
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
opts?: { auth?: boolean },
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${baseUrl}${path}`;
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (opts?.auth !== false && token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (res.status >= 500) {
|
||||||
|
lastErr = new ApiError(res.status, path, text);
|
||||||
|
await sleep(300 * (attempt + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new ApiError(res.status, path, text);
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) throw err; // 4xx — don't retry
|
||||||
|
lastErr = err; // network error — retry
|
||||||
|
await sleep(300 * (attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setToken(t: string) {
|
||||||
|
token = t;
|
||||||
|
},
|
||||||
|
get token() {
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
get: (path, opts) => request('GET', path, undefined, opts),
|
||||||
|
post: (path, body, opts) => request('POST', path, body, opts),
|
||||||
|
put: (path, body, opts) => request('PUT', path, body, opts),
|
||||||
|
del: (path, opts) => request('DELETE', path, undefined, opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
|
export const ARTIFACT_PATH = resolve(process.cwd(), '.e2e-prod.local.json');
|
||||||
|
|
||||||
|
export function writeArtifact(artifact: SeedArtifact): void {
|
||||||
|
writeFileSync(ARTIFACT_PATH, JSON.stringify(artifact, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readArtifact(): SeedArtifact | null {
|
||||||
|
if (!existsSync(ARTIFACT_PATH)) return null;
|
||||||
|
return JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8')) as SeedArtifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeArtifact(): void {
|
||||||
|
if (existsSync(ARTIFACT_PATH)) rmSync(ARTIFACT_PATH);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Vitest globalSetup for the production e2e suite.
|
||||||
|
//
|
||||||
|
// Setup: seeds a fresh project on prod (unless RUDDER_E2E_PROJECT_KEY is provided) and
|
||||||
|
// writes .e2e-prod.local.json for the tests to read.
|
||||||
|
// Teardown: deletes the seeded project (default). Set RUDDER_E2E_KEEP=1 to keep it.
|
||||||
|
|
||||||
|
import { runSeed, deleteProject } from './seed.js';
|
||||||
|
import { readArtifact, writeArtifact, removeArtifact } from './artifact.js';
|
||||||
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
|
export default async function setup(): Promise<() => Promise<void>> {
|
||||||
|
const baseUrl = process.env.RUDDER_E2E_BASE_URL ?? 'https://api.rudder.build';
|
||||||
|
|
||||||
|
// Externally-provided project: skip seeding, just verify an artifact/env exists.
|
||||||
|
if (process.env.RUDDER_E2E_PROJECT_KEY) {
|
||||||
|
const existing = readArtifact();
|
||||||
|
if (!existing) {
|
||||||
|
writeArtifact({
|
||||||
|
baseUrl,
|
||||||
|
projectKey: process.env.RUDDER_E2E_PROJECT_KEY,
|
||||||
|
// Remaining fields are unknown for an external project; tests relying on the
|
||||||
|
// seeded dataset/admin token will require a real seed run.
|
||||||
|
} as unknown as SeedArtifact);
|
||||||
|
}
|
||||||
|
return async () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = (m: string) => console.log(`[seed] ${m}`);
|
||||||
|
log(`seeding ${baseUrl} ...`);
|
||||||
|
const artifact = await runSeed(baseUrl, log);
|
||||||
|
writeArtifact(artifact);
|
||||||
|
log(`seed complete: project=${artifact.projectId}`);
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
if (process.env.RUDDER_E2E_KEEP === '1') {
|
||||||
|
console.log('[seed] RUDDER_E2E_KEEP=1 — leaving project in place');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteProject(artifact);
|
||||||
|
console.log(`[seed] deleted project ${artifact.projectId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[seed] failed to delete project ${artifact.projectId}: ${String(err)}`);
|
||||||
|
} finally {
|
||||||
|
removeArtifact();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Test-side helpers: load the seed artifact, build SDK clients against prod, and
|
||||||
|
// reach the admin API for wallet grants / player inspection.
|
||||||
|
|
||||||
|
import { RudderClient } from '../../../src/client/RudderClient.js';
|
||||||
|
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
|
||||||
|
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
||||||
|
import { createAdminClient, type AdminClient } from './adminClient.js';
|
||||||
|
import { readArtifact } from './artifact.js';
|
||||||
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
|
type RuntimeOptions = {
|
||||||
|
planStateStore?: {
|
||||||
|
state: string | null;
|
||||||
|
load(): Promise<void>;
|
||||||
|
save(state: string | null): Promise<void>;
|
||||||
|
} | null;
|
||||||
|
loginEvent?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
|
||||||
|
export function loadArtifact(): SeedArtifact {
|
||||||
|
const file = readArtifact();
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(
|
||||||
|
'No seed artifact (.e2e-prod.local.json). Run `npm run test:e2e-prod` (globalSetup seeds automatically).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
baseUrl: process.env.RUDDER_E2E_BASE_URL ?? file.baseUrl,
|
||||||
|
projectKey: process.env.RUDDER_E2E_PROJECT_KEY ?? file.projectKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInMemoryTokenStore(): TokenStore {
|
||||||
|
let access: string | null = null;
|
||||||
|
let refresh: string | null = null;
|
||||||
|
return {
|
||||||
|
getAccessToken: () => access,
|
||||||
|
getRefreshToken: () => refresh,
|
||||||
|
saveTokens: (a, r) => {
|
||||||
|
access = a;
|
||||||
|
refresh = r;
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
access = null;
|
||||||
|
refresh = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
|
||||||
|
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||||
|
artifact: SeedArtifact = loadArtifact(),
|
||||||
|
runtime: RuntimeOptions = { planStateStore: null, loginEvent: null },
|
||||||
|
): RudderClient<TConfig> {
|
||||||
|
return new RudderClient<TConfig>({
|
||||||
|
baseUrl: artifact.baseUrl,
|
||||||
|
projectKey: artifact.projectKey,
|
||||||
|
tokenStore: createInMemoryTokenStore(),
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
|
||||||
|
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||||
|
artifact: SeedArtifact = loadArtifact(),
|
||||||
|
runtime?: RuntimeOptions,
|
||||||
|
): Promise<RudderClient<TConfig>> {
|
||||||
|
const client = makeProdClient<TConfig>(artifact, runtime);
|
||||||
|
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin (operator) API client authenticated with the seeded operator token. */
|
||||||
|
export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
|
||||||
|
const api = createAdminClient(artifact.baseUrl);
|
||||||
|
api.setToken(artifact.adminToken);
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
|
||||||
|
export async function withReadRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
ok: (value: T) => boolean,
|
||||||
|
tries = 8,
|
||||||
|
gapMs = 1500,
|
||||||
|
): Promise<T> {
|
||||||
|
let last = await fn();
|
||||||
|
if (ok(last)) return last;
|
||||||
|
for (let i = 1; i < tries; i++) {
|
||||||
|
await sleep(gapMs);
|
||||||
|
last = await fn();
|
||||||
|
if (ok(last)) return last;
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// Production seed: registers an operator, creates a project, configures staging
|
||||||
|
// content (items, remote configs, leaderboard, store with free+paid offers, a
|
||||||
|
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
|
||||||
|
// Excludes realtime & analytics.
|
||||||
|
//
|
||||||
|
// The scenario flow uses the SDK runtime's exact node `data` keys and handle names —
|
||||||
|
// the game's plan builder copies node.data verbatim into the execution plan
|
||||||
|
// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs.
|
||||||
|
|
||||||
|
import { createAdminClient, type AdminClient } from './adminClient.js';
|
||||||
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
interface ReleaseResponse {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
objectives?: Array<{ id: string; metric: string; target: number }>;
|
||||||
|
rewards?: Array<{ itemId?: string; currency?: string; amount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoreResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data?: { slug?: string };
|
||||||
|
offers?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contents?: Array<{ itemId: string; amount: number }>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFlow(scenarioSlug: string) {
|
||||||
|
return {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } },
|
||||||
|
{ id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } },
|
||||||
|
{
|
||||||
|
id: 'rc_1',
|
||||||
|
type: 'remote_config_override',
|
||||||
|
data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] },
|
||||||
|
},
|
||||||
|
{ id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } },
|
||||||
|
{ id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } },
|
||||||
|
{ id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' },
|
||||||
|
{ id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' },
|
||||||
|
{ id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' },
|
||||||
|
{ id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' },
|
||||||
|
{ id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollRelease(
|
||||||
|
api: AdminClient,
|
||||||
|
projPath: string,
|
||||||
|
env: string,
|
||||||
|
releaseId: string,
|
||||||
|
log: (m: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + 60_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const releases = await api.get<ReleaseResponse[]>(
|
||||||
|
`${projPath}/releases?environment=${env}&limit=20&offset=0`,
|
||||||
|
);
|
||||||
|
const rel = releases.find((r) => r.id === releaseId);
|
||||||
|
if (rel) {
|
||||||
|
if (rel.status === 'completed') {
|
||||||
|
log(`release ${releaseId} completed (v${rel.version})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rel.status === 'failed') {
|
||||||
|
throw new Error(`release ${releaseId} failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(1500);
|
||||||
|
}
|
||||||
|
throw new Error(`release ${releaseId} did not complete within 60s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSeed(
|
||||||
|
baseUrl: string,
|
||||||
|
log: (m: string) => void = () => {},
|
||||||
|
): Promise<SeedArtifact> {
|
||||||
|
const api = createAdminClient(baseUrl);
|
||||||
|
const ts = Date.now();
|
||||||
|
const authoringEnv = 'staging';
|
||||||
|
const runtimeEnv = 'prod';
|
||||||
|
const q = `?environment=${authoringEnv}`;
|
||||||
|
const prodQ = `?environment=${runtimeEnv}`;
|
||||||
|
const adminEmail = `e2e+${ts}@rudder.build`;
|
||||||
|
const password = `Passw0rd!e2e-${ts}`;
|
||||||
|
|
||||||
|
// 1. Register operator (open registration; no approval gate at the API layer).
|
||||||
|
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
|
||||||
|
'/platform/v1/authorization/register',
|
||||||
|
{ email: adminEmail, password, fullName: 'E2E Bot' },
|
||||||
|
{ auth: false },
|
||||||
|
);
|
||||||
|
api.setToken(auth.accessToken);
|
||||||
|
log(`registered ${adminEmail}`);
|
||||||
|
|
||||||
|
// 2. Project.
|
||||||
|
const project = await api.post<{ id: string; key: string; name: string }>(
|
||||||
|
'/platform/v1/projects',
|
||||||
|
{ name: `e2e-${ts}` },
|
||||||
|
);
|
||||||
|
const projPath = `/platform/v1/projects/${project.id}`;
|
||||||
|
log(`project ${project.id} key=${project.key}`);
|
||||||
|
|
||||||
|
// 3. Staging environment (idempotent — may already exist).
|
||||||
|
try {
|
||||||
|
await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv });
|
||||||
|
} catch {
|
||||||
|
// already exists / not required — content POSTs below are the real test
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Item (granted as a paid offer's contents).
|
||||||
|
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
|
||||||
|
projectId: project.id,
|
||||||
|
environment: authoringEnv,
|
||||||
|
name: 'Gold Coin',
|
||||||
|
tags: ['currency'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Remote configs — one per value type.
|
||||||
|
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
|
||||||
|
{ key: 'max_energy', value: '100', valueType: 'int' },
|
||||||
|
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
|
||||||
|
{ key: 'feature_x', value: 'true', valueType: 'bool' },
|
||||||
|
{ key: 'welcome_text', value: 'hi', valueType: 'string' },
|
||||||
|
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
|
||||||
|
];
|
||||||
|
for (const rc of remoteConfigs) {
|
||||||
|
await api.post(`${projPath}/remote-configs${q}`, {
|
||||||
|
projectId: project.id,
|
||||||
|
environment: authoringEnv,
|
||||||
|
...rc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
log(`created ${remoteConfigs.length} remote configs`);
|
||||||
|
|
||||||
|
// 6. Leaderboard.
|
||||||
|
await api.post(`${projPath}/leaderboards${q}`, {
|
||||||
|
projectId: project.id,
|
||||||
|
environment: authoringEnv,
|
||||||
|
name: 'Weekly Score',
|
||||||
|
slug: 'weekly-score',
|
||||||
|
metric: 'score',
|
||||||
|
resetPeriod: 'weekly',
|
||||||
|
sortingOrder: 'desc',
|
||||||
|
maxEntries: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Store with a free offer and a paid (currency) offer with a purchase limit.
|
||||||
|
// The runtime resolves a store's slug from data.slug (liveops-game shop cache), and
|
||||||
|
// the CreateStore API has no slug field — so we set it via data.
|
||||||
|
const storeSlug = 'starter-shop';
|
||||||
|
const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>(
|
||||||
|
`${projPath}/stores${q}`,
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
environment: authoringEnv,
|
||||||
|
name: 'Starter Shop',
|
||||||
|
description: 'E2E shop',
|
||||||
|
data: { slug: storeSlug },
|
||||||
|
offers: [
|
||||||
|
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } },
|
||||||
|
{
|
||||||
|
name: 'Gold Pack',
|
||||||
|
contents: [{ itemId: item.id, amount: 50 }],
|
||||||
|
price: { currency: 'soft', amount: 100 },
|
||||||
|
maxPurchases: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
|
||||||
|
const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack');
|
||||||
|
if (!paidOffer?.id) {
|
||||||
|
throw new Error('seeded paid offer did not return an id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
|
||||||
|
const questName = 'Buy Gold Pack Quest';
|
||||||
|
const questObjectiveId = 'buy-gold-pack';
|
||||||
|
const questTarget = 1;
|
||||||
|
const questMetric = `purchase.offer:${paidOffer.id}`;
|
||||||
|
const questRewardAmount = 7;
|
||||||
|
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
|
||||||
|
projectId: project.id,
|
||||||
|
environment: authoringEnv,
|
||||||
|
name: questName,
|
||||||
|
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
|
||||||
|
rewards: [{ itemId: item.id, amount: questRewardAmount }],
|
||||||
|
});
|
||||||
|
log(`quest ${quest.id} staged for metric ${questMetric}`);
|
||||||
|
|
||||||
|
// 9. Scenario (must be live now: startAt <= now <= endAt).
|
||||||
|
const startAt = new Date(ts - 3600_000).toISOString();
|
||||||
|
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
|
||||||
|
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
|
||||||
|
projectId: project.id,
|
||||||
|
environment: authoringEnv,
|
||||||
|
name: 'Onboarding',
|
||||||
|
description: 'e2e onboarding',
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification).
|
||||||
|
await api.put(`${projPath}/scenarios/${scenario.id}`, {
|
||||||
|
projectId: project.id,
|
||||||
|
scenarioId: scenario.id,
|
||||||
|
flow: buildFlow(storeSlug),
|
||||||
|
});
|
||||||
|
log(`scenario ${scenario.id} flow set`);
|
||||||
|
|
||||||
|
// 11. Promote staging content to prod (the runtime serves prod/latest.json).
|
||||||
|
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`);
|
||||||
|
await pollRelease(api, projPath, runtimeEnv, release.id, log);
|
||||||
|
|
||||||
|
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
|
||||||
|
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
|
||||||
|
if (!prodStore) {
|
||||||
|
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
|
||||||
|
}
|
||||||
|
const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack');
|
||||||
|
if (!prodPaidOffer?.id) {
|
||||||
|
throw new Error('promoted paid offer did not return an id');
|
||||||
|
}
|
||||||
|
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
|
||||||
|
if (!prodRewardItemId) {
|
||||||
|
throw new Error('promoted paid offer did not return a reward item id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
|
||||||
|
const prodQuest = prodQuests.find((q) => q.name === questName);
|
||||||
|
if (!prodQuest?.id) {
|
||||||
|
throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`);
|
||||||
|
}
|
||||||
|
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
|
||||||
|
if (!prodQuestMetric) {
|
||||||
|
throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`);
|
||||||
|
}
|
||||||
|
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
|
||||||
|
if (!prodQuestRewardItemId) {
|
||||||
|
throw new Error(`promoted quest ${questName} did not return reward item id`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Settle: let the game service hot-reload caches from the new snapshot.
|
||||||
|
await sleep(3000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
projectId: project.id,
|
||||||
|
projectKey: project.key,
|
||||||
|
adminToken: auth.accessToken,
|
||||||
|
adminEmail,
|
||||||
|
environment: runtimeEnv,
|
||||||
|
seededAt: new Date(ts).toISOString(),
|
||||||
|
releaseId: release.id,
|
||||||
|
store: {
|
||||||
|
name: 'Starter Shop',
|
||||||
|
slug: storeSlug,
|
||||||
|
freeOfferName: 'Welcome Gift',
|
||||||
|
paidOfferName: 'Gold Pack',
|
||||||
|
paidOfferId: prodPaidOffer.id,
|
||||||
|
paidPrice: { currency: 'soft', amount: 100 },
|
||||||
|
paidMaxPurchases: 2,
|
||||||
|
grantedItemName: 'Gold Coin',
|
||||||
|
paidGrantAmount: 50,
|
||||||
|
},
|
||||||
|
quest: {
|
||||||
|
id: prodQuest.id,
|
||||||
|
name: questName,
|
||||||
|
objectiveId: questObjectiveId,
|
||||||
|
metric: prodQuestMetric,
|
||||||
|
target: questTarget,
|
||||||
|
rewardItemId: prodQuestRewardItemId,
|
||||||
|
rewardAmount: questRewardAmount,
|
||||||
|
},
|
||||||
|
leaderboard: { slug: 'weekly-score', metric: 'score' },
|
||||||
|
item: { id: prodRewardItemId, name: item.name },
|
||||||
|
remoteConfigs: {
|
||||||
|
maxEnergy: 100,
|
||||||
|
spawnRate: 1.5,
|
||||||
|
featureX: true,
|
||||||
|
welcomeText: 'hi',
|
||||||
|
shopLayout: { cols: 3 },
|
||||||
|
spawnRateOverride: 3.0,
|
||||||
|
},
|
||||||
|
scenario: { id: scenario.id, event: 'session_start' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(artifact: SeedArtifact): Promise<void> {
|
||||||
|
const api = createAdminClient(artifact.baseUrl);
|
||||||
|
api.setToken(artifact.adminToken);
|
||||||
|
await api.del(`/platform/v1/projects/${artifact.projectId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Shared types for the production e2e harness.
|
||||||
|
|
||||||
|
/** Persisted seed output, written to .e2e-prod.local.json and read by tests. */
|
||||||
|
export interface SeedArtifact {
|
||||||
|
baseUrl: string;
|
||||||
|
projectId: string;
|
||||||
|
projectKey: string;
|
||||||
|
/** Operator (platform) bearer token — used for admin calls (wallet grant, player details). */
|
||||||
|
adminToken: string;
|
||||||
|
adminEmail: string;
|
||||||
|
environment: string;
|
||||||
|
seededAt: string;
|
||||||
|
releaseId: string;
|
||||||
|
store: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
freeOfferName: string;
|
||||||
|
paidOfferName: string;
|
||||||
|
paidOfferId: string;
|
||||||
|
paidPrice: { currency: string; amount: number };
|
||||||
|
paidMaxPurchases: number;
|
||||||
|
grantedItemName: string;
|
||||||
|
paidGrantAmount: number;
|
||||||
|
};
|
||||||
|
quest: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
objectiveId: string;
|
||||||
|
metric: string;
|
||||||
|
target: number;
|
||||||
|
rewardItemId: string;
|
||||||
|
rewardAmount: number;
|
||||||
|
};
|
||||||
|
leaderboard: { slug: string; metric: string };
|
||||||
|
item: { id: string; name: string };
|
||||||
|
remoteConfigs: {
|
||||||
|
maxEnergy: number;
|
||||||
|
spawnRate: number;
|
||||||
|
featureX: boolean;
|
||||||
|
welcomeText: string;
|
||||||
|
shopLayout: Record<string, unknown>;
|
||||||
|
/** spawn_rate value the scenario's remote_config_override applies. */
|
||||||
|
spawnRateOverride: number;
|
||||||
|
};
|
||||||
|
scenario: { id: string; event: string };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { loadArtifact } from './_setup/harness.js';
|
||||||
|
|
||||||
|
// Smoke gate: if egress to prod is blocked or the gateway is down, fail fast here.
|
||||||
|
describe('e2e-prod: health', () => {
|
||||||
|
let baseUrl: string;
|
||||||
|
beforeAll(() => {
|
||||||
|
baseUrl = loadArtifact().baseUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /health is ok', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/health`);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const body = (await res.json()) as { status?: string };
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('content + game gRPC backends are serving', async () => {
|
||||||
|
// /readyz may be 503 if analytics is not_serving (out of scope here); assert the
|
||||||
|
// backends this suite actually uses are serving.
|
||||||
|
const res = await fetch(`${baseUrl}/readyz`);
|
||||||
|
const body = (await res.json()) as { grpc?: Record<string, string> };
|
||||||
|
expect(body.grpc?.content).toBe('serving');
|
||||||
|
expect(body.grpc?.game).toBe('serving');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
|
||||||
|
import type { SeedArtifact } from './_setup/types.js';
|
||||||
|
|
||||||
|
describe('e2e-prod: leaderboards', () => {
|
||||||
|
let artifact: SeedArtifact;
|
||||||
|
beforeAll(() => {
|
||||||
|
artifact = loadArtifact();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits a score and sees it in the ranking', async () => {
|
||||||
|
const client = await freshPlayer(artifact);
|
||||||
|
const me = (await client.player.reload()).player?.id;
|
||||||
|
expect(me).toBeTruthy();
|
||||||
|
|
||||||
|
const lb = client.leaderboards.findBySlug(artifact.leaderboard.slug);
|
||||||
|
const score = 4242;
|
||||||
|
await lb.submit(score);
|
||||||
|
|
||||||
|
const entries = await withReadRetry(
|
||||||
|
() => lb.list(50),
|
||||||
|
(list) => list.some((e) => e.playerId === me),
|
||||||
|
);
|
||||||
|
const mine = entries.find((e) => e.playerId === me);
|
||||||
|
expect(mine).toBeDefined();
|
||||||
|
// int64 fields (score, rank) serialize as JSON strings via protojson — coerce to number.
|
||||||
|
expect(Number(mine?.score)).toBe(score);
|
||||||
|
expect(Number(mine?.rank ?? 0)).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { freshPlayer } from './_setup/harness.js';
|
||||||
|
|
||||||
|
describe('e2e-prod: player', () => {
|
||||||
|
it('device login yields a profile with a player id and wallets array', async () => {
|
||||||
|
const client = await freshPlayer();
|
||||||
|
const profile = await client.player.reload();
|
||||||
|
expect(profile.player?.id).toBeTruthy();
|
||||||
|
expect(Array.isArray(profile.wallets)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { adminApi, freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
|
||||||
|
import type { SeedArtifact } from './_setup/types.js';
|
||||||
|
import type { RudderClient } from '../../src/client/RudderClient.js';
|
||||||
|
import type { OfferHandle } from '../../src/state/shops.js';
|
||||||
|
|
||||||
|
// Exercises the (newly implemented) wallet + inventory + purchase backend end-to-end:
|
||||||
|
// admin grant -> debit on buy -> inventory credit -> idempotency -> max_purchases -> insufficient funds.
|
||||||
|
interface AdminWalletDetails {
|
||||||
|
currencyCode: string;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
interface AdminInventoryItem {
|
||||||
|
itemId: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
interface AdminPlayerDetails {
|
||||||
|
wallets?: AdminWalletDetails[];
|
||||||
|
inventory?: AdminInventoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
|
||||||
|
let artifact: SeedArtifact;
|
||||||
|
beforeAll(() => {
|
||||||
|
artifact = loadArtifact();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resolvePaidOffer(client: RudderClient): Promise<OfferHandle> {
|
||||||
|
const stores = await withReadRetry(
|
||||||
|
() => client.stores.reload(),
|
||||||
|
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
|
||||||
|
);
|
||||||
|
const store = stores.find((s) => s.name === artifact.store.name)!;
|
||||||
|
return store.offers.find((o) => o.name === artifact.store.paidOfferName)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function balance(client: RudderClient, currency: string): Promise<number> {
|
||||||
|
const profile = await client.player.reload();
|
||||||
|
// int64 fields serialize as JSON strings via protojson — coerce to number.
|
||||||
|
return Number(profile.wallets?.find((w) => w.currency === currency)?.balance ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('grants currency, debits on buy, credits inventory, is idempotent, and enforces the purchase limit', async () => {
|
||||||
|
const client = await freshPlayer(artifact);
|
||||||
|
const playerId = (await client.player.reload()).player!.id!;
|
||||||
|
const api = adminApi(artifact);
|
||||||
|
const currency = artifact.store.paidPrice.currency;
|
||||||
|
const price = artifact.store.paidPrice.amount;
|
||||||
|
const offer = await resolvePaidOffer(client);
|
||||||
|
|
||||||
|
// 1. Grant currency via the new admin endpoint.
|
||||||
|
await api.post(`/game/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, {
|
||||||
|
currencyCode: currency,
|
||||||
|
amount: 1000,
|
||||||
|
reason: 'e2e grant',
|
||||||
|
});
|
||||||
|
const granted = await withReadRetry(
|
||||||
|
() => balance(client, currency),
|
||||||
|
(b) => b === 1000,
|
||||||
|
);
|
||||||
|
expect(granted).toBe(1000);
|
||||||
|
|
||||||
|
// 2. First paid buy: success + wallet debited.
|
||||||
|
const buy1 = await offer.buy();
|
||||||
|
expect(buy1.success).toBe(true);
|
||||||
|
expect(buy1.purchaseId).toBeTruthy();
|
||||||
|
expect(await balance(client, currency)).toBe(1000 - price);
|
||||||
|
|
||||||
|
// 3. Inventory credited (verified via admin player details).
|
||||||
|
const details = await api.get<AdminPlayerDetails>(
|
||||||
|
`/game/v1/projects/${artifact.projectId}/players/${playerId}`,
|
||||||
|
);
|
||||||
|
const inv = details.inventory?.find((i) => i.itemId === artifact.item.id);
|
||||||
|
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
|
||||||
|
|
||||||
|
// 4. Idempotency: the same key twice charges once.
|
||||||
|
const key = crypto.randomUUID();
|
||||||
|
const a = await offer.buy({ idempotencyKey: key });
|
||||||
|
const b = await offer.buy({ idempotencyKey: key });
|
||||||
|
expect(a.success).toBe(true);
|
||||||
|
expect(b.success).toBe(true);
|
||||||
|
expect(await balance(client, currency)).toBe(1000 - price * 2);
|
||||||
|
|
||||||
|
// 5. max_purchases (2) reached -> a new distinct buy fails (no charge).
|
||||||
|
const overLimit = await offer.buy();
|
||||||
|
expect(overLimit.success).toBe(false);
|
||||||
|
expect(overLimit.error ?? '').toContain('limit');
|
||||||
|
expect(await balance(client, currency)).toBe(1000 - price * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a paid purchase with insufficient funds', async () => {
|
||||||
|
const poor = await freshPlayer(artifact);
|
||||||
|
const offer = await resolvePaidOffer(poor);
|
||||||
|
const res = await offer.buy();
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
expect(res.error ?? '').toContain('insufficient');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
|
||||||
|
import type { SeedArtifact } from './_setup/types.js';
|
||||||
|
|
||||||
|
interface GameRemoteConfig extends Record<string, unknown> {
|
||||||
|
max_energy: number;
|
||||||
|
spawn_rate: number;
|
||||||
|
feature_x: boolean;
|
||||||
|
welcome_text: string;
|
||||||
|
shop_layout: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('e2e-prod: remoteConfig', () => {
|
||||||
|
let artifact: SeedArtifact;
|
||||||
|
beforeAll(() => {
|
||||||
|
artifact = loadArtifact();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads typed configs matching the seeded values', async () => {
|
||||||
|
const rc = artifact.remoteConfigs;
|
||||||
|
const client = await withReadRetry(
|
||||||
|
() => freshPlayer<GameRemoteConfig>(artifact),
|
||||||
|
(candidate) => candidate.remoteConfig.get('max_energy', 0) === rc.maxEnergy,
|
||||||
|
);
|
||||||
|
expect(client.remoteConfig.status).toBe('ready');
|
||||||
|
|
||||||
|
expect(client.remoteConfig.get('max_energy', 0)).toBe(rc.maxEnergy);
|
||||||
|
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(rc.spawnRate);
|
||||||
|
expect(client.remoteConfig.get('feature_x', false)).toBe(rc.featureX);
|
||||||
|
expect(client.remoteConfig.get('welcome_text', '')).toBe(rc.welcomeText);
|
||||||
|
expect(client.remoteConfig.get('shop_layout', {} as Record<string, unknown>)).toEqual(rc.shopLayout);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||||
|
import { loadArtifact, makeProdClient } from './_setup/harness.js';
|
||||||
|
import type { SeedArtifact } from './_setup/types.js';
|
||||||
|
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
|
||||||
|
import { getScenarioRuntime } from '../../src/client/RudderClient.js';
|
||||||
|
|
||||||
|
describe('e2e-prod: scenarios', () => {
|
||||||
|
let artifact: SeedArtifact;
|
||||||
|
beforeAll(() => {
|
||||||
|
artifact = loadArtifact();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs onboarding: notification -> rc override -> store -> boundary callback -> notification', async () => {
|
||||||
|
const notifications: NotificationEffect[] = [];
|
||||||
|
let storeOffer: StoreOfferEffect | undefined;
|
||||||
|
let runCompleted = false;
|
||||||
|
|
||||||
|
const client = makeProdClient(artifact, {
|
||||||
|
planStateStore: null,
|
||||||
|
loginEvent: artifact.scenario.event,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.effects.onNotification((effect) => { notifications.push(effect); });
|
||||||
|
client.effects.onStoreOffer((effect) => {
|
||||||
|
storeOffer = effect;
|
||||||
|
});
|
||||||
|
const runtime = await getScenarioRuntime(client);
|
||||||
|
runtime.onRunCompleted = () => {
|
||||||
|
runCompleted = true;
|
||||||
|
};
|
||||||
|
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
||||||
|
|
||||||
|
// First notification dispatched.
|
||||||
|
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
|
||||||
|
await notifications[0].done();
|
||||||
|
|
||||||
|
// remote_config_override applies, then the store offer surfaces.
|
||||||
|
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
|
||||||
|
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
|
||||||
|
artifact.remoteConfigs.spawnRateOverride,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Buy crosses the server boundary (POST /sdk/v1/scenarios/callback) -> second notification.
|
||||||
|
const offer = storeOffer!.offers.find((o) => o.name === artifact.store.paidOfferName)!;
|
||||||
|
await storeOffer!.buy(offer);
|
||||||
|
await vi.waitFor(() => expect(notifications.length).toBe(2), { timeout: 15_000 });
|
||||||
|
await notifications[1].done();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(runtime.isRunning).toBe(false), { timeout: 10_000 });
|
||||||
|
expect(runCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { freshPlayer } from './_setup/harness.js';
|
||||||
|
|
||||||
|
describe('e2e-prod: storage', () => {
|
||||||
|
it('save -> get -> delete round-trip', async () => {
|
||||||
|
const client = await freshPlayer();
|
||||||
|
const payload = JSON.stringify({ level: 7 });
|
||||||
|
const item = { id: 'slot_1', type: 'savegame', data: payload };
|
||||||
|
|
||||||
|
await client.storage.save([item]);
|
||||||
|
|
||||||
|
// The server may assign its own item id, so match on type + data, not the client id.
|
||||||
|
// v0.2 fetches the whole storage; filtering by type is client-side.
|
||||||
|
const got = await client.storage.reload();
|
||||||
|
expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true);
|
||||||
|
|
||||||
|
await client.storage.delete('savegame');
|
||||||
|
|
||||||
|
const after = await client.storage.reload();
|
||||||
|
expect(after.items?.some((i) => i.data === payload)).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
|
||||||
|
import type { SeedArtifact } from './_setup/types.js';
|
||||||
|
|
||||||
|
describe('e2e-prod: stores', () => {
|
||||||
|
let artifact: SeedArtifact;
|
||||||
|
beforeAll(() => {
|
||||||
|
artifact = loadArtifact();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the seeded store with its free + paid offers', async () => {
|
||||||
|
const client = await freshPlayer(artifact);
|
||||||
|
const stores = await withReadRetry(
|
||||||
|
() => client.stores.reload(),
|
||||||
|
(r) => r.length > 0,
|
||||||
|
);
|
||||||
|
const store = stores.find((s) => s.name === artifact.store.name);
|
||||||
|
expect(store).toBeDefined();
|
||||||
|
|
||||||
|
const offerNames = store!.offers.map((o) => o.name);
|
||||||
|
expect(offerNames).toContain(artifact.store.freeOfferName);
|
||||||
|
expect(offerNames).toContain(artifact.store.paidOfferName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buys the free offer successfully', async () => {
|
||||||
|
const client = await freshPlayer(artifact);
|
||||||
|
const stores = await withReadRetry(
|
||||||
|
() => client.stores.reload(),
|
||||||
|
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
|
||||||
|
);
|
||||||
|
const store = stores.find((s) => s.name === artifact.store.name)!;
|
||||||
|
const free = store.offers.find((o) => o.name === artifact.store.freeOfferName)!;
|
||||||
|
const res = await free.buy();
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.purchaseId).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
|
||||||
|
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||||
|
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||||
|
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||||
|
import { plan } from '../helpers/plan.js';
|
||||||
|
import type { StoreOfferEffect } from '../../src/index.js';
|
||||||
|
import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js';
|
||||||
|
|
||||||
|
function makeClient(): RudderClient {
|
||||||
|
return new RudderClient({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
runtime: { planStateStore: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An offer whose `onPurchase` continuation lives server-side (boundary node):
|
||||||
|
* the client doesn't hold the post-purchase DAG — it must call back so the
|
||||||
|
* server can validate the transaction and decide what happens next.
|
||||||
|
*
|
||||||
|
* `onDecline`, by contrast, is a plain local edge handled entirely on-device.
|
||||||
|
*/
|
||||||
|
function offerWithBoundary() {
|
||||||
|
return plan('offer_flow')
|
||||||
|
.runId('offer_flow-run')
|
||||||
|
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy the starter pack!' })
|
||||||
|
.node('consolation', 'notification', { message: 'Maybe next time!' })
|
||||||
|
.edge('offer', 'onDecline', 'consolation')
|
||||||
|
.boundary('offer', 'onPurchase') // server owns what comes after a purchase
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the server returns from the callback — a fresh plan = a new run. */
|
||||||
|
function rewardContinuation() {
|
||||||
|
return plan('reward_flow')
|
||||||
|
.runId('reward_flow-run')
|
||||||
|
.node('reward', 'notification', { message: 'Reward granted: 500 gems!' })
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('E2E: boundary nodes (server-side continuation)', () => {
|
||||||
|
let gateway: FakeGateway;
|
||||||
|
let client: RudderClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubDeterministicUuid();
|
||||||
|
gateway = createFakeGateway({
|
||||||
|
stores: [{
|
||||||
|
slug: 'starter',
|
||||||
|
name: 'Starter',
|
||||||
|
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
gateway.install();
|
||||||
|
client = makeClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
|
||||||
|
gateway.onEvent('player_login', offerWithBoundary());
|
||||||
|
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
|
||||||
|
|
||||||
|
const messages: string[] = [];
|
||||||
|
const completed: PlanRun[] = [];
|
||||||
|
let offer: StoreOfferEffect | undefined;
|
||||||
|
const runtime = await getScenarioRuntime(client);
|
||||||
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||||
|
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||||
|
runtime.onRunCompleted = (r) => completed.push(r);
|
||||||
|
|
||||||
|
const token = (await client.auth.loginWithDevice()).accessToken;
|
||||||
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||||
|
|
||||||
|
await offer!.buy(offer!.offers[0]);
|
||||||
|
|
||||||
|
// The callback hit the gateway with the boundary's source node/handle + token.
|
||||||
|
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
|
||||||
|
expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' });
|
||||||
|
expect(callback?.authToken).toBe(token);
|
||||||
|
|
||||||
|
// The server-supplied continuation ran (as a new run), not the local edge.
|
||||||
|
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
||||||
|
expect(messages).not.toContain('Maybe next time!');
|
||||||
|
|
||||||
|
// The original run finished; the continuation ('reward_flow') is now active.
|
||||||
|
expect(completed.map((r) => r.scenarioId)).toContain('offer_flow');
|
||||||
|
expect(runtime.activeRuns.map((r) => r.scenarioId)).toEqual(['reward_flow']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decline stays local → no callback, local edge is followed', async () => {
|
||||||
|
gateway.onEvent('player_login', offerWithBoundary());
|
||||||
|
|
||||||
|
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!.dismiss();
|
||||||
|
|
||||||
|
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(false);
|
||||||
|
expect(messages).toEqual(['Maybe next time!']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a boundary takes precedence over a local edge on the SAME handle', async () => {
|
||||||
|
// `offer` has BOTH a boundary AND a local edge on onPurchase.
|
||||||
|
const conflicting = plan('offer_flow')
|
||||||
|
.runId('offer_flow-conflict')
|
||||||
|
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy!' })
|
||||||
|
.node('local_next', 'notification', { message: 'LOCAL branch' })
|
||||||
|
.edge('offer', 'onPurchase', 'local_next')
|
||||||
|
.boundary('offer', 'onPurchase')
|
||||||
|
.build();
|
||||||
|
gateway.onEvent('player_login', conflicting);
|
||||||
|
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
|
||||||
|
|
||||||
|
const messages: string[] = [];
|
||||||
|
let offer: StoreOfferEffect | undefined;
|
||||||
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||||
|
client.effects.onNotification((effect) => { messages.push(effect.message); });
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||||
|
await offer!.buy(offer!.offers[0]);
|
||||||
|
|
||||||
|
// Only the server continuation ran; the local edge was suppressed.
|
||||||
|
expect(messages).toEqual(['Reward granted: 500 gems!']);
|
||||||
|
expect(messages).not.toContain('LOCAL branch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failing callback does not fail or crash the run', async () => {
|
||||||
|
gateway.onEvent('player_login', offerWithBoundary());
|
||||||
|
gateway.onCallbackError('offer', 'onPurchase', 500);
|
||||||
|
|
||||||
|
const failures: ScenarioRunFailedEvent[] = [];
|
||||||
|
const completed: PlanRun[] = [];
|
||||||
|
let offer: StoreOfferEffect | undefined;
|
||||||
|
const runtime = await getScenarioRuntime(client);
|
||||||
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||||
|
runtime.onRunFailed = (e) => failures.push(e);
|
||||||
|
runtime.onRunCompleted = (r) => completed.push(r);
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||||
|
|
||||||
|
// Must not throw despite the 500 from the callback endpoint.
|
||||||
|
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
|
||||||
|
|
||||||
|
expect(failures).toHaveLength(0); // 500 is transient — no terminal failure
|
||||||
|
// The run stays pending because the boundary call failed transiently.
|
||||||
|
// On reconnection the consumer can retry the purchase completion.
|
||||||
|
expect(runtime.isRunning).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { RudderClient } from '../../src/client/RudderClient.js';
|
||||||
|
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||||
|
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||||
|
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||||
|
import { plan } from '../helpers/plan.js';
|
||||||
|
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
|
||||||
|
function makeClient(): RudderClient {
|
||||||
|
return new RudderClient({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
runtime: { planStateStore: null }, // exercised separately in the persistence test
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offer scenario both branches share:
|
||||||
|
*
|
||||||
|
* login → wait 1m → offer ──onDecline──→ wait 1m → "still available"
|
||||||
|
* └─onPurchase─→ "thanks for your purchase"
|
||||||
|
*/
|
||||||
|
function offerScenario() {
|
||||||
|
return plan('offer_flow')
|
||||||
|
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
|
||||||
|
.node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' })
|
||||||
|
.node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' })
|
||||||
|
.node('reminder', 'notification', { message: 'Your offer is still available!' })
|
||||||
|
.node('thanks', 'notification', { message: 'Thanks for your purchase!' })
|
||||||
|
.edge('wait_intro', 'onComplete', 'offer')
|
||||||
|
.edge('offer', 'onDecline', 'wait_reminder')
|
||||||
|
.edge('wait_reminder', 'onComplete', 'reminder')
|
||||||
|
.edge('offer', 'onPurchase', 'thanks')
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('E2E: player offer journey', () => {
|
||||||
|
let gateway: FakeGateway;
|
||||||
|
let client: RudderClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubDeterministicUuid();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
gateway = createFakeGateway({
|
||||||
|
stores: [{
|
||||||
|
slug: 'starter',
|
||||||
|
name: 'Starter',
|
||||||
|
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
gateway.install();
|
||||||
|
client = makeClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates the device and persists tokens for later calls', async () => {
|
||||||
|
const res = await client.auth.loginWithDevice({ region: 'eu', language: 'en' });
|
||||||
|
|
||||||
|
expect(res.accessToken).toBeTruthy();
|
||||||
|
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
|
||||||
|
|
||||||
|
// The login request carried the project key + a device id.
|
||||||
|
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device');
|
||||||
|
expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
|
||||||
|
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
|
||||||
|
|
||||||
|
// The post-login runtime trigger carries the bearer token.
|
||||||
|
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
|
||||||
|
expect(trigger?.authToken).toBe(res.accessToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
|
||||||
|
gateway.onEvent('player_login', offerScenario());
|
||||||
|
|
||||||
|
const notifications: NotificationEffect[] = [];
|
||||||
|
let offer: StoreOfferEffect | undefined;
|
||||||
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||||
|
client.effects.onNotification((effect) => { notifications.push(effect); });
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
|
||||||
|
// Immediately after login the player is just waiting — no offer yet.
|
||||||
|
expect(offer).toBeUndefined();
|
||||||
|
|
||||||
|
// The offer must NOT appear before the full minute has elapsed...
|
||||||
|
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
|
||||||
|
expect(offer).toBeUndefined();
|
||||||
|
|
||||||
|
// ...and surfaces exactly when the minute is up.
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||||
|
expect(offer!.message).toBe('Limited starter pack!');
|
||||||
|
|
||||||
|
// Player declines → the DAG moves to the reminder wait, no notification yet.
|
||||||
|
await offer!.dismiss();
|
||||||
|
expect(notifications).toHaveLength(0);
|
||||||
|
|
||||||
|
// The reminder also honours the full minute, not a moment sooner.
|
||||||
|
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
|
||||||
|
expect(notifications).toHaveLength(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(notifications).toHaveLength(1);
|
||||||
|
expect(notifications[0].message).toBe('Your offer is still available!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
|
||||||
|
gateway.onEvent('player_login', offerScenario());
|
||||||
|
|
||||||
|
let offer: StoreOfferEffect | undefined;
|
||||||
|
const notifications: NotificationEffect[] = [];
|
||||||
|
client.effects.onStoreOffer((effect) => { offer = effect; });
|
||||||
|
client.effects.onNotification((effect) => { notifications.push(effect); });
|
||||||
|
|
||||||
|
const accessToken = (await client.auth.loginWithDevice()).accessToken;
|
||||||
|
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||||
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||||
|
|
||||||
|
// The game buys a selected offer; the session advances only after success.
|
||||||
|
const selectedOffer = offer!.offers[0];
|
||||||
|
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
|
||||||
|
expect(purchase.success).toBe(true);
|
||||||
|
|
||||||
|
// The purchase hit the gateway with idempotency key + bearer token.
|
||||||
|
expect(gateway.purchases).toEqual([
|
||||||
|
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The scenario took the onPurchase branch → "thanks", and the run finished.
|
||||||
|
expect(notifications).toHaveLength(1);
|
||||||
|
expect(notifications[0].message).toBe('Thanks for your purchase!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declining does NOT take the purchase branch (handles are isolated)', async () => {
|
||||||
|
gateway.onEvent('player_login', offerScenario());
|
||||||
|
const 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.advanceTimersByTimeAsync(MINUTE);
|
||||||
|
await vi.waitFor(() => expect(offer).toBeDefined());
|
||||||
|
await offer!.dismiss();
|
||||||
|
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||||
|
|
||||||
|
expect(messages).toEqual(['Your offer is still available!']);
|
||||||
|
expect(messages).not.toContain('Thanks for your purchase!');
|
||||||
|
expect(gateway.purchases).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
|
||||||
|
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||||
|
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||||
|
import { createMemoryPlanStore, stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||||
|
import { plan } from '../helpers/plan.js';
|
||||||
|
import type { StoreSession, WaitSession } from '../../src/scenario/engine/sessions.js';
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
|
||||||
|
describe('E2E: scenario state survives a reload', () => {
|
||||||
|
let gateway: FakeGateway;
|
||||||
|
// Shared backing store mimics IndexedDB persisting across page loads.
|
||||||
|
const backing = { value: null as string | null };
|
||||||
|
|
||||||
|
function clientWithSharedStore(): RudderClient {
|
||||||
|
return new RudderClient({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
runtime: { planStateStore: createMemoryPlanStore(backing), loginEvent: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubDeterministicUuid();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
backing.value = null;
|
||||||
|
gateway = createFakeGateway();
|
||||||
|
gateway.onEvent(
|
||||||
|
'session_start',
|
||||||
|
plan('offer_flow')
|
||||||
|
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
|
||||||
|
.node('offer', 'store', { message: 'Limited starter pack!' })
|
||||||
|
.edge('wait_intro', 'onComplete', 'offer')
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
gateway.install();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a wait started before reload resumes and fires the offer after reload', async () => {
|
||||||
|
// --- Session 1: trigger the scenario, then "close the tab" mid-wait ---
|
||||||
|
const client1 = clientWithSharedStore();
|
||||||
|
await client1.auth.loginWithDevice();
|
||||||
|
const runtime1 = await getScenarioRuntime(client1);
|
||||||
|
await runtime1.send('session_start');
|
||||||
|
expect(runtime1.isRunning).toBe(true);
|
||||||
|
expect(backing.value).toBeTruthy(); // run was persisted
|
||||||
|
|
||||||
|
// --- Session 2: fresh client, same persisted state (page reload) ---
|
||||||
|
const client2 = clientWithSharedStore();
|
||||||
|
const runtime2 = await getScenarioRuntime(client2);
|
||||||
|
const resumedWaits: WaitSession[] = [];
|
||||||
|
let offer: StoreSession | undefined;
|
||||||
|
runtime2.onWait = (s) => resumedWaits.push(s);
|
||||||
|
runtime2.onStore = (s) => { offer = s; };
|
||||||
|
|
||||||
|
await client2.auth.loginWithDevice();
|
||||||
|
|
||||||
|
// The wait node was rehydrated and re-dispatched.
|
||||||
|
expect(runtime2.isRunning).toBe(true);
|
||||||
|
expect(resumedWaits).toHaveLength(1);
|
||||||
|
expect(offer).toBeUndefined();
|
||||||
|
|
||||||
|
// The remaining wait time still elapses → the offer surfaces on the new client.
|
||||||
|
await vi.advanceTimersByTimeAsync(MINUTE);
|
||||||
|
expect(offer).toBeDefined();
|
||||||
|
expect(offer!.get('message', '')).toBe('Limited starter pack!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { RudderClient } from '../../src/client/RudderClient.js';
|
||||||
|
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||||
|
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||||
|
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
|
||||||
|
import { plan } from '../helpers/plan.js';
|
||||||
|
import type { RemoteConfig } from '../../src/generated/remote-config.js';
|
||||||
|
|
||||||
|
interface GameRemoteConfig extends Record<string, unknown> {
|
||||||
|
max_energy: number;
|
||||||
|
drop_rate: number;
|
||||||
|
pvp_enabled: boolean;
|
||||||
|
welcome_text: string;
|
||||||
|
boss: { hp: number; name: string };
|
||||||
|
legacy: string;
|
||||||
|
missing_declared: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cfg(key: string, value: string, valueType: string, active = true): RemoteConfig {
|
||||||
|
return { key, value, valueType, active };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClient(): RudderClient<GameRemoteConfig> {
|
||||||
|
return new RudderClient<GameRemoteConfig>({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
runtime: { planStateStore: null, loginEvent: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('E2E: remote config', () => {
|
||||||
|
let gateway: FakeGateway;
|
||||||
|
let client: RudderClient<GameRemoteConfig>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubDeterministicUuid();
|
||||||
|
gateway = createFakeGateway({
|
||||||
|
remoteConfigs: {
|
||||||
|
max_energy: cfg('max_energy', '50', 'int'),
|
||||||
|
drop_rate: cfg('drop_rate', '0.25', 'float'),
|
||||||
|
pvp_enabled: cfg('pvp_enabled', 'true', 'bool'),
|
||||||
|
welcome_text: cfg('welcome_text', 'Hello!', 'string'),
|
||||||
|
boss: cfg('boss', '{"hp":1000,"name":"Drake"}', 'json'),
|
||||||
|
legacy: cfg('legacy', 'old', 'string', false), // inactive — must be excluded
|
||||||
|
},
|
||||||
|
});
|
||||||
|
gateway.install();
|
||||||
|
client = makeClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('loads configs and reads them back with type coercion', async () => {
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
|
||||||
|
expect(client.remoteConfig.status).toBe('ready');
|
||||||
|
expect(client.remoteConfig.get('max_energy', 0)).toBe(50);
|
||||||
|
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.25);
|
||||||
|
expect(client.remoteConfig.get('pvp_enabled', false)).toBe(true);
|
||||||
|
expect(client.remoteConfig.get('welcome_text', '')).toBe('Hello!');
|
||||||
|
expect(client.remoteConfig.get('boss', { hp: 0, name: '' })).toEqual({
|
||||||
|
hp: 1000,
|
||||||
|
name: 'Drake',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inactive configs are not exposed; missing keys fall back to default', async () => {
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
|
||||||
|
expect(client.remoteConfig.get('legacy', 'DEFAULT')).toBe('DEFAULT');
|
||||||
|
expect(client.remoteConfig.get('missing_declared', 'fallback')).toBe('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get() before login returns the default (no throw)', () => {
|
||||||
|
expect(client.remoteConfig.status).toBe('idle');
|
||||||
|
expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a remote_config_override scenario node patches the live cache', async () => {
|
||||||
|
client = new RudderClient<GameRemoteConfig>({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
runtime: { planStateStore: null },
|
||||||
|
});
|
||||||
|
gateway.onEvent(
|
||||||
|
'player_login',
|
||||||
|
plan('boost')
|
||||||
|
.node('override', 'remote_config_override', {
|
||||||
|
patches: [{ path: 'drop_rate', valueType: 'float', value: '0.9' }],
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
|
||||||
|
// The override is reflected immediately, without a reload.
|
||||||
|
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.9);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { RudderClient } from '../../src/client/RudderClient.js';
|
||||||
|
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||||
|
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||||
|
|
||||||
|
function makeClient(): RudderClient {
|
||||||
|
return new RudderClient({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
runtime: { planStateStore: null, loginEvent: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('E2E: player storage', () => {
|
||||||
|
let gateway: FakeGateway;
|
||||||
|
let client: RudderClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
gateway = createFakeGateway();
|
||||||
|
gateway.install();
|
||||||
|
client = makeClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('saves items then reads them back (round-trip through the gateway)', async () => {
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
|
||||||
|
await client.storage.save([
|
||||||
|
{ id: 'save_1', type: 'progress', data: JSON.stringify({ level: 7, gold: 1200 }) },
|
||||||
|
{ id: 'settings', type: 'prefs', data: JSON.stringify({ sound: false }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const all = await client.storage.reload();
|
||||||
|
expect(all.items).toHaveLength(2);
|
||||||
|
|
||||||
|
const progress = all.items!.find((i) => i.id === 'save_1');
|
||||||
|
expect(JSON.parse(progress!.data!)).toEqual({ level: 7, gold: 1200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by type', async () => {
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
await client.storage.save([
|
||||||
|
{ id: 'a', type: 'progress', data: '{}' },
|
||||||
|
{ id: 'b', type: 'prefs', data: '{}' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// v0.2 fetches the whole storage; filtering by type is client-side.
|
||||||
|
const all = await client.storage.reload();
|
||||||
|
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
|
||||||
|
expect(prefs).toHaveLength(1);
|
||||||
|
expect(prefs[0].id).toBe('b');
|
||||||
|
|
||||||
|
// The read went out as a storage fetch with limit as a query param, not a path segment.
|
||||||
|
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
|
||||||
|
expect(getReq?.query.get('limit')).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a type and the items are gone', async () => {
|
||||||
|
await client.auth.loginWithDevice();
|
||||||
|
await client.storage.save([
|
||||||
|
{ id: 'a', type: 'progress', data: '{}' },
|
||||||
|
{ id: 'b', type: 'prefs', data: '{}' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await client.storage.delete('progress');
|
||||||
|
|
||||||
|
const remaining = await client.storage.reload();
|
||||||
|
expect(remaining.items?.map((i) => i.id)).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('storage writes carry the auth token', async () => {
|
||||||
|
const token = (await client.auth.loginWithDevice()).accessToken;
|
||||||
|
await client.storage.save([{ id: 'a', type: 't', data: '{}' }]);
|
||||||
|
|
||||||
|
const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage');
|
||||||
|
expect(put?.authToken).toBe(token);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { TokenStore } from '../../src/token/TokenStore.js';
|
||||||
|
|
||||||
|
/** In-memory token store for testing. */
|
||||||
|
export function createFakeTokenStore(): TokenStore {
|
||||||
|
let access: string | null = null;
|
||||||
|
let refresh: string | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAccessToken: () => access,
|
||||||
|
getRefreshToken: () => refresh,
|
||||||
|
saveTokens: (a, r) => {
|
||||||
|
access = a;
|
||||||
|
refresh = r;
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
access = null;
|
||||||
|
refresh = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { RudderClient } from '../../src/client/RudderClient.js';
|
||||||
|
import type { RudderClientOptions } from '../../src/client/RudderClientOptions.js';
|
||||||
|
import { createFakeTokenStore } from './FakeTokenStore.js';
|
||||||
|
|
||||||
|
/** Creates a RudderClient with test-friendly defaults and a fake token store. */
|
||||||
|
export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient {
|
||||||
|
return new RudderClient({
|
||||||
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
|
projectKey: 'test-project-key',
|
||||||
|
tokenStore: createFakeTokenStore(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { ExecutionPlan } from '../../src/generated/common.js';
|
||||||
|
import type { RemoteConfig } from '../../src/generated/remote-config.js';
|
||||||
|
import type { Store } from '../../src/generated/stores.js';
|
||||||
|
import type { StorageItem } from '../../src/generated/storage.js';
|
||||||
|
import type {
|
||||||
|
ClaimQuestResponse,
|
||||||
|
ListQuestsResponse,
|
||||||
|
} from '../../src/generated/quests.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process fake of the LiveOps API gateway.
|
||||||
|
*
|
||||||
|
* Stubs the global `fetch` and routes requests the same way the real gateway
|
||||||
|
* does — so tests exercise the *real* SDK transport, auth-header injection,
|
||||||
|
* scenario engine and DAG traversal, just against deterministic in-memory state
|
||||||
|
* instead of a live backend.
|
||||||
|
*
|
||||||
|
* Seed state up front (scenarios per event, remote configs, stores), then drive
|
||||||
|
* the SDK through a journey and assert on `recorded` requests / server state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RecordedRequest {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
query: URLSearchParams;
|
||||||
|
body: unknown;
|
||||||
|
authToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FakeGatewayState {
|
||||||
|
/** Plans returned by POST /scenarios/trigger, keyed by event name. */
|
||||||
|
scenarios: Map<string, ExecutionPlan[]>;
|
||||||
|
/** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */
|
||||||
|
callbacks: Map<string, ExecutionPlan>;
|
||||||
|
/** HTTP status to fail a callback with, keyed by `nodeId:handle`. */
|
||||||
|
callbackErrors: Map<string, number>;
|
||||||
|
/** Optional response for POST /scenarios/counter. */
|
||||||
|
counterPlan?: ExecutionPlan;
|
||||||
|
remoteConfigs: Record<string, RemoteConfig>;
|
||||||
|
stores: Store[];
|
||||||
|
quests: ListQuestsResponse;
|
||||||
|
questClaims: Map<string, ClaimQuestResponse>;
|
||||||
|
/** Player KV storage, keyed by item id. */
|
||||||
|
storage: Map<string, StorageItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FakeGateway {
|
||||||
|
state: FakeGatewayState;
|
||||||
|
recorded: RecordedRequest[];
|
||||||
|
/** Purchases received, in order. */
|
||||||
|
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>;
|
||||||
|
/** Quest claim requests received, in order. */
|
||||||
|
questClaims: Array<{ questId: string; authToken: string | null }>;
|
||||||
|
install(): void;
|
||||||
|
/** Convenience: register the plans returned for a trigger event. */
|
||||||
|
onEvent(event: string, ...plans: ExecutionPlan[]): void;
|
||||||
|
/** Convenience: register the plan returned when a boundary handle calls back. */
|
||||||
|
onCallback(nodeId: string, handle: string, plan: ExecutionPlan): void;
|
||||||
|
/** Convenience: make a boundary callback fail with an HTTP status (default 500). */
|
||||||
|
onCallbackError(nodeId: string, handle: string, status?: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const noContent = (): Response => new Response(null, { status: 204 });
|
||||||
|
|
||||||
|
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
|
||||||
|
const state: FakeGatewayState = {
|
||||||
|
scenarios: seed?.scenarios ?? new Map(),
|
||||||
|
callbacks: seed?.callbacks ?? new Map(),
|
||||||
|
callbackErrors: seed?.callbackErrors ?? new Map(),
|
||||||
|
counterPlan: seed?.counterPlan,
|
||||||
|
remoteConfigs: seed?.remoteConfigs ?? {},
|
||||||
|
stores: seed?.stores ?? [],
|
||||||
|
quests: seed?.quests ?? { quests: [] },
|
||||||
|
questClaims: seed?.questClaims ?? new Map(),
|
||||||
|
storage: seed?.storage ?? new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const recorded: RecordedRequest[] = [];
|
||||||
|
const purchases: FakeGateway['purchases'] = [];
|
||||||
|
const questClaims: FakeGateway['questClaims'] = [];
|
||||||
|
|
||||||
|
async function handle(req: RecordedRequest): Promise<Response> {
|
||||||
|
const { method, path, query, body } = req;
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
if (method === 'POST' && path === '/sdk/v1/authorization/device') {
|
||||||
|
const b = body as { deviceId?: string };
|
||||||
|
return json({
|
||||||
|
accessToken: `access-${b.deviceId ?? 'dev'}`,
|
||||||
|
refreshToken: `refresh-${b.deviceId ?? 'dev'}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Player ---
|
||||||
|
if (method === 'GET' && path === '/sdk/v1/player/information') {
|
||||||
|
return json({ player: null, wallets: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sync (baseline revision poll after login) ---
|
||||||
|
if (method === 'GET' && path === '/sdk/v1/sync') {
|
||||||
|
return json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Catalog ---
|
||||||
|
if (method === 'GET' && path === '/sdk/v1/catalog') {
|
||||||
|
return json({ items: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scenarios ---
|
||||||
|
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
||||||
|
const event = (body as { event?: string }).event ?? '';
|
||||||
|
return json({ plans: state.scenarios.get(event) ?? [] });
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
|
||||||
|
const b = body as { nodeId?: string; handle?: string };
|
||||||
|
const key = `${b.nodeId}:${b.handle}`;
|
||||||
|
const errStatus = state.callbackErrors.get(key);
|
||||||
|
if (errStatus) return json({ error: 'callback failed' }, errStatus);
|
||||||
|
const plan = state.callbacks.get(key);
|
||||||
|
return json(plan ? { plan } : {});
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
|
||||||
|
return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Remote config ---
|
||||||
|
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
|
||||||
|
return json({ configs: state.remoteConfigs });
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.startsWith('/sdk/v1/remote-configs/')) {
|
||||||
|
const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length));
|
||||||
|
const cfg = state.remoteConfigs[key];
|
||||||
|
return cfg ? json(cfg) : json({ error: 'not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stores ---
|
||||||
|
if (method === 'GET' && path === '/sdk/v1/stores') {
|
||||||
|
return json({ stores: state.stores, total: state.stores.length });
|
||||||
|
}
|
||||||
|
const purchaseMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)\/offers\/([^/]+)\/purchase$/);
|
||||||
|
if (method === 'POST' && purchaseMatch) {
|
||||||
|
const b = body as { idempotencyKey?: string };
|
||||||
|
purchases.push({
|
||||||
|
storeSlug: decodeURIComponent(purchaseMatch[1]),
|
||||||
|
offerId: decodeURIComponent(purchaseMatch[2]),
|
||||||
|
idempotencyKey: b.idempotencyKey ?? '',
|
||||||
|
authToken: req.authToken,
|
||||||
|
});
|
||||||
|
return json({ success: true, purchaseId: `purchase-${purchases.length}` });
|
||||||
|
}
|
||||||
|
const storeMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)$/);
|
||||||
|
if (method === 'GET' && storeMatch) {
|
||||||
|
const slug = decodeURIComponent(storeMatch[1]);
|
||||||
|
const store = state.stores.find((s) => s.slug === slug);
|
||||||
|
return store ? json(store) : json({ error: 'not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Quests ---
|
||||||
|
if (method === 'POST' && path === '/sdk/v1/quests/list') {
|
||||||
|
return json(state.quests);
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
|
||||||
|
const b = body as { questId?: string };
|
||||||
|
const questId = b.questId ?? '';
|
||||||
|
questClaims.push({ questId, authToken: req.authToken });
|
||||||
|
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Storage ---
|
||||||
|
if (path === '/sdk/v1/storage') {
|
||||||
|
if (method === 'GET') {
|
||||||
|
const typeFilter = query.get('types');
|
||||||
|
const items = [...state.storage.values()].filter(
|
||||||
|
(it) => !typeFilter || it.type === typeFilter,
|
||||||
|
);
|
||||||
|
return json({ items, nextCursor: '' });
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const items = (body as { items?: StorageItem[] }).items ?? [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id) state.storage.set(item.id, item);
|
||||||
|
}
|
||||||
|
return noContent();
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
const type = query.get('type');
|
||||||
|
for (const [id, item] of state.storage) {
|
||||||
|
if (!type || item.type === type) state.storage.delete(id);
|
||||||
|
}
|
||||||
|
return noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: `unhandled route: ${method} ${path}` }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function install(): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (input: string | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = new URL(typeof input === 'string' ? input : input.toString());
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
const auth = headers.get('Authorization');
|
||||||
|
const req: RecordedRequest = {
|
||||||
|
method: (init?.method ?? 'GET').toUpperCase(),
|
||||||
|
path: url.pathname,
|
||||||
|
query: url.searchParams,
|
||||||
|
body: init?.body ? JSON.parse(init.body as string) : undefined,
|
||||||
|
authToken: auth ? auth.replace(/^Bearer\s+/, '') : null,
|
||||||
|
};
|
||||||
|
recorded.push(req);
|
||||||
|
return handle(req);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
recorded,
|
||||||
|
purchases,
|
||||||
|
questClaims,
|
||||||
|
install,
|
||||||
|
onEvent(event, ...plans) {
|
||||||
|
state.scenarios.set(event, plans);
|
||||||
|
},
|
||||||
|
onCallback(nodeId, handle, plan) {
|
||||||
|
state.callbacks.set(`${nodeId}:${handle}`, plan);
|
||||||
|
},
|
||||||
|
onCallbackError(nodeId, handle, status = 500) {
|
||||||
|
state.callbackErrors.set(`${nodeId}:${handle}`, status);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { PlanStateStore } from '../../src/scenario/engine/IndexedDbPlanStore.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory PlanStateStore that survives across RudderClient instances — lets a
|
||||||
|
* test simulate a page reload: drive scenario A on one client, construct a fresh
|
||||||
|
* client sharing the same `backing`, call `scenarios.restore()`, and assert the
|
||||||
|
* run resumed mid-DAG.
|
||||||
|
*/
|
||||||
|
export function createMemoryPlanStore(backing: { value: string | null } = { value: null }): PlanStateStore {
|
||||||
|
return {
|
||||||
|
get state() {
|
||||||
|
return backing.value;
|
||||||
|
},
|
||||||
|
set state(v: string | null) {
|
||||||
|
backing.value = v;
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
/* state already in `backing` */
|
||||||
|
},
|
||||||
|
async save(s: string | null) {
|
||||||
|
backing.value = s;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic, collision-free crypto.randomUUID() stub for scenario run IDs. */
|
||||||
|
export function stubDeterministicUuid(): void {
|
||||||
|
let n = 0;
|
||||||
|
const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto);
|
||||||
|
Object.defineProperty(globalThis, 'crypto', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
...existing,
|
||||||
|
randomUUID: () => {
|
||||||
|
n += 1;
|
||||||
|
const hex = n.toString(16).padStart(12, '0');
|
||||||
|
return `00000000-0000-4000-a000-${hex}` as `${string}-${string}-${string}-${string}-${string}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small fluent builder for ExecutionPlans, so scenario journeys read like the
|
||||||
|
* DAGs they model rather than walls of object literals.
|
||||||
|
*
|
||||||
|
* plan('offer_flow')
|
||||||
|
* .node('wait1', 'wait', { duration: 1, unit: 'minutes' })
|
||||||
|
* .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1' })
|
||||||
|
* .edge('wait1', 'onComplete', 'offer')
|
||||||
|
* .build();
|
||||||
|
*
|
||||||
|
* The first node added becomes the start node unless `.start(id)` is called.
|
||||||
|
*/
|
||||||
|
export class PlanBuilder {
|
||||||
|
private readonly nodes: ExecutionPlanNode[] = [];
|
||||||
|
private readonly edges: PlanEdge[] = [];
|
||||||
|
private readonly boundaryNodes: BoundaryNode[] = [];
|
||||||
|
private startNodeId?: string;
|
||||||
|
private runIdValue?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly scenarioId: string,
|
||||||
|
private readonly opts: { planId?: string; userId?: string } = {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Sets a specific run ID (default: auto-derived from scenarioId). */
|
||||||
|
runId(id: string): this {
|
||||||
|
this.runIdValue = id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
node(id: string, type: string, data?: Record<string, unknown>): this {
|
||||||
|
this.nodes.push({ id, type, data: data as ExecutionPlanNode['data'] });
|
||||||
|
if (!this.startNodeId) this.startNodeId = id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
edge(source: string, sourceHandle: string, target: string): this {
|
||||||
|
this.edges.push({ id: `e-${source}-${sourceHandle}-${target}`, source, sourceHandle, target });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers a server-side boundary node (handle that calls back to the gateway). */
|
||||||
|
boundary(sourceNodeId: string, sourceHandle: string, nodeId = `b-${sourceNodeId}-${sourceHandle}`): this {
|
||||||
|
this.boundaryNodes.push({ sourceNodeId, sourceHandle, nodeId });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(id: string): this {
|
||||||
|
this.startNodeId = id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build(): ExecutionPlan {
|
||||||
|
return {
|
||||||
|
planId: this.opts.planId ?? `${this.scenarioId}-plan`,
|
||||||
|
scenarioId: this.scenarioId,
|
||||||
|
userId: this.opts.userId ?? 'player-1',
|
||||||
|
startNodeId: this.startNodeId,
|
||||||
|
runId: this.runIdValue ?? `${this.scenarioId}-run`,
|
||||||
|
nodes: this.nodes,
|
||||||
|
edges: this.edges,
|
||||||
|
boundaryNodes: this.boundaryNodes,
|
||||||
|
context: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder {
|
||||||
|
return new PlanBuilder(scenarioId, opts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./dist",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"types": ["node"],
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm', 'cjs'],
|
||||||
|
target: 'es2022',
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
// Code-split the lazily-imported scenario engine out of the initial chunk.
|
||||||
|
splitting: true,
|
||||||
|
treeshake: true,
|
||||||
|
platform: 'browser',
|
||||||
|
outDir: 'dist',
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
// The prod e2e suite has its own config (vitest.e2e-prod.config.ts) and hits a real API.
|
||||||
|
exclude: ['**/node_modules/**', '**/dist/**', 'test/e2e-prod/**'],
|
||||||
|
setupFiles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
// Production e2e: drives the real SDK against a seeded project on api.rudder.build.
|
||||||
|
// Runs in Node (real fetch, no jsdom). globalSetup seeds + tears down the project.
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: true,
|
||||||
|
include: ['test/e2e-prod/**/*.e2e.test.ts'],
|
||||||
|
globalSetup: ['test/e2e-prod/_setup/globalSetup.ts'],
|
||||||
|
testTimeout: 60_000,
|
||||||
|
hookTimeout: 120_000,
|
||||||
|
retry: 1,
|
||||||
|
fileParallelism: false,
|
||||||
|
pool: 'forks',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user