feat: modules and resources domains, drop removed domains, regenerated models

This commit is contained in:
edmand46
2026-09-17 12:08:03 +03:00
parent f45261439f
commit 6f37b00fb4
71 changed files with 516 additions and 4184 deletions
+36 -28
View File
@@ -1,38 +1,46 @@
# Changelog # Changelog
## Unreleased
- Login and token refresh send `platform: 'web'` and, when the new optional
`appVersion` client option is set, `appVersion`. The backend stores them on
the player for segment rules (`player.platform`, `player.appVersion`).
## 2.0.0 ## 2.0.0
Breaking change — major bump, required by the backend environments release. Breaking change — kernel surface. Module-specific client methods are removed.
Every project now has exactly two environments, `staging` and `prod`, and the The SDK now exposes the kernel only: auth, player (including wallets), catalog
SDK key passed as `projectKey` decides which one the player belongs to. The currencies, storage, project storage, revision sync,
environment never appears in the client API: it is resolved at login and `client.modules.call(module, fn, args)`, and `client.resources(slug)`.
carried inside the access and refresh tokens. Tokens issued before this
release have no environment claim and are rejected with 401, so the first call ### Removed
after the backend upgrade refreshes, fails, clears the token store and emits
- `client.battlePass` / `BattlePassService`
- `client.quests` / `QuestsService` / `QuestMetrics`
- `client.leaderboards` / `LeaderboardsService` / `LeaderboardHandle`
- `client.stores` / `StoresDomain` / `ShopHandle` / `OfferHandle`
- `client.inventory` / `InventoryDomain`
- `client.remoteConfig` / `ConfigDomain` / `RemoteConfigState`
- Scenario runtime (`ScenarioService`, login-event trigger, pending poll)
- Module-specific effects: `onStoreOffer`, `onLeaderboard`, `onQuest`,
`onBattlePass`, `onBattlePassLevel`, `onConfigChanged`
- Generated wire types for battle pass, quests, stores, inventory,
leaderboards, remote config, and scenarios
Call module functions with `client.modules.call(module, fn, args)` and read or
write resource rows with `client.resources(slug)` (`list` / `get` / `create` /
`update` / `delete`).
Also required by the backend environments release: every project now has
exactly two environments, `staging` and `prod`, and the SDK key passed as
`projectKey` decides which one the player belongs to. The environment never
appears in the client API: it is resolved at login and carried inside the
access and refresh tokens. Tokens issued before this release have no
environment claim and are rejected with 401, so the first call after the
backend upgrade refreshes, fails, clears the token store and emits
`'signed-out'` to every `onAuthStateChange` listener. Log the player in again `'signed-out'` to every `onAuthStateChange` listener. Log the player in again
with `client.auth.loginWithDevice()` / `loginWithCustom()`. with `client.auth.loginWithDevice()` / `loginWithCustom()`.
Quests, scenarios and offers are addressed by their slug instead of their id,
because ids differ between staging and prod while slugs are stable. Renamed
accordingly: `Quest.id` is now `Quest.slug`, `client.quests.claim()` takes a
quest slug, `reportProgress()` resolves to the slugs of the completed quests
(`ReportQuestProgressResponse.completedQuestSlugs`),
`client.stores.purchase(storeSlug, offerSlug, options?)` takes an offer slug
and `OfferHandle` carries a `slug` alongside its `id`,
`QuestMetrics.purchaseOffer(offerSlug)` builds its metric from the offer slug,
and every scenario-scoped type exposes `scenarioSlug` instead of `scenarioId` —
`PendingEffect`, `ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle
pass requests and `client.battlePass.getProgress(scenarioSlug, nodeId)`.
`offer.buy()` is unchanged; it now binds the slug for you. Leaderboards, items
and stores already used slugs and are untouched.
Also shipped here, previously committed but never published: the effects client
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run
that disappears server-side (finished elsewhere, expired after a promote) now
emits `onScenarioCompleted` instead of lingering; and `run_not_active` joins
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
emits `onScenarioFailed`.
## 1.0.0 ## 1.0.0
- Scenario execution moved server-side. The SDK no longer walks a local DAG - Scenario execution moved server-side. The SDK no longer walks a local DAG
+20 -93
View File
@@ -1,8 +1,7 @@
# @rudder/js-sdk # @rudder/js-sdk
LiveOps Web SDK for browser games — device auth, observable player state, LiveOps Web SDK for browser games — device auth, observable player state,
stores, remote config, leaderboards, quests, battle pass, and a scenario runtime catalog currencies, storage, generic resources, and module function calls.
surfaced as typed effects.
## Install ## Install
@@ -29,22 +28,18 @@ const client = new RudderClient({
projectKey: 'your-project-key', projectKey: 'your-project-key',
// tokenStore is optional: defaults to localStorage with an in-memory // tokenStore is optional: defaults to localStorage with an in-memory
// fallback where localStorage is unavailable (SSR, private mode). // fallback where localStorage is unavailable (SSR, private mode).
appVersion: '1.4.2', // optional; sent with platform 'web' on login and refresh
}); });
await client.auth.loginWithDevice(); // region 'global', language 'en' await client.auth.loginWithDevice(); // region 'global', language 'en'
// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' }); // 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 profile = client.player.data;
const speed = client.remoteConfig.get('player_speed', 200); const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance;
client.stores.onChange(({ data: stores }) => renderStores(stores ?? [])); const items = await client.resources('items').list();
const result = await client.modules.call('quests', 'list');
// 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(); client.dispose();
``` ```
@@ -54,75 +49,15 @@ client.dispose();
|---|---| |---|---|
| Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` | | Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
| Player / wallets | `client.player` (observable) | | Player / wallets | `client.player` (observable) |
| Inventory | `client.inventory` (observable, catalog-merged) | | Catalog currencies | `client.catalog` (observable) |
| 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)` | | Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
| Leaderboards | `client.leaderboards.findBySlug(slug)` → `handle.submit(score)` / `handle.list(limit?)` | | Project storage | `client.projectStorage` (observable) + `.save(items)` |
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) | | Modules | `client.modules.call(module, fn, args?)` |
| Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` | | Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` |
| Scenario effects | `client.effects.on*` |
Type the remote config for `client.remoteConfig`: Game-design features (quests, stores, inventory, leaderboards, battle pass,
remote config) live in installed modules. Call them through
```ts `client.modules.call` and read their rows through `client.resources(slug)`.
interface GameConfig extends Record<string, unknown> {
player_speed: number;
feature_x: boolean;
}
const client = new RudderClient<GameConfig>({ /* … */ });
client.remoteConfig.get('player_speed', 200); // number
```
## Quests
`client.quests` covers the player's global quests — list with per-objective
progress, claim, and metric reports. These are distinct from scenario quest
nodes, which advance through the `onQuest` effect's `QuestSession`. Global
quests have no live sync: re-list after a claim or report.
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.id) {
await client.quests.claim(quest.id);
}
}
// Custom metrics advance matching objectives server-side; the call returns
// the ids of quests completed by this report.
const completedIds = await client.quests.reportProgress('kills', 1);
```
Purchase metrics are reported automatically when a purchase goes through
`client.stores`; the `QuestMetrics` helpers name the format so quest configs
and client code agree on it:
```ts
import { QuestMetrics } from '@rudder/js-sdk';
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
## Scenario effects
The scenario runtime is not exposed directly; scenario nodes surface through
`client.effects`:
- `onNotification`, `onStoreOffer`, `onLeaderboard`, `onConfigChanged`
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
- `onScenarioCompleted`, `onScenarioFailed`
The server executes the scenario graph. The SDK is a thin effects client
(trigger, pending poll, callbacks) loaded lazily on first login — a client
that only reads domains never pulls it into the page. `onConfigChanged`
stays on the surface, but scenario `remote_config_override` nodes are
applied server-side and no longer emit that effect.
Errors thrown inside effect handlers are reported via the `onEffectError`
client option (default: `console.error`).
## Error handling ## Error handling
@@ -135,7 +70,6 @@ import {
RudderNetworkError, RudderNetworkError,
RudderHttpError, RudderHttpError,
RudderAuthError, RudderAuthError,
RudderErrorCodes,
} from '@rudder/js-sdk'; } from '@rudder/js-sdk';
try { try {
@@ -146,9 +80,6 @@ try {
// cleared tokens and emitted 'signed-out' via onAuthStateChange. // cleared tokens and emitted 'signed-out' via onAuthStateChange.
showLogin(); showLogin();
} else if (error instanceof RudderHttpError) { } else if (error instanceof RudderHttpError) {
if (error.code === RudderErrorCodes.runExpired) {
// typed server error code
}
console.error(error.status, error.code, error.body); console.error(error.status, error.code, error.body);
} else if (error instanceof RudderNetworkError) { } else if (error instanceof RudderNetworkError) {
// fetch failed / timeout — the underlying error is in error.cause // fetch failed / timeout — the underlying error is in error.cause
@@ -171,15 +102,14 @@ client.auth.onAuthStateChange((state) => {
## Data liveness model ## Data liveness model
Observable domains (`player`, `inventory`, `catalog`, `stores`, `remoteConfig`, Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are
`storage`) are warmed once at login and then kept fresh by a revision poll: warmed at login (`player` and `catalog`) or on first use, then kept fresh by
the client asks the gateway for entity revisions every **30 s (±20% jitter)** a revision poll: the client asks the gateway for entity revisions every
and reloads only the entities whose revision grew. Polling **pauses while the **30 s (±20% jitter)** and reloads only the entities whose revision grew.
tab is hidden** and resumes on `visibilitychange`. Mutations (purchases, Polling **pauses while the tab is hidden** and resumes on `visibilitychange`.
storage writes, scenario callbacks) invalidate the affected domains Storage writes invalidate the affected domain immediately. Expect data to be
immediately, so subscribers see fresh data without waiting for the next poll. eventually consistent within one poll interval; call `reload()` when you need
Expect data to be eventually consistent within one poll interval; call a guarantee.
`reload()` when you need a guarantee.
## React recipe ## React recipe
@@ -202,9 +132,6 @@ function Wallet() {
} }
``` ```
The same pattern works for `client.remoteConfig`, `client.stores`, and
`client.auth.onAuthStateChange`.
## Development ## Development
```bash ```bash
+1 -1
View File
@@ -4,7 +4,7 @@
"publishConfig": { "publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/" "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", "description": "LiveOps Web SDK for browser games — player authentication, catalog currencies, storage, generic resources, and module function calls",
"type": "module", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
+30 -45
View File
@@ -1,6 +1,6 @@
--- ---
name: rudder-web-sdk name: rudder-web-sdk
description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/google/apple/custom auth, identity linking, observable domains (player, inventory, catalog, stores, remote config, storage), leaderboards, quests, battle pass, scenario effects, and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears. description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/google/apple/custom auth, identity linking, observable kernel domains (player, catalog, storage), modules.call, resources(slug), and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears.
--- ---
# Rudder Web SDK (@rudder/js-sdk) # Rudder Web SDK (@rudder/js-sdk)
@@ -9,6 +9,11 @@ Browser-facing player SDK for the Rudder LiveOps platform. Source of truth:
`liveops-web-sdk/src` (generated wire types in `src/generated` come from the `liveops-web-sdk/src` (generated wire types in `src/generated` come from the
gateway's apigen — never edit them by hand). gateway's apigen — never edit them by hand).
2.0 kernel surface: auth, player (wallets), catalog currencies, storage,
project storage, revision sync, `client.modules.call(module, fn, args)`,
and `client.resources(slug)`. Module-specific methods (battle pass, quests,
leaderboards, stores, inventory, remote config, scenarios) are gone.
## Install ## Install
Published to a private registry, not npmjs. Point the `@rudder` scope at it in Published to a private registry, not npmjs. Point the `@rudder` scope at it in
@@ -36,16 +41,14 @@ const client = new RudderClient({
// requestTimeoutMs?: number — default 10000 // requestTimeoutMs?: number — default 10000
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter) // syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
// onEffectError?: (error) => void — default console.error // onEffectError?: (error) => void — default console.error
// runtime?: { loginEvent? } — advanced, see reference/scenarios.md
}); });
``` ```
`client` is generic: `RudderClient<TConfig extends Record<string, unknown>>` Missing `baseUrl`/`projectKey` throws `RudderError` with
types `client.remoteConfig.get()`. Missing `baseUrl`/`projectKey` throws `code: 'sdk/invalid-options'` from the constructor.
`RudderError` with `code: 'sdk/invalid-options'` from the constructor.
Call `client.dispose()` on unmount/HMR to stop the sync poll, wait timers, and Call `client.dispose()` on unmount/HMR to stop the sync poll and drop cached
drop cached state. state.
## Auth essentials ## Auth essentials
@@ -54,14 +57,13 @@ await client.auth.loginWithDevice({ region, language, nickname }); // device id
await client.auth.loginWithGoogle({ idToken, region, language, nickname }); await client.auth.loginWithGoogle({ idToken, region, language, nickname });
await client.auth.loginWithApple({ idToken, region, language, nickname }); await client.auth.loginWithApple({ idToken, region, language, nickname });
await client.auth.loginWithCustom({ customData, provider?, region, language, nickname }); await client.auth.loginWithCustom({ customData, provider?, region, language, nickname });
// identity linking (requires an active session):
await client.auth.linkWithGoogle(idToken); await client.auth.linkWithGoogle(idToken);
await client.auth.linkWithApple(idToken); await client.auth.linkWithApple(idToken);
await client.auth.linkWithCustom({ customData, provider? }); await client.auth.linkWithCustom({ customData, provider? });
await client.auth.unlinkIdentity(provider); // 'device' | 'google' | 'apple' | 'custom' | 'custom:<name>' await client.auth.unlinkIdentity(provider); // 'device' | 'google' | 'apple' | 'custom' | 'custom:<name>'
client.auth.logout(); client.auth.logout();
client.auth.isAuthenticated; // boolean client.auth.isAuthenticated;
client.auth.onAuthStateChange(cb); // fires immediately, returns unsubscribe client.auth.onAuthStateChange(cb);
``` ```
Linked identities are listed on the profile: `client.player.data.identities` Linked identities are listed on the profile: `client.player.data.identities`
@@ -72,31 +74,28 @@ Tokens are saved to the `TokenStore` automatically. The transport injects
401; a failed refresh clears tokens and emits `signed-out`. Details: 401; a failed refresh clears tokens and emits `signed-out`. Details:
[reference/auth.md](reference/auth.md). [reference/auth.md](reference/auth.md).
## Client surface (all domains that exist) ## Client surface
Every member below is a property on `RudderClient`. There is no UGC, wallet
service, or standalone economy service in this SDK — wallets live on
`client.player.data.wallets`, and purchases go through `client.stores`.
| Area | Access | Kind | | Area | Access | Kind |
|---|---|---| |---|---|---|
| Auth | `client.auth` | service | | Auth | `client.auth` | service |
| Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` | | Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` | | Catalog currencies | `client.catalog` | observable `SyncedState<Map<string, CatalogCurrency>>` |
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` |
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerSlug, opts?)` | observable `SyncedState<ShopHandle[]>` |
| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations | | Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation | | Project storage | `client.projectStorage` + `.save(items)` | observable + mutation |
| Leaderboards | `client.leaderboards.findBySlug(slug)` | service → cached handle | | Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionResponse` |
| Battle pass | `client.battlePass` | plain service (no live sync) | | Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` |
| Quests | `client.quests` | plain service (no live sync) |
| Scenario effects | `client.effects.on*` | event subscriptions | There is no UGC, wallet service, or standalone economy service — wallets live
on `client.player.data.wallets`. Quests, stores, inventory, leaderboards,
battle pass, and remote config are installed modules: call
`client.modules.call('quests', 'list')` (etc.) and read rows with
`client.resources('items')`.
## Observable domains — the shared pattern ## Observable domains — the shared pattern
`player`, `catalog`, `inventory`, `remoteConfig`, `storage`, `player`, `catalog`, `storage`, and `projectStorage` all extend
`projectStorage`, and `stores` all extend `SyncedState<T>`: `SyncedState<T>`:
- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`, - `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`,
`.error?: Error` `.error?: Error`
@@ -105,16 +104,12 @@ service, or standalone economy service in this SDK — wallets live on
- `load(): Promise<T>` — deduplicated, no-op when ready - `load(): Promise<T>` — deduplicated, no-op when ready
- `reload(): Promise<T>` — forced refetch - `reload(): Promise<T>` — forced refetch
`remoteConfig`, `player`, `stores`, and `catalog` are warmed once at login; `player` and `catalog` are warmed once at login; `storage` and
`inventory`, `storage`, and `projectStorage` load on first use. All observable `projectStorage` load on first use. All observable domains are then kept fresh
domains are then kept fresh by a revision poll every 30 s (±20% jitter); by a revision poll every 30 s (±20% jitter); polling pauses while the tab is
polling pauses while the tab is hidden. Mutations (purchases, storage writes, hidden. Storage mutations invalidate their domain immediately. Call `reload()`
scenario callbacks) invalidate affected domains immediately. Call `reload()`
when you need a freshness guarantee. when you need a freshness guarantee.
`leaderboards`, `battlePass`, and `quests` are NOT observable — refetch
explicitly after mutations.
## Errors ## Errors
All SDK errors extend `RudderError` (optional machine-readable `code`): All SDK errors extend `RudderError` (optional machine-readable `code`):
@@ -126,21 +121,11 @@ All SDK errors extend `RudderError` (optional machine-readable `code`):
- `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`) - `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`)
— constructor validation — constructor validation
Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`): Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`) are
`early_completion`, `forbidden`, `level_not_reached`, `node_not_active`, compared with `error.code === RudderErrorCodes.forbidden`.
`objectives_incomplete`, `run_expired`, `run_not_active`,
`scenario_not_active`, `unknown_run`. Compare with
`error.code === RudderErrorCodes.runExpired`.
## Reference files ## Reference files
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle - [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`) - [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
- [reference/inventory.md](reference/inventory.md) — inventory domain, merged `InventoryItem`, catalog
- [reference/stores.md](reference/stores.md) — `ShopHandle`/`OfferHandle`, purchases, idempotency
- [reference/remote-config.md](reference/remote-config.md) — typed `get()`, value types, overrides
- [reference/storage.md](reference/storage.md) — player + project storage, limits - [reference/storage.md](reference/storage.md) — player + project storage, limits
- [reference/leaderboards.md](reference/leaderboards.md) — handles, submit, list
- [reference/quests.md](reference/quests.md) — list/claim/reportProgress, `QuestMetrics`
- [reference/battlepass.md](reference/battlepass.md) — progress, XP, claims, premium
- [reference/scenarios.md](reference/scenarios.md) — effects, scenario runtime, wait/quest/battlepass nodes
+5 -5
View File
@@ -70,9 +70,9 @@ interface LoginViaAppleResponse { accessToken?: string; refreshToken?: string }
interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string } interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string }
``` ```
On success both tokens are saved to the client's `TokenStore`, the runtime On success both tokens are saved to the client's `TokenStore`, player and
starts (domains warmed, scenario engine restored, `player_login` event fired), catalog are warmed, the revision poll starts, and the state flips to
and the state flips to `'signed-in'`. `'signed-in'`.
## Identity linking ## Identity linking
@@ -127,8 +127,8 @@ type AuthStateListener = (state: AuthState) => void;
- `isAuthenticated` is `true` while an access token is present in the token store. - `isAuthenticated` is `true` while an access token is present in the token store.
- `onAuthStateChange` fires the listener **immediately** with the current state - `onAuthStateChange` fires the listener **immediately** with the current state
and returns an unsubscribe function. and returns an unsubscribe function.
- `logout()` clears tokens, stops the runtime (sync poll, scenario runs, - `logout()` clears tokens, stops the revision poll, drops cached domain
cached domain data), and emits `'signed-out'`. data, and emits `'signed-out'`.
## Token refresh (automatic, transport level) ## Token refresh (automatic, transport level)
@@ -1,84 +0,0 @@
# Battle pass — `client.battlePass`
`BattlePassService` (source: `src/battlepass/BattlePassService.ts`).
Call-and-response access to battle pass endpoints. Battle pass state is tied to
a **scenario battle pass node**, so calls carry `scenarioSlug` + `nodeId` (plus
`runId` for mutating calls). NOT observable — re-fetch progress explicitly
after a mutation.
In most games you do not call this service directly: a scenario battle pass
node surfaces through `client.effects.onBattlePass` with a session object that
wraps these calls (see `reference/scenarios.md`). Use the service directly when
you already know the scenario/node/run identifiers.
## Methods
```ts
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse>
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
```
## Request / response types
```ts
interface AddBattlePassXpRequest {
amount?: number;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
source?: string; // configured XP source
}
interface AddBattlePassXpResponse {
level?: number;
leveledUp?: boolean;
maxLevel?: boolean;
xp?: number;
}
interface ClaimBattlePassRewardRequest {
level?: number;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
track?: 'free' | 'premium';
}
interface ClaimBattlePassRewardResponse {
alreadyClaimed?: boolean;
error?: string;
granted?: Reward[]; // { amount?, currency?, itemId? }
success?: boolean;
}
interface GetBattlePassProgressResponse {
claimedTiers?: ClaimedTier[]; // { level?, track? }
level?: number;
premiumOwned?: boolean;
xp?: number;
}
interface PurchaseBattlePassPremiumRequest {
idempotencyKey?: string;
nodeId?: string;
runId?: string;
scenarioSlug?: string;
}
interface PurchaseBattlePassPremiumResponse {
error?: string;
success?: boolean;
}
```
## Semantics
- `addXp` credits XP from a configured source; returns the new `xp`/`level`
plus `leveledUp` / `maxLevel` flags.
- `claimReward` claims a tier reward at a reached level; idempotent
server-side (`alreadyClaimed`). Claiming a tier above the current level (or
a premium tier without premium) fails server-side — check `success`/`error`
in the response and the typed error `code` (`RudderErrorCodes`).
- `purchasePremium` charges the player's wallet; idempotent; pass your own
`idempotencyKey` for safe retries.
- Prefer the `onBattlePass` effect session, which binds `scenarioSlug` /
`nodeId` / `runId` and posts scenario callbacks for you.
@@ -1,56 +0,0 @@
# Inventory + catalog — `client.inventory`, `client.catalog`
## Inventory
`InventoryDomain` (source: `src/domains/InventoryDomain.ts`) — owned items
merged with their catalog entries. Synced under revision key `inventory`;
also refreshed whenever the catalog changes.
```ts
client.inventory.data // InventoryItem[] | undefined
client.inventory.onChange(cb);
await client.inventory.load() / .reload();
```
### `InventoryItem` (merged view, source: `src/state/inventory.ts`)
```ts
interface InventoryItem {
slug: string; // '' when the wire item has no slug
amount: number; // 0 when absent on the wire
name: string; // nameOverride || catalog name || slug
properties: Record<string, unknown>; // catalog properties + propertiesOverride (override wins)
tags: string[]; // from the catalog entry
}
```
The raw wire shape is `PlayerInventoryItem`
(`slug?, amount?, nameOverride?, propertiesOverride?, updatedAt?`) — you rarely
need it; the domain hands out the merged `InventoryItem`.
## Catalog
`CatalogDomain` (source: `src/domains/CatalogDomain.ts`) — the item catalog
keyed by slug. Synced under revision key `catalog`.
```ts
client.catalog.data // Map<string, CatalogItem> | undefined
interface CatalogItem {
name?: string;
properties?: { [key: string]: unknown };
slug?: string;
tags?: string[];
}
```
Items without a `slug` are skipped when the map is built.
## Behavior notes
- The catalog is warmed at login; inventory loads on first use, waits on the
catalog load, and merges, so `client.inventory.data` always has catalog
fields filled in.
- Invalidated after store purchases and scenario callbacks.
- Inventory has no client-side mutations — items change via purchases, quest /
battle pass rewards, and scenario nodes (all server-side).
@@ -1,50 +0,0 @@
# Leaderboards — `client.leaderboards`
`LeaderboardsService` (source: `src/leaderboards/LeaderboardsService.ts`).
Plain call-and-response service, NOT observable — refetch explicitly.
## Surface
```ts
const board = client.leaderboards.findBySlug('weekly-kills'); // cached handle
await board.submit(score);
const entries = await board.list(limit?);
board.getEntries();
```
## `LeaderboardHandle`
```ts
class LeaderboardHandle {
readonly slug: string;
getEntries(): readonly RankEntry[]; // last fetched list, [] initially
submit(score: number): Promise<void>;
list(limit = 100): Promise<readonly RankEntry[]>; // fetches and caches
}
```
- `findBySlug(slug)` caches handles per slug — repeated calls return the same
instance (and its cached entries).
- `list(limit)`: `limit <= 0` sends no limit to the server; default is 100.
- `submit` does not update the cached entries; call `list()` afterwards to see
the effect.
## Types
```ts
interface RankEntry {
playerId?: string;
playerName?: string;
rank?: number;
score?: number;
}
```
## Notes
- No player-around-me or metadata endpoints are exposed by this SDK — submit
and top-N list only.
- Scenario `leaderboard` nodes surface through
`client.effects.onLeaderboard` (`end()`, `rewardClaimed()`) — see
`reference/scenarios.md`.
+5 -6
View File
@@ -4,8 +4,8 @@
profile: identity plus currency wallets. Synced under revision key `profile`. profile: identity plus currency wallets. Synced under revision key `profile`.
There is no separate wallet service in this SDK. Wallet balances are read from There is no separate wallet service in this SDK. Wallet balances are read from
the profile and are credited/debited server-side (purchases, quest/battle pass the profile and are credited/debited server-side (admin wallet adjust, module
rewards, scenario nodes). functions).
## Surface ## Surface
@@ -51,10 +51,9 @@ interface Wallet {
## Behavior notes ## Behavior notes
- Warmed automatically at login (one of the four parallel warm loads). - Warmed automatically at login (with catalog).
- Invalidated (refetched) after every successful store purchase and after - Kept fresh by the revision poll; call `reload()` after a module function
scenario server callbacks — subscribers see fresh balances without waiting that mutates wallets.
for the revision poll.
- All fields are optional on the wire (`?`); code defensively. - All fields are optional on the wire (`?`); code defensively.
- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK - React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK
README "React recipe"). README "React recipe").
-75
View File
@@ -1,75 +0,0 @@
# Quests — `client.quests`
`QuestsService` (source: `src/quests/QuestsService.ts`). The player's **global**
quests — list, claim, report metric progress. Plain call-and-response service,
NOT observable (no sync revision key): re-list after a claim or report.
Distinct from scenario quest nodes, which advance through
`client.effects.onQuest` (see `reference/scenarios.md`).
## Methods
```ts
list(): Promise<Quest[]>
claim(questSlug: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // slugs of quests completed by this report
```
## Types
```ts
interface Quest {
slug?: string; // stable across environments — what claim() takes
name?: string;
objectives?: QuestObjectiveProgress[];
rewards?: Reward[];
status?: 'active' | 'claimed' | 'completed';
}
interface QuestObjectiveProgress {
completed?: boolean;
current?: number;
metric?: string;
objectiveId?: string;
target?: number;
}
interface ClaimQuestResponse {
alreadyClaimed?: boolean;
error?: string;
granted?: Reward[]; // { amount?, currency?, itemId? }
success?: boolean;
}
```
## Usage notes
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.slug) {
const res = await client.quests.claim(quest.slug);
// res.success / res.alreadyClaimed / res.granted
}
}
const completedSlugs = await client.quests.reportProgress('kills', 1);
```
- `claim` is idempotent server-side; check `success` / `alreadyClaimed` /
`error` in the response rather than relying on exceptions.
- Objective completion is judged server-side from metric reports.
## `QuestMetrics` helpers
Exported as a namespace: `import { QuestMetrics } from '@rudder/js-sdk'`
(source: `src/quests/QuestMetrics.ts`).
```ts
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
Purchase metrics are reported automatically server-side by the store purchase
fan-out — these helpers exist so quest configs and client code name the format
consistently. Catalog counter slugs are reported via `reportProgress`. Custom
free-text metrics no longer progress quests — they no-op at runtime.
@@ -1,63 +0,0 @@
# Remote config — `client.remoteConfig`
`ConfigDomain<TConfig>` (source: `src/domains/ConfigDomain.ts`, base class
`src/state/RemoteConfigState.ts`) — typed remote configuration as an
observable entity. Synced under revision key `config`.
## Typed access
```ts
interface GameConfig extends Record<string, unknown> {
player_speed: number;
feature_x: boolean;
}
const client = new RudderClient<GameConfig>({ baseUrl, projectKey });
client.remoteConfig.get('player_speed', 200); // number
client.remoteConfig.get('feature_x'); // boolean | undefined
```
`get()` overloads:
```ts
get(key): TConfig[key] | undefined;
get(key, defaultValue: TConfig[key]): TConfig[key];
```
- Values are parsed synchronously from the loaded snapshot according to the
config's server-declared `valueType`.
- Returns the default value (or `undefined`) while not loaded, for unknown
keys, and when parsing fails.
- Parsing rules: `int`/`integer` → `parseInt`; `float`/`double`/`number` →
`parseFloat`; `bool`/`boolean` → `value === 'true'`; `json`/`object` →
`JSON.parse`; anything else → raw string.
## Observable
```ts
client.remoteConfig.data // Map<string, RemoteConfig> | undefined
client.remoteConfig.onChange(cb); // fires immediately
await client.remoteConfig.reload();
```
```ts
interface RemoteConfig {
active?: boolean; createdAt?: string; description?: string;
environment?: string; id?: string; key?: string; projectId?: string;
updatedAt?: string; value?: string;
valueType?: 'bool' | 'float' | 'int' | 'json' | 'string';
}
```
Only configs that are not explicitly `active: false` enter the map (the server
already filters inactive configs out and may omit the flag).
## Behavior notes
- Warmed at login.
- Scenario `remote_config_override` nodes are applied server-side and do not
reach the client. Reload or wait for the config sync poll to observe the
patched value via `get()`.
- Without a `TConfig` type argument, `RemoteConfigShape` defaults to
`Record<string, unknown>` and `get()` returns `unknown`.
@@ -1,166 +0,0 @@
# Scenarios + effects — `client.effects`
Scenarios are server-authored node graphs (configured in the dashboard) that
run per player. The server executes the graph. The SDK is a thin effects
client: it sends trigger events, turns `PendingEffect` payloads into typed
`client.effects` handlers, posts callbacks when the game completes an
effect, and polls for pending effects. The runtime is deliberately **not**
part of the public client surface (source: `src/effects/EffectsCenter.ts`)
and loads lazily on first login.
## Lifecycle
- After login the SDK fetches `GET /sdk/v1/scenarios/pending` (replacing any
local restore), then fires the login event (`'player_login'` by default).
- A heartbeat polls pending every ~30s (±20% jitter), paused while
`document.hidden`. Each effect with a `waitDeadline` also schedules a
local timer so waits fire on time.
- An effect with an already-active `(runId, nodeId)` is not re-emitted.
- `logout()` / `dispose()` stop the poll, cancel wait timers, and drop
active effects. There is no local plan persistence.
### Runtime options (`RudderClientOptions.runtime`)
```ts
runtime?: {
// Scenario event fired automatically after login.
// undefined = 'player_login'; null = fire nothing.
loginEvent?: string | null;
}
```
## Subscribing
Every `on*` method takes a handler `(effect) => void | Promise<void>` and
returns an unsubscribe function. Errors thrown inside handlers go to the
`onEffectError` client option (default `console.error`).
```ts
const off = client.effects.onNotification(async (n) => {
showToast(n.title, n.message);
await n.done(); // always resolve the session or the run stalls
});
```
## Effect types
```ts
interface Effects {
onNotification(handler): EffectUnsubscribe;
onStoreOffer(handler): EffectUnsubscribe;
onLeaderboard(handler): EffectUnsubscribe;
onConfigChanged(handler): EffectUnsubscribe;
onWait(handler): EffectUnsubscribe;
onScenarioCompleted(handler): EffectUnsubscribe;
onScenarioFailed(handler): EffectUnsubscribe;
onQuest(handler): EffectUnsubscribe;
onBattlePass(handler): EffectUnsubscribe;
onBattlePassLevel(handler): EffectUnsubscribe;
}
```
`onConfigChanged` remains on the public surface but scenario
`remote_config_override` nodes no longer reach the client — the server
applies them. Patched values show up through `client.remoteConfig` after
sync/reload.
### `NotificationEffect` — a notification node became active
```ts
{ readonly title: string; readonly message: string; done(): Promise<void> }
```
### `StoreOfferEffect` — a store node became active
```ts
{
readonly store: ShopHandle;
readonly offers: readonly OfferHandle[];
readonly message?: string;
buy(offer: OfferHandle, options?: BuyOptions): Promise<PurchaseOfferResponse>;
dismiss(): Promise<void>; // declines the offer and advances the run
}
```
### `LeaderboardEffect` — a leaderboard node became active
```ts
{ end(): Promise<void>; claim(): Promise<void>; rewardClaimed(): Promise<void> }
```
`rewardClaimed()` is a deprecated alias for `claim()`.
### `WaitEffect` — a wait node became active
```ts
{ readonly deadlineUtc: Date } // SDK polls pending at the deadline; the server advances the run
```
### `QuestEffect` — a scenario quest node became active
```ts
{
readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>;
}
```
The node auto-completes server-side once every objective is satisfied; the
counter response may carry the next `PendingEffect`.
### `BattlePassEffect` — a battle pass node became active
```ts
{
getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
levelUp(): Promise<void>;
end(): Promise<void>;
}
```
These wrap `client.battlePass` with the run's `scenarioSlug`/`nodeId`/`runId`
already bound — prefer them over calling the service manually.
### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier)
```ts
{ readonly level: number; claim(): Promise<void> }
```
### Run lifecycle effects
```ts
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioSlug: string }
interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioSlug: string; readonly nodeId: string;
readonly error: Error;
}
```
Always subscribe to `onScenarioFailed` — otherwise run failures surface only
as console warnings.
## Supported node types
`wait`, `notification`, `store`, `leaderboard`, `quest`, `battlepass`,
`battlepass_level`. Any other node type fails the run (surfaced via
`onScenarioFailed`). `remote_config_override` is applied server-side and is
not delivered to the client.
## Reliability notes
- Completion methods POST `/sdk/v1/scenarios/callback` with
`{scenarioSlug, runId, nodeId, handle}` using the handles `output`,
`onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`,
`onPremiumPurchase`.
- Transient callback failures (network error, 5xx) leave the effect active
for retry; `unknown_run` / `run_expired` drop that run's effects and fire
`onScenarioFailed`.
- Server scenario errors use the typed codes `run_expired`, `run_not_active`,
`node_not_active`, `scenario_not_active`, `unknown_run`,
`early_completion`, `objectives_incomplete`, `level_not_reached`,
`forbidden` (`RudderErrorCodes`).
-82
View File
@@ -1,82 +0,0 @@
# Stores / purchases — `client.stores`
`StoresDomain` (source: `src/domains/StoresDomain.ts`, handles in
`src/state/shops.ts`). The observable store list plus the purchase executor.
Synced under revision key `stores`. This is the SDK's economy surface — there
is no separate economy service.
## Surface
```ts
client.stores.data // ShopHandle[] | undefined
client.stores.onChange(cb);
await client.stores.load() / .reload();
// Direct purchase (bypassing handles):
await client.stores.purchase(storeSlug, offerSlug, options?);
```
## Handles
```ts
class ShopHandle {
readonly slug: string;
readonly name?: string;
readonly description?: string;
readonly data?: { [key: string]: unknown };
readonly offers: OfferHandle[];
}
class OfferHandle {
readonly id: string; // authoring id, differs between staging and prod
readonly slug: string; // stable across environments — what purchases use
readonly name?: string;
readonly price?: OfferPrice; // { amount?: number; currency?: string }
readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[]
readonly maxPurchases?: number;
buy(options?: BuyOptions): Promise<PurchaseOfferResponse>;
}
interface BuyOptions { idempotencyKey?: string }
interface PurchaseOfferResponse {
error?: string;
purchaseId?: string;
success?: boolean;
}
```
## Purchase semantics
- `stores.purchase(storeSlug, offerSlug, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the store slug and the offer slug.
- When `options.idempotencyKey` is omitted the SDK generates
`crypto.randomUUID()` per call — safe retries require passing your own key.
- On success the executor invalidates `player`, `inventory`, and `stores`, so
subscribers observe fresh wallet/inventory/store data immediately.
- Check `response.success` / `response.error` — a failed purchase is a resolved
response, not necessarily a thrown error.
- Purchase metrics (`purchase.offer:<offerSlug>`, `purchase.item:<itemId>`) are
reported to quests automatically server-side — do not report them manually
(see `reference/quests.md`).
## Wire types
```ts
interface Store {
createdAt?: string; data?: { [key: string]: unknown }; description?: string;
environment?: string; id?: string; name?: string; offers?: Offer[];
projectId?: string; scenarioId?: string; slug?: string; status?: string;
updatedAt?: string;
}
interface Offer {
contents?: OfferContent[]; createdAt?: string; id?: string;
maxPurchases?: number; name?: string; price?: OfferPrice; slug?: string;
updatedAt?: string;
}
```
`ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store
without `slug` / an offer without `id` or `slug` — in practice the domain only
builds handles from server data that has all of them.
+9 -2
View File
@@ -7,7 +7,6 @@
*/ */
import type { RudderContext } from '../core/context.js'; import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
import { getOrCreateDeviceId } from '../device/DeviceId.js'; import { getOrCreateDeviceId } from '../device/DeviceId.js';
import { api } from '../generated/api.js'; import { api } from '../generated/api.js';
import type { import type {
@@ -72,7 +71,7 @@ export interface LinkWithCustomOptions {
provider?: string; provider?: string;
} }
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> { export class AuthService {
private readonly listeners = new Set<AuthStateListener>(); private readonly listeners = new Set<AuthStateListener>();
private state: AuthState; private state: AuthState;
@@ -118,6 +117,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region, region,
language, language,
nickname, nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
}); });
this.ctx.options.tokenStore.saveTokens( this.ctx.options.tokenStore.saveTokens(
@@ -145,6 +146,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region, region,
language, language,
nickname, nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
}); });
this.ctx.options.tokenStore.saveTokens( this.ctx.options.tokenStore.saveTokens(
@@ -169,6 +172,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region, region,
language, language,
nickname, nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
}); });
this.ctx.options.tokenStore.saveTokens( this.ctx.options.tokenStore.saveTokens(
@@ -193,6 +198,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
region, region,
language, language,
nickname, nickname,
platform: 'web',
appVersion: this.ctx.options.appVersion,
}); });
this.ctx.options.tokenStore.saveTokens( this.ctx.options.tokenStore.saveTokens(
-46
View File
@@ -1,46 +0,0 @@
/**
* 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 `scenarioSlug` + `nodeId` (and a `runId` for the mutating calls).
* Battle pass has no sync-revision key, so this is a plain service, not an
* observable domain; re-fetch progress explicitly after a mutation.
*/
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(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioSlug, 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);
}
}
+20 -112
View File
@@ -1,16 +1,12 @@
/** /**
* RudderClient — the main entry point for the LiveOps Web SDK. * RudderClient — the main entry point for the LiveOps Web SDK.
* *
* Composes the observable domains, the scenario runtime, and the core HTTP * Composes the kernel domains (auth, player, catalog, storage, modules,
* transport that every service uses to reach the LiveOps API gateway. * resources) and the HTTP transport every service uses to reach the API.
* *
* The client builds a {@link RudderContext} first and injects it into every * 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 * domain, so they depend on that narrow surface rather than on the client
* client (which keeps the module graph acyclic). Cross-domain wiring (the * (which keeps the module graph acyclic).
* inventory↔catalog link, purchase invalidation) is done here at construction.
*
* The scenario effects client is loaded lazily on first use — a client that
* never logs in never pays for the scenario machinery.
*/ */
import type { import type {
@@ -22,45 +18,32 @@ import type { RudderContext } from '../core/context.js';
import { request, type RequestContext } from '../transport/request.js'; import { request, type RequestContext } from '../transport/request.js';
import { createDefaultTokenStore } from '../token/TokenStore.js'; import { createDefaultTokenStore } from '../token/TokenStore.js';
import { AuthService } from '../auth/AuthService.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 { EffectsCenter, type Effects } from '../effects/EffectsCenter.js'; import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
import { SyncEngine } from '../state/SyncEngine.js'; import { SyncEngine } from '../state/SyncEngine.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
import { PlayerDomain } from '../domains/PlayerDomain.js'; import { PlayerDomain } from '../domains/PlayerDomain.js';
import { CatalogDomain } from '../domains/CatalogDomain.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 { StorageDomain } from '../domains/StorageDomain.js';
import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js'; import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
import { StoresDomain } from '../domains/StoresDomain.js'; import { ModulesDomain } from '../domains/ModulesDomain.js';
import { ResourcesDomain, type ResourceHandle } from '../domains/ResourcesDomain.js';
type ManagedDomain = { invalidate(): void; reset(): void }; type ManagedDomain = { invalidate(): void; reset(): void };
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> { export class RudderClient {
public readonly options: ResolvedRudderClientOptions; public readonly options: ResolvedRudderClientOptions;
public readonly auth: AuthService<TConfig>; public readonly auth: AuthService;
public readonly leaderboards: LeaderboardsService;
public readonly battlePass: BattlePassService;
public readonly quests: QuestsService;
public readonly effects: Effects; public readonly effects: Effects;
public readonly player: PlayerDomain; public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain; public readonly catalog: CatalogDomain;
public readonly inventory: InventoryDomain;
public readonly remoteConfig: ConfigDomain<TConfig>;
public readonly storage: StorageDomain; public readonly storage: StorageDomain;
public readonly projectStorage: ProjectStorageDomain; public readonly projectStorage: ProjectStorageDomain;
public readonly stores: StoresDomain; public readonly modules: ModulesDomain;
private readonly resourcesDomain: ResourcesDomain;
private readonly effectsCenter: EffectsCenter; private readonly effectsCenter: EffectsCenter;
private scenarioRuntime: ScenarioService | null = null;
private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
private readonly syncEngine: SyncEngine; private readonly syncEngine: SyncEngine;
private readonly loginEvent: string | null;
private readonly ctx: RudderContext; private readonly ctx: RudderContext;
private readonly transportContext: RequestContext; private readonly transportContext: RequestContext;
private runtimeStarted = false; private runtimeStarted = false;
@@ -78,13 +61,12 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
} }
this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() }; this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() };
this.loginEvent =
options.runtime?.loginEvent === undefined ? 'player_login' : options.runtime.loginEvent;
this.transportContext = { this.transportContext = {
baseUrl: this.options.baseUrl, baseUrl: this.options.baseUrl,
tokenStore: this.options.tokenStore, tokenStore: this.options.tokenStore,
requestTimeoutMs: this.options.requestTimeoutMs, requestTimeoutMs: this.options.requestTimeoutMs,
appVersion: this.options.appVersion,
refreshState: {}, refreshState: {},
onAuthFailure: () => { onAuthFailure: () => {
this.auth.notifySessionExpired(); this.auth.notifySessionExpired();
@@ -105,29 +87,18 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.player = new PlayerDomain(ctx); this.player = new PlayerDomain(ctx);
this.catalog = new CatalogDomain(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.storage = new StorageDomain(ctx);
this.projectStorage = new ProjectStorageDomain(ctx); this.projectStorage = new ProjectStorageDomain(ctx);
this.stores = new StoresDomain(ctx, () => { this.modules = new ModulesDomain(ctx);
this.player.invalidate(); this.resourcesDomain = new ResourcesDomain(ctx);
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.syncEngine = new SyncEngine(ctx, [
this.player, this.player,
this.catalog, this.catalog,
this.inventory,
this.remoteConfig,
this.storage, this.storage,
this.projectStorage, this.projectStorage,
this.stores,
]); ]);
this.auth = new AuthService<TConfig>( this.auth = new AuthService(
ctx, ctx,
() => this.startRuntimeAfterLogin(), () => this.startRuntimeAfterLogin(),
() => this.stopRuntimeAfterLogout(), () => this.stopRuntimeAfterLogout(),
@@ -136,81 +107,33 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
dispose(): void { dispose(): void {
this.syncEngine.stop(); this.syncEngine.stop();
this.scenarioRuntime?.clear();
this.resetAll(); this.resetAll();
this.runtimeStarted = false; this.runtimeStarted = false;
} }
resources(slug: string): ResourceHandle {
return this.resourcesDomain.of(slug);
}
/** @internal */ /** @internal */
async startRuntimeAfterLogin(): Promise<void> { async startRuntimeAfterLogin(): Promise<void> {
const runtimePromise = this.ensureScenarioRuntime();
if (this.runtimeStarted) { if (this.runtimeStarted) {
this.scenarioRuntime?.clear();
this.invalidateAll(); this.invalidateAll();
} }
this.runtimeStarted = true; this.runtimeStarted = true;
await Promise.all([ await Promise.all([this.player.load(), this.catalog.load()]);
this.remoteConfig.load(),
this.player.load(),
this.stores.load(),
this.catalog.load(),
]);
const runtime = await runtimePromise;
await runtime.start();
if (this.loginEvent) {
await this.sendRuntimeEvent(runtime, this.loginEvent);
}
this.syncEngine.start(); this.syncEngine.start();
} }
private ensureScenarioRuntime(): Promise<ScenarioService> {
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
return this.scenarioRuntimePromise;
}
private async createScenarioRuntime(): Promise<ScenarioService> {
const { ScenarioService } = await import('../scenario/ScenarioService.js');
this.scenarioRuntime = new ScenarioService(
this.ctx,
{
player: this.player,
inventory: this.inventory,
stores: this.stores,
},
this.battlePass,
);
return this.scenarioRuntime;
}
private async sendRuntimeEvent(
runtime: ScenarioService,
event: string,
): Promise<void> {
try {
await runtime.send(event);
} catch (error) {
console.warn(`RudderClient: scenario event "${event}" failed after login`, error);
}
}
/** @internal */ /** @internal */
stopRuntimeAfterLogout(): void { stopRuntimeAfterLogout(): void {
this.runtimeStarted = false; this.runtimeStarted = false;
this.syncEngine.stop(); this.syncEngine.stop();
this.scenarioRuntime?.clear();
this.resetAll(); this.resetAll();
} }
private get managedDomains(): ManagedDomain[] { private get managedDomains(): ManagedDomain[] {
return [ return [this.player, this.catalog, this.storage, this.projectStorage];
this.player,
this.catalog,
this.inventory,
this.remoteConfig,
this.storage,
this.projectStorage,
this.stores,
];
} }
private invalidateAll(): void { private invalidateAll(): void {
@@ -221,18 +144,3 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
for (const domain of this.managedDomains) domain.reset(); 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 effects client on first call.
*
* @internal
*/
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
client: RudderClient<TConfig>,
): Promise<ScenarioService> {
return (
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
).ensureScenarioRuntime();
}
+6 -7
View File
@@ -1,9 +1,5 @@
import type { TokenStore } from '../token/TokenStore.js'; import type { TokenStore } from '../token/TokenStore.js';
export interface RudderRuntimeOptions {
loginEvent?: string | null;
}
export interface RudderClientOptions { export interface RudderClientOptions {
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */ /** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
baseUrl: string; baseUrl: string;
@@ -18,6 +14,12 @@ export interface RudderClientOptions {
*/ */
tokenStore?: TokenStore; tokenStore?: TokenStore;
/**
* Game build version sent as `appVersion` on every login and token refresh
* (used by segments). Omitted when not set.
*/
appVersion?: string;
/** Per-request timeout in milliseconds (default: 10000). */ /** Per-request timeout in milliseconds (default: 10000). */
requestTimeoutMs?: number; requestTimeoutMs?: number;
@@ -29,9 +31,6 @@ export interface RudderClientOptions {
* Defaults to `console.error`. * Defaults to `console.error`.
*/ */
onEffectError?: (error: unknown) => void; onEffectError?: (error: unknown) => void;
/** Advanced runtime knobs (login event). */
runtime?: RudderRuntimeOptions;
} }
/** Client options with all defaults resolved — what `client.options` exposes. */ /** Client options with all defaults resolved — what `client.options` exposes. */
+4 -6
View File
@@ -3,15 +3,13 @@
* *
* All errors extend `RudderError`. Network errors, HTTP errors, and * All errors extend `RudderError`. Network errors, HTTP errors, and
* authentication errors have dedicated subclasses for granular handling. * authentication errors have dedicated subclasses for granular handling.
* The machine-readable `code` is the server-provided {@link RudderErrorCode} * The machine-readable `code` is the server-provided code (open-ended: platform
* when known, or any other string for unknown servers / SDK-local codes * codes and module business error codes) or an SDK-local code
* (e.g. `sdk/invalid-options` for client configuration errors). * (e.g. `sdk/invalid-options` for client configuration errors).
*/ */
import type { RudderErrorCode } from '../generated/errors.js'; /** Code carried by SDK errors: a server code or an SDK-local code. */
export type RudderErrorCodeLike = string;
/** 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. */ /** Code for client-side configuration validation failures. */
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options'; export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
+9 -9
View File
@@ -1,22 +1,22 @@
import { SyncedState } from '../state/SyncedState.js'; import { SyncedState } from '../state/SyncedState.js';
import type { RudderContext } from '../core/context.js'; import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js'; import { api } from '../generated/api.js';
import type { CatalogItem } from '../generated/catalog.js'; import type { CatalogCurrency } from '../generated/catalog.js';
/** /**
* CatalogDomain — the item catalog, keyed by slug. Synced under revision * CatalogDomain — currency definitions, keyed by slug. Synced under revision
* key `catalog`. Feeds the merged {@link InventoryDomain} view. * key `catalog`.
*/ */
export class CatalogDomain extends SyncedState<Map<string, CatalogItem>> { export class CatalogDomain extends SyncedState<Map<string, CatalogCurrency>> {
readonly syncKey = 'catalog'; readonly syncKey = 'catalog';
constructor(ctx: RudderContext) { constructor(ctx: RudderContext) {
super(async () => { super(async () => {
const response = await api.listCatalogItems(ctx); const response = await api.listCatalogCurrencies(ctx);
const map = new Map<string, CatalogItem>(); const map = new Map<string, CatalogCurrency>();
for (const item of response.items ?? []) { for (const currency of response.currencies ?? []) {
if (!item.slug) continue; if (!currency.slug) continue;
map.set(item.slug, item); map.set(currency.slug, currency);
} }
return map; return map;
}); });
-33
View File
@@ -1,33 +0,0 @@
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;
});
}
}
-31
View File
@@ -1,31 +0,0 @@
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();
});
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type { CallModuleFunctionClientResponse } from '../generated/modules.js';
export class ModulesDomain {
constructor(private readonly ctx: RudderContext) {}
call(module: string, fn: string, args?: unknown): Promise<CallModuleFunctionClientResponse> {
return api.callModuleFunction(this.ctx, { module, fn }, { args });
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
import type {
CreateResourceRowRequest,
ListResourceRowsResponse,
ResourceRow,
UpdateResourceRowRequest,
} from '../generated/resources.js';
export class ResourceHandle {
constructor(
private readonly ctx: RudderContext,
private readonly slug: string,
) {}
list(): Promise<ListResourceRowsResponse> {
return api.listResourceRows(this.ctx, { slug: this.slug });
}
get(id: string): Promise<ResourceRow> {
return api.getResourceRow(this.ctx, { slug: this.slug, id });
}
create(body: CreateResourceRowRequest): Promise<ResourceRow> {
return api.createResourceRow(this.ctx, { slug: this.slug }, body);
}
update(id: string, body: UpdateResourceRowRequest): Promise<ResourceRow> {
return api.updateResourceRow(this.ctx, { slug: this.slug, id }, body);
}
delete(id: string): Promise<void> {
return api.deleteResourceRow(this.ctx, { slug: this.slug, id });
}
}
export class ResourcesDomain {
constructor(private readonly ctx: RudderContext) {}
of(slug: string): ResourceHandle {
return new ResourceHandle(this.ctx, slug);
}
}
-62
View File
@@ -1,62 +0,0 @@
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, offerSlug, 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,
offerSlug: string,
options: BuyOptions = {},
): Promise<PurchaseOfferResponse> => {
const response = await api.purchaseOffer(
ctx,
{ storeSlug, offerSlug },
{
storeSlug,
offerSlug,
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);
}
}
-116
View File
@@ -1,12 +1,3 @@
import type { PurchaseOfferResponse } from '../generated/stores.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
GetBattlePassProgressResponse,
PurchaseBattlePassPremiumResponse,
} from '../generated/battlepass.js';
export type EffectUnsubscribe = () => void; export type EffectUnsubscribe = () => void;
export interface NotificationEffect { export interface NotificationEffect {
@@ -15,25 +6,6 @@ export interface NotificationEffect {
done(): Promise<void>; 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>;
claim(): Promise<void>;
/** @deprecated Use {@link LeaderboardEffect.claim}. Removed in the next SDK version. */
rewardClaimed(): Promise<void>;
}
export interface ConfigChangedEffect {
readonly key: string;
}
export interface WaitEffect { export interface WaitEffect {
readonly deadlineUtc: Date; readonly deadlineUtc: Date;
} }
@@ -50,52 +22,20 @@ export interface ScenarioFailedEffect {
readonly error: Error; readonly error: Error;
} }
export interface QuestEffect {
readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>;
}
export interface BattlePassEffect {
getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
levelUp(): Promise<void>;
end(): Promise<void>;
}
export interface BattlePassLevelEffect {
readonly level: number;
claim(): Promise<void>;
}
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>; type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
export interface Effects { export interface Effects {
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe; 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; onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe; onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): 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 { export class EffectsCenter implements Effects {
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>(); 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 waitHandlers = new Set<EffectHandler<WaitEffect>>();
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>(); private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>(); 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( constructor(
private readonly onError: (error: unknown) => void = (error) => private readonly onError: (error: unknown) => void = (error) =>
@@ -106,18 +46,6 @@ export class EffectsCenter implements Effects {
return this.addHandler(this.notificationHandlers, handler); 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 { onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
return this.addHandler(this.waitHandlers, handler); return this.addHandler(this.waitHandlers, handler);
} }
@@ -130,40 +58,11 @@ export class EffectsCenter implements Effects {
return this.addHandler(this.scenarioFailedHandlers, handler); 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 */ /** @internal */
emitNotification(effect: NotificationEffect): void { emitNotification(effect: NotificationEffect): void {
this.emit(this.notificationHandlers, effect); this.emit(this.notificationHandlers, effect);
} }
/** @internal */
emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
Promise.resolve(effect)
.then((value) => this.emit(this.storeOfferHandlers, value))
.catch((error) => this.onError(error));
}
/** @internal */
emitLeaderboard(effect: LeaderboardEffect): void {
this.emit(this.leaderboardHandlers, effect);
}
/** @internal */
emitConfigChanged(key: string): void {
this.emit(this.configChangedHandlers, { key });
}
/** @internal */ /** @internal */
emitWait(effect: WaitEffect): void { emitWait(effect: WaitEffect): void {
this.emit(this.waitHandlers, effect); this.emit(this.waitHandlers, effect);
@@ -179,21 +78,6 @@ export class EffectsCenter implements Effects {
this.emit(this.scenarioFailedHandlers, effect); this.emit(this.scenarioFailedHandlers, effect);
} }
/** @internal */
emitQuest(effect: QuestEffect): void {
this.emit(this.questHandlers, effect);
}
/** @internal */
emitBattlePass(effect: BattlePassEffect): void {
this.emit(this.battlePassHandlers, effect);
}
/** @internal */
emitBattlePassLevel(effect: BattlePassLevelEffect): void {
this.emit(this.battlePassLevelHandlers, effect);
}
private addHandler<TEffect>( private addHandler<TEffect>(
handlers: Set<EffectHandler<TEffect>>, handlers: Set<EffectHandler<TEffect>>,
handler: EffectHandler<TEffect>, handler: EffectHandler<TEffect>,
+33 -111
View File
@@ -1,17 +1,12 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, LoginViaAppleRequest, LoginViaAppleResponse, LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, LoginViaGoogleRequest, LoginViaGoogleResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse, UnlinkIdentityRequest } from './auth.js'; import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, LoginViaAppleRequest, LoginViaAppleResponse, LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, LoginViaGoogleRequest, LoginViaGoogleResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse, UnlinkIdentityRequest } from './auth.js';
import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js'; import type { ListCatalogCurrenciesResponse } from './catalog.js';
import type { ListCatalogItemsResponse } from './catalog.js'; import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
import type { GetInventoryResponse } from './inventory.js';
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
import type { PlayerProfile } from './player.js'; import type { PlayerProfile } from './player.js';
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js'; import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js'; import type { CreateResourceRowRequest, ListResourceRowsResponse, ResourceRow, UpdateResourceRowRequest } from './resources.js';
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
import type { HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, ListPendingScenarioEffectsResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js'; import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
/** The minimal transport contract the generated API functions call through. */ /** The minimal transport contract the generated API functions call through. */
export interface Transport { export interface Transport {
@@ -36,23 +31,25 @@ function qs(
/** Typed request functions, one per SDK operation. */ /** Typed request functions, one per SDK operation. */
export const api = { export const api = {
addBattlePassXp: ( callModuleFunction: (
t: Transport, t: Transport,
body: AddBattlePassXpRequest, path: { module: string; fn: string },
): Promise<AddBattlePassXpResponse> => body: CallModuleFunctionRequest,
t.request<AddBattlePassXpResponse>('POST', '/sdk/v1/battlepass/xp', body), ): Promise<CallModuleFunctionClientResponse> =>
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
claimBattlePassReward: ( createResourceRow: (
t: Transport, t: Transport,
body: ClaimBattlePassRewardRequest, path: { slug: string },
): Promise<ClaimBattlePassRewardResponse> => body: CreateResourceRowRequest,
t.request<ClaimBattlePassRewardResponse>('POST', '/sdk/v1/battlepass/claim', body), ): Promise<ResourceRow> =>
t.request<ResourceRow>('POST', `/sdk/v1/resources/${enc(path.slug)}`, body),
claimQuest: ( deleteResourceRow: (
t: Transport, t: Transport,
body: ClaimQuestRequest, path: { slug: string; id: string },
): Promise<ClaimQuestResponse> => ): Promise<void> =>
t.request<ClaimQuestResponse>('POST', '/sdk/v1/quests/claim', body), t.request<void>('DELETE', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
deleteStorage: ( deleteStorage: (
t: Transport, t: Transport,
@@ -60,17 +57,6 @@ export const api = {
): Promise<void> => ): Promise<void> =>
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`), t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
getBattlePassProgress: (
t: Transport,
body: GetBattlePassProgressRequest,
): Promise<GetBattlePassProgressResponse> =>
t.request<GetBattlePassProgressResponse>('POST', '/sdk/v1/battlepass/progress', body),
getInventory: (
t: Transport,
): Promise<GetInventoryResponse> =>
t.request<GetInventoryResponse>('GET', '/sdk/v1/inventory'),
getPlayerInformation: ( getPlayerInformation: (
t: Transport, t: Transport,
): Promise<PlayerProfile> => ): Promise<PlayerProfile> =>
@@ -82,18 +68,11 @@ export const api = {
): Promise<GetProjectStorageResponse> => ): Promise<GetProjectStorageResponse> =>
t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`), t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
getRanking: ( getResourceRow: (
t: Transport, t: Transport,
path: { slug: string }, path: { slug: string; id: string },
query?: { limit?: number }, ): Promise<ResourceRow> =>
): Promise<GetRankingResponse> => t.request<ResourceRow>('GET', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
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: ( getRevisions: (
t: Transport, t: Transport,
@@ -106,18 +85,6 @@ export const api = {
): Promise<GetStorageResponse> => ): Promise<GetStorageResponse> =>
t.request<GetStorageResponse>('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`), 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)}`),
handleScenarioCallback: (
t: Transport,
body: HandleScenarioCallbackRequest,
): Promise<HandleScenarioCallbackResponse> =>
t.request<HandleScenarioCallbackResponse>('POST', '/sdk/v1/scenarios/callback', body),
linkViaApple: ( linkViaApple: (
t: Transport, t: Transport,
body: LinkViaAppleRequest, body: LinkViaAppleRequest,
@@ -136,30 +103,16 @@ export const api = {
): Promise<void> => ): Promise<void> =>
t.request<void>('POST', '/sdk/v1/authorization/google/link', body), t.request<void>('POST', '/sdk/v1/authorization/google/link', body),
listCatalogItems: ( listCatalogCurrencies: (
t: Transport, t: Transport,
): Promise<ListCatalogItemsResponse> => ): Promise<ListCatalogCurrenciesResponse> =>
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'), t.request<ListCatalogCurrenciesResponse>('GET', '/sdk/v1/catalog'),
listPendingScenarioEffects: ( listResourceRows: (
t: Transport, t: Transport,
): Promise<ListPendingScenarioEffectsResponse> => path: { slug: string },
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'), ): Promise<ListResourceRowsResponse> =>
t.request<ListResourceRowsResponse>('GET', `/sdk/v1/resources/${enc(path.slug)}`),
listQuests: (
t: Transport,
): Promise<ListQuestsResponse> =>
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list'),
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'),
loginViaApple: ( loginViaApple: (
t: Transport, t: Transport,
@@ -185,44 +138,12 @@ export const api = {
): Promise<LoginViaGoogleResponse> => ): Promise<LoginViaGoogleResponse> =>
t.request<LoginViaGoogleResponse>('POST', '/sdk/v1/authorization/google', body), t.request<LoginViaGoogleResponse>('POST', '/sdk/v1/authorization/google', body),
purchaseBattlePassPremium: (
t: Transport,
body: PurchaseBattlePassPremiumRequest,
): Promise<PurchaseBattlePassPremiumResponse> =>
t.request<PurchaseBattlePassPremiumResponse>('POST', '/sdk/v1/battlepass/premium', body),
purchaseOffer: (
t: Transport,
path: { storeSlug: string; offerSlug: string },
body: PurchaseOfferRequest,
): Promise<PurchaseOfferResponse> =>
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
refreshAccessToken: ( refreshAccessToken: (
t: Transport, t: Transport,
body: RefreshAccessTokenRequest, body: RefreshAccessTokenRequest,
): Promise<RefreshAccessTokenResponse> => ): Promise<RefreshAccessTokenResponse> =>
t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body), t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body),
reportQuestProgress: (
t: Transport,
body: ReportQuestProgressRequest,
): Promise<ReportQuestProgressResponse> =>
t.request<ReportQuestProgressResponse>('POST', '/sdk/v1/quests/progress', body),
submitScore: (
t: Transport,
path: { slug: string },
body: SubmitScoreRequest,
): Promise<void> =>
t.request<void>('POST', `/sdk/v1/leaderboards/${enc(path.slug)}/submit-score`, body),
triggerScenario: (
t: Transport,
body: TriggerScenarioRequest,
): Promise<TriggerScenarioResponse> =>
t.request<TriggerScenarioResponse>('POST', '/sdk/v1/scenarios/trigger', body),
unlinkIdentity: ( unlinkIdentity: (
t: Transport, t: Transport,
body: UnlinkIdentityRequest, body: UnlinkIdentityRequest,
@@ -235,11 +156,12 @@ export const api = {
): Promise<void> => ): Promise<void> =>
t.request<void>('PUT', '/sdk/v1/project-storage', body), t.request<void>('PUT', '/sdk/v1/project-storage', body),
updateScenarioCounter: ( updateResourceRow: (
t: Transport, t: Transport,
body: UpdateScenarioCounterRequest, path: { slug: string; id: string },
): Promise<UpdateScenarioCounterResponse> => body: UpdateResourceRowRequest,
t.request<UpdateScenarioCounterResponse>('POST', '/sdk/v1/scenarios/counter', body), ): Promise<ResourceRow> =>
t.request<ResourceRow>('PATCH', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`, body),
updateStorage: ( updateStorage: (
t: Transport, t: Transport,
+10
View File
@@ -14,10 +14,12 @@ export interface LinkViaGoogleRequest {
} }
export interface LoginViaAppleRequest { export interface LoginViaAppleRequest {
"appVersion"?: string;
"idToken": string; "idToken": string;
"key": string; "key": string;
"language": string; "language": string;
"nickname"?: string; "nickname"?: string;
"platform"?: string;
"region": string; "region": string;
} }
@@ -27,10 +29,12 @@ export interface LoginViaAppleResponse {
} }
export interface LoginViaCustomRequest { export interface LoginViaCustomRequest {
"appVersion"?: string;
"customData"?: { [key: string]: unknown }; "customData"?: { [key: string]: unknown };
"key"?: string; "key"?: string;
"language"?: string; "language"?: string;
"nickname"?: string; "nickname"?: string;
"platform"?: string;
"provider"?: string; "provider"?: string;
"region"?: string; "region"?: string;
} }
@@ -41,10 +45,12 @@ export interface LoginViaCustomResponse {
} }
export interface LoginViaDeviceRequest { export interface LoginViaDeviceRequest {
"appVersion"?: string;
"deviceId"?: string; "deviceId"?: string;
"key"?: string; "key"?: string;
"language"?: string; "language"?: string;
"nickname"?: string; "nickname"?: string;
"platform"?: string;
"region"?: string; "region"?: string;
} }
@@ -54,10 +60,12 @@ export interface LoginViaDeviceResponse {
} }
export interface LoginViaGoogleRequest { export interface LoginViaGoogleRequest {
"appVersion"?: string;
"idToken": string; "idToken": string;
"key": string; "key": string;
"language": string; "language": string;
"nickname"?: string; "nickname"?: string;
"platform"?: string;
"region": string; "region": string;
} }
@@ -67,6 +75,8 @@ export interface LoginViaGoogleResponse {
} }
export interface RefreshAccessTokenRequest { export interface RefreshAccessTokenRequest {
"appVersion"?: string;
"platform"?: string;
"refreshToken"?: string; "refreshToken"?: string;
} }
-63
View File
@@ -1,63 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
import type { Reward } from './common.js';
export interface AddBattlePassXpRequest {
"amount"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
"source"?: string;
}
export interface AddBattlePassXpResponse {
"level"?: number;
"leveledUp"?: boolean;
"maxLevel"?: boolean;
"xp"?: number;
}
export interface ClaimBattlePassRewardRequest {
"level"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
"track"?: "free" | "premium";
}
export interface ClaimBattlePassRewardResponse {
"alreadyClaimed"?: boolean;
"error"?: string;
"granted"?: Reward[];
"success"?: boolean;
}
export interface ClaimedTier {
"level"?: number;
"track"?: string;
}
export interface GetBattlePassProgressRequest {
"nodeId"?: string;
"scenarioSlug"?: string;
}
export interface GetBattlePassProgressResponse {
"claimedTiers"?: ClaimedTier[];
"level"?: number;
"premiumOwned"?: boolean;
"xp"?: number;
}
export interface PurchaseBattlePassPremiumRequest {
"idempotencyKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface PurchaseBattlePassPremiumResponse {
"error"?: string;
"success"?: boolean;
}
+5 -5
View File
@@ -1,13 +1,13 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
export interface CatalogItem { export interface CatalogCurrency {
"data"?: { [key: string]: unknown };
"icon"?: string;
"name"?: string; "name"?: string;
"properties"?: { [key: string]: unknown };
"slug"?: string; "slug"?: string;
"tags"?: string[];
} }
export interface ListCatalogItemsResponse { export interface ListCatalogCurrenciesResponse {
"items"?: CatalogItem[]; "currencies"?: CatalogCurrency[];
} }
+5 -7
View File
@@ -1,16 +1,14 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { ResourceRowUsage } from './resources.js';
export interface ErrorResponse { export interface ErrorResponse {
"code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run"; "code"?: "forbidden" | "row_referenced";
"error"?: string; "error"?: string;
"index"?: number; "index"?: number;
"path"?: string;
"playerId"?: string; "playerId"?: string;
"requestId"?: string; "requestId"?: string;
} "usages"?: ResourceRowUsage[];
export interface Reward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
} }
+2 -9
View File
@@ -1,15 +1,8 @@
// Code generated by apigen. DO NOT EDIT. // 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 type RudderErrorCode = "forbidden" | "row_referenced";
export const RudderErrorCodes = { export const RudderErrorCodes = {
earlyCompletion: "early_completion",
forbidden: "forbidden", forbidden: "forbidden",
levelNotReached: "level_not_reached", rowReferenced: "row_referenced",
nodeNotActive: "node_not_active",
objectivesIncomplete: "objectives_incomplete",
runExpired: "run_expired",
runNotActive: "run_not_active",
scenarioNotActive: "scenario_not_active",
unknownRun: "unknown_run",
} as const; } as const;
+2 -7
View File
@@ -1,17 +1,12 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
export * from './auth.js'; export * from './auth.js';
export * from './battlepass.js';
export * from './catalog.js'; export * from './catalog.js';
export * from './common.js'; export * from './common.js';
export * from './inventory.js'; export * from './modules.js';
export * from './leaderboards.js';
export * from './player.js'; export * from './player.js';
export * from './project-storage.js'; export * from './project-storage.js';
export * from './quests.js'; export * from './resources.js';
export * from './remote-config.js';
export * from './scenarios.js';
export * from './storage.js'; export * from './storage.js';
export * from './stores.js';
export * from './api.js'; export * from './api.js';
export * from './errors.js'; export * from './errors.js';
-14
View File
@@ -1,14 +0,0 @@
// 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;
}
-19
View File
@@ -1,19 +0,0 @@
// 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;
}
+17
View File
@@ -0,0 +1,17 @@
// Code generated by apigen. DO NOT EDIT.
export interface CallModuleFunctionClientResponse {
"result"?: unknown;
}
export interface CallModuleFunctionRequest {
"args"?: unknown;
"dryRun"?: boolean;
}
export interface ModuleErrorResponse {
"code": string;
"error": string;
"requestId"?: string;
}
-44
View File
@@ -1,44 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
import type { Reward } from './common.js';
export interface ClaimQuestRequest {
"questSlug"?: string;
}
export interface ClaimQuestResponse {
"alreadyClaimed"?: boolean;
"error"?: string;
"granted"?: Reward[];
"success"?: boolean;
}
export interface ListQuestsResponse {
"quests"?: Quest[];
}
export interface Quest {
"name"?: string;
"objectives"?: QuestObjectiveProgress[];
"rewards"?: Reward[];
"slug"?: string;
"status"?: "active" | "claimed" | "completed";
}
export interface QuestObjectiveProgress {
"completed"?: boolean;
"current"?: number;
"metric"?: string;
"objectiveId"?: string;
"target"?: number;
}
export interface ReportQuestProgressRequest {
"amount"?: number;
"metric"?: string;
}
export interface ReportQuestProgressResponse {
"completedQuestSlugs"?: string[];
}
-19
View File
@@ -1,19 +0,0 @@
// 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"?: "bool" | "float" | "int" | "json" | "string";
}
+34
View File
@@ -0,0 +1,34 @@
// Code generated by apigen. DO NOT EDIT.
export interface CreateResourceRowRequest {
"data"?: { [key: string]: unknown };
"id"?: string;
}
export interface ListResourceRowsResponse {
"items"?: ResourceRow[];
"total"?: number;
}
export interface ResourceRow {
"createdAt"?: string;
"data"?: { [key: string]: unknown };
"id"?: string;
"playerId"?: string;
"rank"?: number;
"updatedAt"?: string;
"version"?: number;
}
export interface ResourceRowUsage {
"definitionSlug"?: string;
"field"?: string;
"moduleSlug"?: string;
"rowId"?: string;
}
export interface UpdateResourceRowRequest {
"data"?: { [key: string]: unknown };
"version"?: number;
}
-47
View File
@@ -1,47 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
export interface HandleScenarioCallbackRequest {
"handle"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface HandleScenarioCallbackResponse {
"effect"?: PendingEffect;
}
export interface ListPendingScenarioEffectsResponse {
"effects": PendingEffect[];
}
export interface PendingEffect {
"data": { [key: string]: unknown };
"nodeId": string;
"runId": string;
"scenarioSlug": string;
"type": string;
"waitDeadline"?: string;
}
export interface TriggerScenarioRequest {
"event"?: string;
}
export interface TriggerScenarioResponse {
"effects": PendingEffect[];
}
export interface UpdateScenarioCounterRequest {
"amount"?: number;
"counterKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioSlug"?: string;
}
export interface UpdateScenarioCounterResponse {
"completed"?: boolean;
"effect"?: PendingEffect;
}
-55
View File
@@ -1,55 +0,0 @@
// 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;
"slug"?: string;
"updatedAt"?: string;
}
export interface OfferContent {
"amount"?: number;
"itemId"?: string;
}
export interface OfferPrice {
"amount"?: number;
"currency"?: string;
}
export interface PurchaseOfferRequest {
"idempotencyKey"?: string;
"offerSlug"?: 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;
}
+13 -49
View File
@@ -16,8 +16,7 @@ export type { RudderErrorCodeLike } from './client/RudderError.js';
export type { TokenStore } from './token/TokenStore.js'; export type { TokenStore } from './token/TokenStore.js';
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js'; export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
// Services — types only; instances live on the client // Services — types only; instances live on the client (client.auth).
// (client.auth, client.leaderboards, client.battlePass, client.quests).
export type { export type {
AuthService, AuthService,
AuthState, AuthState,
@@ -28,10 +27,6 @@ export type {
LoginWithDeviceOptions, LoginWithDeviceOptions,
LoginWithGoogleOptions, LoginWithGoogleOptions,
} from './auth/AuthService.js'; } 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';
export * as QuestMetrics from './quests/QuestMetrics.js';
// Observable state primitives // Observable state primitives
export { SyncedState } from './state/SyncedState.js'; export { SyncedState } from './state/SyncedState.js';
@@ -40,74 +35,43 @@ export type {
SyncedStatus, SyncedStatus,
SyncedListener, SyncedListener,
} from './state/SyncedState.js'; } 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 // Domains — types only; instances live on the client
// (client.player, client.stores, client.remoteConfig, …). // (client.player, client.modules, client.resources(slug), …).
export type { PlayerDomain } from './domains/PlayerDomain.js'; export type { PlayerDomain } from './domains/PlayerDomain.js';
export type { CatalogDomain } from './domains/CatalogDomain.js'; export type { CatalogDomain } from './domains/CatalogDomain.js';
export type { InventoryDomain } from './domains/InventoryDomain.js'; export type { ModulesDomain } from './domains/ModulesDomain.js';
export type { ConfigDomain } from './domains/ConfigDomain.js'; export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.js';
export type { StorageDomain } from './domains/StorageDomain.js'; export type { StorageDomain } from './domains/StorageDomain.js';
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js'; export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
export type { StoresDomain } from './domains/StoresDomain.js';
// Effects // Effects
export type { export type {
ConfigChangedEffect,
Effects, Effects,
EffectUnsubscribe, EffectUnsubscribe,
LeaderboardEffect,
NotificationEffect, NotificationEffect,
StoreOfferEffect,
WaitEffect, WaitEffect,
ScenarioCompletedEffect, ScenarioCompletedEffect,
ScenarioFailedEffect, ScenarioFailedEffect,
QuestEffect,
BattlePassEffect,
BattlePassLevelEffect,
} from './effects/EffectsCenter.js'; } from './effects/EffectsCenter.js';
// Generated wire types — the shapes returned by the observable state and // Generated wire types — the shapes returned by the observable state and
// handles, so consumers can name them instead of re-declaring mirrors. // handles, so consumers can name them instead of re-declaring mirrors.
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js'; export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
export type { CatalogItem } from './generated/catalog.js'; export type { CatalogCurrency } 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 { StorageItem, GetStorageResponse } from './generated/storage.js';
export type { export type {
ProjectStorageItem, ProjectStorageItem,
GetProjectStorageResponse, GetProjectStorageResponse,
ProjectStorageUpdateItem, ProjectStorageUpdateItem,
} from './generated/project-storage.js'; } from './generated/project-storage.js';
export type { RemoteConfig } from './generated/remote-config.js';
export type { Reward } from './generated/common.js';
export type { export type {
Quest, CallModuleFunctionRequest,
QuestObjectiveProgress, CallModuleFunctionClientResponse,
ClaimQuestResponse, } from './generated/modules.js';
} from './generated/quests.js';
export type { export type {
ClaimedTier, ResourceRow,
AddBattlePassXpRequest, ListResourceRowsResponse,
AddBattlePassXpResponse, CreateResourceRowRequest,
ClaimBattlePassRewardRequest, UpdateResourceRowRequest,
ClaimBattlePassRewardResponse, } from './generated/resources.js';
PurchaseBattlePassPremiumRequest,
PurchaseBattlePassPremiumResponse,
GetBattlePassProgressRequest,
GetBattlePassProgressResponse,
} from './generated/battlepass.js';
export type { RudderErrorCode } from './generated/errors.js';
export { RudderErrorCodes } from './generated/errors.js';
-52
View File
@@ -1,52 +0,0 @@
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;
}
}
-19
View File
@@ -1,19 +0,0 @@
/**
* QuestMetrics — builders for the quest metric strings known to the platform.
*
* Purchase metrics are reported automatically server-side by the shop
* purchase fan-out, so these helpers exist mainly to name the format
* instead of hardcoding it. Catalog counter slugs are reported via
* {@link QuestsService.reportProgress}. Custom free-text metrics no longer
* progress quests — they no-op at runtime.
*/
/** Metric for purchasing a store offer; auto-reported on purchase. */
export function purchaseOffer(offerSlug: string): string {
return `purchase.offer:${offerSlug}`;
}
/** Metric for purchasing a catalog item; auto-reported on purchase. */
export function purchaseItem(itemId: string): string {
return `purchase.item:${itemId}`;
}
-32
View File
@@ -1,32 +0,0 @@
/**
* 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(questSlug: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questSlug });
}
/** Reports metric progress; returns ids of quests completed by this report. */
async reportProgress(metric: string, amount: number): Promise<string[]> {
const response = await api.reportQuestProgress(this.ctx, { metric, amount });
return response.completedQuestSlugs ?? [];
}
}
-454
View File
@@ -1,454 +0,0 @@
import type { RudderContext } from '../core/context.js';
import type { ShopHandle, OfferHandle, BuyOptions } from '../state/shops.js';
import type { PurchaseOfferResponse } from '../generated/stores.js';
import { api } from '../generated/api.js';
import type { PendingEffect, TriggerScenarioResponse } from '../generated/scenarios.js';
import { RudderErrorCodes } from '../generated/errors.js';
import {
RudderNetworkError,
RudderHttpError,
} from '../client/RudderError.js';
import type { BattlePassService } from '../battlepass/BattlePassService.js';
export interface ScenarioDomains {
readonly player: { invalidate(): void };
readonly inventory: { invalidate(): void };
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
}
const DEFAULT_PENDING_INTERVAL_MS = 30_000;
const JITTER_RATIO = 0.2;
type ActiveRecord = {
runId: string;
nodeId: string;
scenarioSlug: string;
storePurchased?: boolean;
lastPurchase?: PurchaseOfferResponse;
};
function effectKey(runId: string, nodeId: string): string {
return `${runId}:${nodeId}`;
}
function isTransientHttpError(err: unknown): boolean {
if (err instanceof RudderNetworkError) return true;
if (err instanceof RudderHttpError && err.status >= 500) return true;
return false;
}
function isDroppedRunError(err: unknown): boolean {
if (!(err instanceof RudderHttpError)) return false;
return (
err.code === RudderErrorCodes.unknownRun ||
err.code === RudderErrorCodes.runExpired ||
err.code === RudderErrorCodes.runNotActive
);
}
function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function asObjectives(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === 'object' && !Array.isArray(item),
);
}
function asLevel(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
export class ScenarioService {
private readonly active = new Map<string, ActiveRecord>();
private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly droppedRuns = new Set<string>();
private readonly completedRuns = new Set<string>();
private heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
private running = false;
private polling = false;
private pollQueued = false;
constructor(
private readonly ctx: RudderContext,
private readonly domains: ScenarioDomains,
private readonly battlePass: BattlePassService,
private readonly intervalMs: number = DEFAULT_PENDING_INTERVAL_MS,
) {}
get isRunning(): boolean {
return this.active.size > 0;
}
async send(eventName: string): Promise<TriggerScenarioResponse> {
const response = await api.triggerScenario(this.ctx, { event: eventName });
this.domains.player.invalidate();
this.domains.inventory.invalidate();
this.ingest(response.effects ?? []);
return response;
}
async start(): Promise<void> {
if (this.running) return;
this.running = true;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
await this.pollPending();
this.scheduleHeartbeat();
}
clear(): void {
this.running = false;
this.pollQueued = false;
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
for (const timer of this.waitTimers.values()) {
clearTimeout(timer);
}
this.waitTimers.clear();
this.active.clear();
this.droppedRuns.clear();
this.completedRuns.clear();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
}
private readonly onVisibilityChange = (): void => {
if (this.running && typeof document !== 'undefined' && !document.hidden) {
void this.heartbeatTick();
}
};
private scheduleHeartbeat(): void {
if (!this.running) return;
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
this.heartbeatTimer = setTimeout(() => {
void this.heartbeatTick();
}, this.intervalMs * jitter);
}
private async heartbeatTick(): Promise<void> {
if (!this.running) return;
if (typeof document !== 'undefined' && document.hidden) {
this.scheduleHeartbeat();
return;
}
await this.pollPending();
this.scheduleHeartbeat();
}
private async pollPending(): Promise<void> {
if (this.polling) {
this.pollQueued = true;
return;
}
this.polling = true;
try {
do {
this.pollQueued = false;
try {
const response = await api.listPendingScenarioEffects(this.ctx);
this.reconcile(response.effects ?? []);
} catch {
}
} while (this.pollQueued);
} finally {
this.polling = false;
}
}
private reconcile(effects: PendingEffect[]): void {
const incoming = effects.filter((effect) => !this.droppedRuns.has(effect.runId));
const incomingKeys = new Set(
incoming.map((effect) => effectKey(effect.runId, effect.nodeId)),
);
const before = new Set(this.active.keys());
this.ingest(incoming);
for (const [key, record] of [...this.active.entries()]) {
if (incomingKeys.has(key)) continue;
this.deactivate(key);
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
const after = new Set(this.active.keys());
if (before.size !== after.size || [...before].some((key) => !after.has(key))) {
this.domains.player.invalidate();
this.domains.inventory.invalidate();
}
}
private ingest(effects: PendingEffect[]): void {
for (const effect of effects) {
this.ingestOne(effect);
}
}
private ingestOne(pending: PendingEffect): void {
if (this.droppedRuns.has(pending.runId)) return;
const key = effectKey(pending.runId, pending.nodeId);
if (this.active.has(key)) {
if (pending.waitDeadline && !this.waitTimers.has(key)) {
this.scheduleWait(key, pending.waitDeadline);
}
return;
}
const record: ActiveRecord = {
runId: pending.runId,
nodeId: pending.nodeId,
scenarioSlug: pending.scenarioSlug,
};
this.active.set(key, record);
this.completedRuns.delete(pending.runId);
if (pending.waitDeadline) {
this.scheduleWait(key, pending.waitDeadline);
}
this.dispatch(pending, record);
}
private dispatch(pending: PendingEffect, record: ActiveRecord): void {
switch (pending.type) {
case 'notification':
this.ctx.effects.emitNotification({
title: asString(pending.data.title),
message: asString(pending.data.message),
done: () => this.complete(record, 'output'),
});
break;
case 'store':
this.dispatchStore(pending, record);
break;
case 'wait':
this.ctx.effects.emitWait({
deadlineUtc: pending.waitDeadline
? new Date(pending.waitDeadline)
: new Date(),
});
break;
case 'leaderboard': {
const end = () => this.complete(record, 'onEnd');
const claim = () => this.complete(record, 'onClaim');
this.ctx.effects.emitLeaderboard({
end,
claim,
rewardClaimed: claim,
});
break;
}
case 'quest':
this.ctx.effects.emitQuest({
name: asString(pending.data.name),
objectives: asObjectives(pending.data.objectives),
reportProgress: (objectiveId, amount) =>
this.reportQuestProgress(record, objectiveId, amount),
});
break;
case 'battlepass':
this.dispatchBattlePass(record);
break;
case 'battlepass_level':
this.ctx.effects.emitBattlePassLevel({
level: asLevel(pending.data.levelNumber),
claim: () => this.complete(record, 'onComplete'),
});
break;
default:
console.warn(
`[Rudder] Unsupported scenario node type '${pending.type}' (${pending.nodeId})`,
);
this.dropRun(
record.runId,
record.nodeId,
new Error(`Unsupported scenario node type '${pending.type}'`),
);
break;
}
}
private dispatchStore(pending: PendingEffect, record: ActiveRecord): void {
const slug = asString(pending.data.storeSlug);
this.ctx.effects.emitStoreOffer(
this.domains.stores.getBySlug(slug).then((store) => ({
store,
offers: store.offers,
message: typeof pending.data.message === 'string' ? pending.data.message : undefined,
buy: (offer, options) => this.buyStore(record, offer, options),
dismiss: () => this.dismissStore(record),
})),
);
}
private dispatchBattlePass(record: ActiveRecord): void {
const { scenarioSlug, nodeId, runId } = record;
this.ctx.effects.emitBattlePass({
getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
addXp: (source, amount) =>
this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
claimReward: (level, track) =>
this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
purchasePremium: async () => {
const response = await this.battlePass.purchasePremium({
scenarioSlug,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
if (response.success) {
await this.complete(record, 'onPremiumPurchase');
}
return response;
},
levelUp: () => this.complete(record, 'onLevelUp'),
end: () => this.complete(record, 'onComplete'),
});
}
private async buyStore(
record: ActiveRecord,
offer: OfferHandle,
options?: BuyOptions,
): Promise<PurchaseOfferResponse> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) {
return { success: false, error: 'store session already resolved' };
}
if (!record.storePurchased) {
const purchase = await offer.buy(options);
if (!purchase.success) return purchase;
record.storePurchased = true;
record.lastPurchase = purchase;
}
await this.complete(record, 'onPurchase');
return record.lastPurchase ?? { success: true };
}
private async dismissStore(record: ActiveRecord): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (record.storePurchased) return;
await this.complete(record, 'onDecline');
}
private async reportQuestProgress(
record: ActiveRecord,
objectiveId: string,
amount = 1,
): Promise<void> {
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
counterKey: objectiveId,
amount,
runId: record.runId,
});
if (!response?.completed) return;
this.domains.player.invalidate();
this.domains.inventory.invalidate();
const key = effectKey(record.runId, record.nodeId);
if (this.active.has(key)) {
this.deactivate(key);
}
if (response.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch {
}
}
private async complete(record: ActiveRecord, handle: string): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (this.droppedRuns.has(record.runId)) return;
try {
const response = await api.handleScenarioCallback(this.ctx, {
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
handle,
runId: record.runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (!this.active.has(key)) return;
this.deactivate(key);
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch (err) {
if (isTransientHttpError(err)) return;
if (isDroppedRunError(err)) {
const error = err instanceof Error ? err : new Error(String(err));
this.dropRun(record.runId, record.nodeId, error);
throw err;
}
throw err;
}
}
private scheduleWait(key: string, waitDeadline: string): void {
const deadline = Date.parse(waitDeadline);
if (Number.isNaN(deadline)) return;
const remaining = Math.max(0, deadline - Date.now());
const existing = this.waitTimers.get(key);
if (existing) clearTimeout(existing);
this.waitTimers.set(
key,
setTimeout(() => {
this.waitTimers.delete(key);
void this.pollPending();
}, remaining),
);
}
private deactivate(key: string): void {
const timer = this.waitTimers.get(key);
if (timer) {
clearTimeout(timer);
this.waitTimers.delete(key);
}
this.active.delete(key);
}
private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId);
let scenarioSlug = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
this.deactivate(key);
}
console.warn(
`[Rudder] Scenario run ${runId} failed at node ${nodeId}: ${error.message}`,
);
this.ctx.effects.emitScenarioFailed({
runId,
scenarioSlug,
nodeId,
error,
});
}
private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
if (this.droppedRuns.has(runId)) return;
if (this.completedRuns.has(runId)) return;
for (const record of this.active.values()) {
if (record.runId === runId) return;
}
this.completedRuns.add(runId);
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
}
}
-54
View File
@@ -1,54 +0,0 @@
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);
}
}
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;
}
}
-29
View File
@@ -1,29 +0,0 @@
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 ?? [],
};
});
}
-77
View File
@@ -1,77 +0,0 @@
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,
offerSlug: string,
options?: BuyOptions,
) => Promise<PurchaseOfferResponse>;
export class OfferHandle {
public readonly id: string;
public readonly slug: string;
public readonly name?: string;
public readonly price?: OfferPrice;
public readonly contents: OfferContent[];
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');
}
if (!offer.slug) {
throw new Error('Rudder Stores: offer.slug is required');
}
this.id = offer.id;
this.slug = offer.slug;
this.name = offer.name;
this.price = offer.price;
this.contents = offer.contents ?? [];
this.maxPurchases = offer.maxPurchases;
}
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
return this.purchase(this.storeSlug, this.slug, 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),
);
}
}
+7 -1
View File
@@ -19,6 +19,8 @@ export interface RequestContext {
baseUrl: string; baseUrl: string;
tokenStore: TokenStore; tokenStore: TokenStore;
requestTimeoutMs?: number; requestTimeoutMs?: number;
/** Sent with `platform: 'web'` on token refresh; omitted when not set. */
appVersion?: string;
/** Single-flight refresh state shared by all requests of one client. */ /** Single-flight refresh state shared by all requests of one client. */
refreshState?: { pending?: Promise<boolean> }; refreshState?: { pending?: Promise<boolean> };
/** Called when the refresh flow fails — lets the client stop its runtime. */ /** Called when the refresh flow fails — lets the client stop its runtime. */
@@ -168,7 +170,11 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken } satisfies RefreshAccessTokenRequest), body: JSON.stringify({
refreshToken,
platform: 'web',
appVersion: ctx.appVersion,
} satisfies RefreshAccessTokenRequest),
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok) return false; if (!response.ok) return false;
+41 -115
View File
@@ -6,9 +6,30 @@ function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 }); return new Response(JSON.stringify(body), { status: 200 });
} }
function stubKernelFetch(
extra?: (url: string) => Response | undefined,
): ReturnType<typeof vi.fn> {
return vi.fn().mockImplementation((url: string) => {
const custom = extra?.(url);
if (custom) return Promise.resolve(custom);
if (String(url).includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
}));
}
if (String(url).includes('/sdk/v1/catalog')) {
return Promise.resolve(jsonResponse({ currencies: [] }));
}
if (String(url).includes('/sdk/v1/sync')) {
return Promise.resolve(jsonResponse({}));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
}
describe('AuthService', () => { describe('AuthService', () => {
beforeEach(() => { beforeEach(() => {
// Mock crypto.randomUUID for deterministic device ID.
vi.stubGlobal('crypto', { vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001', randomUUID: () => '00000000-0000-4000-a000-000000000001',
}); });
@@ -17,20 +38,11 @@ describe('AuthService', () => {
it('loginWithDevice sends correct request body', async () => { it('loginWithDevice sends correct request body', async () => {
const client = createTestClient(); const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => { const fetchMock = stubKernelFetch();
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); vi.stubGlobal('fetch', fetchMock);
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' }); const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
expect(fetchMock).toHaveBeenCalledTimes(9);
const [url, init] = fetchMock.mock.calls[0]; const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/sdk/v1/authorization/device'); expect(url).toContain('/sdk/v1/authorization/device');
const body = JSON.parse(init.body); const body = JSON.parse(init.body);
@@ -40,29 +52,24 @@ describe('AuthService', () => {
expect(body.language).toBe('en'); expect(body.language).toBe('en');
expect(response.accessToken).toBe('access-token-123'); expect(response.accessToken).toBe('access-token-123');
expect(response.refreshToken).toBe('refresh-token-456'); expect(response.refreshToken).toBe('refresh-token-456');
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([ const paths = fetchMock.mock.calls.map(([callUrl]) => new URL(String(callUrl)).pathname);
'/sdk/v1/authorization/device', expect(paths[0]).toBe('/sdk/v1/authorization/device');
'/sdk/v1/remote-configs', expect(paths).toContain('/sdk/v1/player/information');
'/sdk/v1/player/information', expect(paths).toContain('/sdk/v1/catalog');
'/sdk/v1/stores', expect(paths).toContain('/sdk/v1/sync');
'/sdk/v1/catalog', expect(paths).not.toContain('/sdk/v1/remote-configs');
'/sdk/v1/scenarios/pending', expect(paths).not.toContain('/sdk/v1/stores');
'/sdk/v1/scenarios/trigger', expect(paths).not.toContain('/sdk/v1/scenarios/pending');
'/sdk/v1/player/information', expect(paths).not.toContain('/sdk/v1/scenarios/trigger');
'/sdk/v1/sync',
]);
}); });
it('loginWithDevice saves tokens on success', async () => { it('loginWithDevice saves tokens on success', async () => {
const client = createTestClient(); const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { vi.stubGlobal('fetch', stubKernelFetch((url) => {
if (url.includes('/sdk/v1/remote-configs')) { if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({ configs: {} })); return jsonResponse({ accessToken: 'at', refreshToken: 'rt' });
} }
return Promise.resolve(jsonResponse({ return undefined;
accessToken: 'at',
refreshToken: 'rt',
}));
})); }));
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
@@ -94,23 +101,16 @@ describe('AuthService', () => {
it('loginWithDevice sends token in Authorization header after login', async () => { it('loginWithDevice sends token in Authorization header after login', async () => {
const client = createTestClient(); const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => { const fetchMock = stubKernelFetch((url) => {
if (url.includes('/sdk/v1/authorization/device')) { if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({ return jsonResponse({ accessToken: 'at2', refreshToken: 'rt2' });
accessToken: 'at2',
refreshToken: 'rt2',
}));
} }
if (url.includes('/sdk/v1/remote-configs')) { return undefined;
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
}); });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
// Subsequent authenticated calls should include the token.
const response = await client.player.reload(); const response = await client.player.reload();
expect(response).toEqual({ player: null, wallets: [] }); expect(response).toEqual({ player: null, wallets: [] });
const playerCall = fetchMock.mock.calls.find(([url]) => const playerCall = fetchMock.mock.calls.find(([url]) =>
@@ -120,71 +120,9 @@ describe('AuthService', () => {
expect(headers['Authorization']).toBe('Bearer at2'); 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 () => { it('loginWithDevice defaults to region global and language en', async () => {
const client = createTestClient(); const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => { const fetchMock = stubKernelFetch();
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
});
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
@@ -203,21 +141,9 @@ describe('auth state', () => {
localStorage.clear(); 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 () => { it('onAuthStateChange fires immediately, then on login and logout', async () => {
const client = createTestClient(); const client = createTestClient();
stubLoginFlow(); vi.stubGlobal('fetch', stubKernelFetch());
const states: string[] = []; const states: string[] = [];
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state)); const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
+28 -29
View File
@@ -14,20 +14,17 @@ describe('public API surface', () => {
}); });
it('keeps helpers, domain classes, and service constructors out of the barrel', () => { it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
// Device id helper — internal.
expect('getOrCreateDeviceId' in sdk).toBe(false); expect('getOrCreateDeviceId' in sdk).toBe(false);
// Domain classes — exported as types only.
for (const name of [ for (const name of [
'PlayerDomain', 'PlayerDomain',
'CatalogDomain', 'CatalogDomain',
'InventoryDomain',
'ConfigDomain',
'StorageDomain', 'StorageDomain',
'StoresDomain', 'ProjectStorageDomain',
'ModulesDomain',
'ResourcesDomain',
]) { ]) {
expect(name in sdk).toBe(false); expect(name in sdk).toBe(false);
} }
// Service constructors — exported as types only.
for (const name of [ for (const name of [
'AuthService', 'AuthService',
'LeaderboardsService', 'LeaderboardsService',
@@ -48,43 +45,45 @@ describe('public API surface', () => {
'RudderHttpError', 'RudderHttpError',
'RudderAuthError', 'RudderAuthError',
'SDK_ERROR_INVALID_OPTIONS', 'SDK_ERROR_INVALID_OPTIONS',
'RudderErrorCodes',
'createDefaultTokenStore', 'createDefaultTokenStore',
'createLocalStorageTokenStore', 'createLocalStorageTokenStore',
'SyncedState', 'SyncedState',
'RemoteConfigState',
'ShopHandle',
'OfferHandle',
]) { ]) {
expect(name in sdk, name).toBe(true); expect(name in sdk, name).toBe(true);
} }
expect('RemoteConfigState' in sdk).toBe(false);
expect('ShopHandle' in sdk).toBe(false);
expect('OfferHandle' in sdk).toBe(false);
expect('QuestMetrics' in sdk).toBe(false);
}); });
it('exposes effects without a public scenarios service on the client', () => { it('exposes kernel members and no module-specific namespaces on the client', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
runtime: { loginEvent: null },
}); });
expect(client.auth).toBeDefined();
expect(client.player).toBeDefined();
expect(client.catalog).toBeDefined();
expect(client.storage).toBeDefined();
expect(client.projectStorage).toBeDefined();
expect(client.modules).toBeDefined();
expect(typeof client.modules.call).toBe('function');
expect(typeof client.resources).toBe('function');
expect(client.effects).toBeDefined(); expect(client.effects).toBeDefined();
expect('battlePass' in client).toBe(false);
expect('quests' in client).toBe(false);
expect('leaderboards' in client).toBe(false);
expect('stores' in client).toBe(false);
expect('inventory' in client).toBe(false);
expect('remoteConfig' in client).toBe(false);
expect('scenarios' in client).toBe(false); expect('scenarios' in client).toBe(false);
}); expect('onBattlePass' in client.effects).toBe(false);
expect('onStoreOffer' in client.effects).toBe(false);
it('uses the canonical glossary property names on the client', () => { expect('onQuest' in client.effects).toBe(false);
const client = new RudderClient({ expect('onLeaderboard' in client.effects).toBe(false);
baseUrl: 'https://api.test.rudder.build', expect('onConfigChanged' in client.effects).toBe(false);
projectKey: 'test-project',
runtime: { 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);
}); });
}); });
+41 -57
View File
@@ -135,83 +135,67 @@ describe('transport error mapping', () => {
}); });
}); });
describe('lazy scenario runtime', () => { describe('modules and resources', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('does not create the scenario runtime until first use', async () => { it('modules.call posts to /sdk/v1/modules/:module/:fn', async () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
projectKey: 'proj_123', projectKey: 'proj_123',
runtime: { loginEvent: null }, tokenStore: createFakeTokenStore(),
}); });
const internals = client as unknown as { const fetchMock = vi.fn().mockResolvedValue(
scenarioRuntime: unknown; new Response(JSON.stringify({ result: { ok: true }, log: [] }), { status: 200 }),
ensureScenarioRuntime(): Promise<unknown>; );
}; vi.stubGlobal('fetch', fetchMock);
expect(internals.scenarioRuntime).toBeNull();
await internals.ensureScenarioRuntime(); const result = await client.modules.call('quests', 'list', { limit: 10 });
expect(internals.scenarioRuntime).not.toBeNull();
expect(result).toEqual({ result: { ok: true }, log: [] });
const [url, init] = fetchMock.mock.calls[0];
expect(new URL(String(url)).pathname).toBe('/sdk/v1/modules/quests/list');
expect(JSON.parse(String(init.body))).toEqual({ args: { limit: 10 } });
}); });
it('delivers effects to handlers subscribed before the runtime is ready', async () => { it('resources(slug) lists and mutates rows', async () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
projectKey: 'proj_123', projectKey: 'proj_123',
runtime: {}, tokenStore: createFakeTokenStore(),
}); });
const row = { id: 'row-1', data: { name: 'Sword' }, version: 1 };
const notifications: string[] = []; const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
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; const path = new URL(url).pathname;
if (path === '/sdk/v1/authorization/device') { const method = (init?.method ?? 'GET').toUpperCase();
return Promise.resolve(new Response( if (method === 'GET' && path === '/sdk/v1/resources/items') {
JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }), return Promise.resolve(new Response(JSON.stringify({ items: [row], total: 1 }), { status: 200 }));
{ status: 200 },
));
} }
if (path === '/sdk/v1/scenarios/trigger') { if (method === 'GET' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(JSON.stringify({ return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
effects: [{
runId: 'run-1',
scenarioSlug: 'scenario-1',
nodeId: 'start',
type: 'notification',
data: { message: 'Welcome!' },
}],
}), { status: 200 }));
} }
if (path === '/sdk/v1/scenarios/pending') { if (method === 'POST' && path === '/sdk/v1/resources/items') {
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 })); return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
} }
if (path === '/sdk/v1/remote-configs') { if (method === 'PATCH' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 })); return Promise.resolve(new Response(JSON.stringify({ ...row, version: 2 }), { status: 200 }));
} }
if (path === '/sdk/v1/stores') { if (method === 'DELETE' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 })); return Promise.resolve(new Response(null, { status: 204 }));
} }
if (path === '/sdk/v1/catalog') { return Promise.resolve(new Response('unhandled', { status: 500 }));
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 })); });
} vi.stubGlobal('fetch', fetchMock);
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(); const handle = client.resources('items');
await vi.waitFor(() => expect(notifications).toEqual(['Welcome!'])); expect(await handle.list()).toEqual({ items: [row], total: 1 });
client.dispose(); expect(await handle.get('row-1')).toEqual(row);
expect(await handle.create({ data: { name: 'Sword' } })).toEqual(row);
expect(await handle.update('row-1', { data: { name: 'Sword' }, version: 1 })).toEqual({
...row,
version: 2,
});
expect(await handle.delete('row-1')).toBeUndefined();
}); });
}); });
-416
View File
@@ -1,416 +0,0 @@
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 { effect } from './helpers/plan.js';
import { RudderHttpError } from '../src/client/RudderError.js';
import type { NotificationEffect, StoreOfferEffect, WaitEffect } from '../src/index.js';
import type { PendingEffect } from '../src/generated/scenarios.js';
async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-key',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
return { client, scenarios: await getScenarioRuntime(client) };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status });
}
function stubFetch(handler: (path: string, method: string, body: unknown) => Response | Promise<Response>): void {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const parsed = new URL(url);
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : undefined;
return Promise.resolve(handler(parsed.pathname, method, body));
}));
}
describe('ScenarioService', () => {
beforeEach(() => {
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('send (trigger)', () => {
it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
client.effects.onNotification(onNotification);
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({
effects: [effect('notification', { title: 'Hi', message: 'Welcome' })],
});
}
return jsonResponse({});
});
const response = await scenarios.send('level_complete');
expect(response.effects).toHaveLength(1);
expect(scenarios.isRunning).toBe(true);
expect(onNotification).toHaveBeenCalledOnce();
expect(onNotification.mock.calls[0][0].title).toBe('Hi');
expect(onNotification.mock.calls[0][0].message).toBe('Welcome');
});
});
describe('effect emission', () => {
it('notification node fires onNotification', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
client.effects.onNotification(onNotif);
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
});
it('store node fires onStoreOffer', async () => {
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
client.effects.onStoreOffer(onStore);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({
effects: [effect('store', { storeSlug: 'starter', message: 'Buy!' })],
});
}
if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] });
}
return jsonResponse({});
});
await scenarios.send('test');
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
const offer = onStore.mock.calls[0][0] as StoreOfferEffect;
expect(offer.message).toBe('Buy!');
expect(offer.store.slug).toBe('starter');
});
it('wait node fires onWait', async () => {
const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn();
client.effects.onWait(onWait);
const deadline = new Date(Date.now() + 60_000).toISOString();
stubFetch(() => jsonResponse({
effects: [effect('wait', {}, { waitDeadline: deadline })],
}));
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
expect(onWait.mock.calls[0][0].deadlineUtc).toBeInstanceOf(Date);
});
it('leaderboard node fires onLeaderboard', async () => {
const { client, scenarios } = await createClientWithScenario();
const onLb = vi.fn();
client.effects.onLeaderboard(onLb);
stubFetch(() => jsonResponse({ effects: [effect('leaderboard')] }));
await scenarios.send('test');
expect(onLb).toHaveBeenCalledOnce();
});
it('quest node dispatches onQuest', async () => {
const { client, scenarios } = await createClientWithScenario();
const onQuest = vi.fn();
client.effects.onQuest(onQuest);
stubFetch(() => jsonResponse({
effects: [effect('quest', { name: 'Daily', objectives: [{ objectiveId: 'kills', target: 10 }] })],
}));
await scenarios.send('test');
expect(onQuest).toHaveBeenCalledOnce();
expect(onQuest.mock.calls[0][0].name).toBe('Daily');
expect(scenarios.isRunning).toBe(true);
});
it('battlepass node dispatches onBattlePass', async () => {
const { client, scenarios } = await createClientWithScenario();
const onBp = vi.fn();
client.effects.onBattlePass(onBp);
stubFetch(() => jsonResponse({ effects: [effect('battlepass', { premiumPrice: 100 })] }));
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);
stubFetch(() => jsonResponse({
effects: [effect('battlepass_level', { levelNumber: 3 })],
}));
await scenarios.send('test');
expect(onLevel).toHaveBeenCalledOnce();
expect(onLevel.mock.calls[0][0].level).toBe(3);
});
it('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);
stubFetch(() => jsonResponse({ effects: [effect('unknown_type')] }));
await scenarios.send('test');
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Unsupported scenario node type'),
);
expect(onFailed).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(false);
warn.mockRestore();
});
});
describe('callback continuation', () => {
it('completing an effect posts callback and emits the next effect', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
client.effects.onNotification(onNotif);
const first = effect('notification', { message: 'one' }, { nodeId: 'n1' });
const second = effect('notification', { message: 'two' }, { nodeId: 'n2' });
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [first] });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ effect: second });
}
return jsonResponse({});
});
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
const session = onNotif.mock.calls[0][0] as NotificationEffect;
await session.done();
expect(onNotif).toHaveBeenCalledTimes(2);
expect(onNotif.mock.calls[1][0].message).toBe('two');
const fetchMock = vi.mocked(fetch);
const callbackCall = fetchMock.mock.calls.find((call) => {
const url = String(call[0]);
return url.includes('/sdk/v1/scenarios/callback');
});
expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({
scenarioSlug: 'scenario-1',
nodeId: 'n1',
handle: 'output',
runId: 'run-1',
});
});
it('completing the last effect emits onScenarioCompleted', async () => {
const { client, scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onNotif = vi.fn();
client.effects.onScenarioCompleted(onCompleted);
client.effects.onNotification(onNotif);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('notification')] });
}
return jsonResponse({});
});
await scenarios.send('test');
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
expect(onCompleted).toHaveBeenCalledOnce();
expect(onCompleted.mock.calls[0][0]).toEqual({
runId: 'run-1',
scenarioSlug: 'scenario-1',
});
expect(scenarios.isRunning).toBe(false);
});
});
describe('pending dedup', () => {
it('does not re-emit an effect with the same runId and nodeId', async () => {
const { client, scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
client.effects.onNotification(onNotification);
const pending = effect('notification', { message: 'once' });
stubFetch(() => jsonResponse({ effects: [pending] }));
await scenarios.send('first-event');
await scenarios.send('second-event');
expect(onNotification).toHaveBeenCalledTimes(1);
expect(scenarios.isRunning).toBe(true);
});
});
describe('waitDeadline timer', () => {
it('polls pending at waitDeadline and emits the next effect', async () => {
vi.useFakeTimers();
const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn();
const onNotif = vi.fn();
client.effects.onWait(onWait);
client.effects.onNotification(onNotif);
const deadline = new Date(Date.now() + 5_000).toISOString();
const wait = effect('wait', {}, { waitDeadline: deadline, nodeId: 'wait-1' });
const next = effect('notification', { message: 'after wait' }, { nodeId: 'n2' });
let pending: PendingEffect[] = [wait];
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [wait] });
}
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
return jsonResponse({ effects: pending });
}
return jsonResponse({});
});
await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce();
expect(onNotif).not.toHaveBeenCalled();
pending = [next];
await vi.advanceTimersByTimeAsync(5_000);
expect(onNotif).toHaveBeenCalledOnce();
expect(onNotif.mock.calls[0][0].message).toBe('after wait');
const session = onWait.mock.calls[0][0] as WaitEffect;
expect(session.deadlineUtc.toISOString()).toBe(deadline);
});
});
describe('expired-run drop', () => {
it('drops the run on unknown_run and does not retry it', async () => {
const { client, scenarios } = await createClientWithScenario();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const onFailed = vi.fn();
const onNotif = vi.fn();
client.effects.onScenarioFailed(onFailed);
client.effects.onNotification(onNotif);
const pending = effect('notification', { message: 'hello' });
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [pending] });
}
if (path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ code: 'unknown_run', error: 'gone' }, 410);
}
if (path === '/sdk/v1/scenarios/pending') {
return jsonResponse({ effects: [pending] });
}
return jsonResponse({});
});
await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce();
await expect((onNotif.mock.calls[0][0] as NotificationEffect).done())
.rejects.toBeInstanceOf(RudderHttpError);
expect(onFailed).toHaveBeenCalledOnce();
expect(onFailed.mock.calls[0][0].runId).toBe('run-1');
expect(scenarios.isRunning).toBe(false);
await scenarios.start();
expect(onNotif).toHaveBeenCalledTimes(1);
warn.mockRestore();
client.dispose();
});
it('leaves the effect active on a transient callback error', async () => {
const { client, scenarios } = await createClientWithScenario();
const onFailed = vi.fn();
const onNotif = vi.fn();
client.effects.onScenarioFailed(onFailed);
client.effects.onNotification(onNotif);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('notification')] });
}
if (path === '/sdk/v1/scenarios/callback') {
return jsonResponse({ error: 'boom' }, 500);
}
return jsonResponse({});
});
await scenarios.send('test');
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
expect(onFailed).not.toHaveBeenCalled();
expect(scenarios.isRunning).toBe(true);
});
});
describe('store session', () => {
it('buy() purchases the selected offer and completes with onPurchase', async () => {
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
client.effects.onStoreOffer(onStore);
stubFetch((path, method) => {
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
}
if (method === 'GET' && path === '/sdk/v1/stores/starter') {
return jsonResponse({
name: 'Starter',
slug: 'starter',
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
});
}
if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack-1/purchase') {
return jsonResponse({ success: true, purchaseId: 'purchase-1' });
}
return jsonResponse({});
});
await scenarios.send('test');
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
const session = onStore.mock.calls[0][0] as StoreOfferEffect;
const purchase = await session.buy(session.offers[0]);
expect(purchase.success).toBe(true);
const duplicate = await session.buy(session.offers[0]);
expect(duplicate.success).toBe(false);
});
it('dismiss() completes with onDecline', async () => {
const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn();
client.effects.onStoreOffer(onStore);
stubFetch((path) => {
if (path === '/sdk/v1/scenarios/trigger') {
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
}
if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1', slug: 'pack-1' }] });
}
return jsonResponse({});
});
await scenarios.send('test');
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
await (onStore.mock.calls[0][0] as StoreOfferEffect).dismiss();
const fetchMock = vi.mocked(fetch);
const callbackCall = fetchMock.mock.calls.find((call) =>
String(call[0]).includes('/sdk/v1/scenarios/callback'),
);
expect(JSON.parse(callbackCall![1]!.body as string).handle).toBe('onDecline');
});
});
describe('clear', () => {
it('clear removes all runs', async () => {
const { client, scenarios } = await createClientWithScenario();
client.effects.onNotification(vi.fn());
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
await scenarios.send('test');
expect(scenarios.isRunning).toBe(true);
scenarios.clear();
expect(scenarios.isRunning).toBe(false);
});
});
});
+7 -15
View File
@@ -2,16 +2,11 @@
// reach the admin API for wallet grants / player inspection. // reach the admin API for wallet grants / player inspection.
import { RudderClient } from '../../../src/client/RudderClient.js'; import { RudderClient } from '../../../src/client/RudderClient.js';
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
import type { TokenStore } from '../../../src/token/TokenStore.js'; import type { TokenStore } from '../../../src/token/TokenStore.js';
import { createAdminClient, type AdminClient } from './adminClient.js'; import { createAdminClient, type AdminClient } from './adminClient.js';
import { readArtifact } from './artifact.js'; import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js'; import type { SeedArtifact } from './types.js';
type RuntimeOptions = {
loginEvent?: string | null;
};
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */ /** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
export function loadArtifact(): SeedArtifact { export function loadArtifact(): SeedArtifact {
const file = readArtifact(); const file = readArtifact();
@@ -44,25 +39,22 @@ export function createInMemoryTokenStore(): TokenStore {
}; };
} }
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */ /** A client with no auth yet (in-memory tokens). */
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>( export function makeProdClient(
artifact: SeedArtifact = loadArtifact(), artifact: SeedArtifact = loadArtifact(),
runtime: RuntimeOptions = { loginEvent: null }, ): RudderClient {
): RudderClient<TConfig> { return new RudderClient({
return new RudderClient<TConfig>({
baseUrl: artifact.baseUrl, baseUrl: artifact.baseUrl,
projectKey: artifact.projectKey, projectKey: artifact.projectKey,
tokenStore: createInMemoryTokenStore(), tokenStore: createInMemoryTokenStore(),
runtime,
}); });
} }
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */ /** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>( export async function freshPlayer(
artifact: SeedArtifact = loadArtifact(), artifact: SeedArtifact = loadArtifact(),
runtime?: RuntimeOptions, ): Promise<RudderClient> {
): Promise<RudderClient<TConfig>> { const client = makeProdClient(artifact);
const client = makeProdClient<TConfig>(artifact, runtime);
await client.auth.loginWithDevice({ region: 'en', language: 'en' }); await client.auth.loginWithDevice({ region: 'en', language: 'en' });
return client; return client;
} }
+4 -288
View File
@@ -1,108 +1,20 @@
// Production seed: registers an operator, creates a project, configures staging // Production seed: registers an operator and creates a project. Creating a
// content (counter, item, remote configs, leaderboard, store with free+paid offers, a // project provisions staging + prod environments; the prod SDK key is what
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves. // the runtime SDK sends as projectKey.
//
// The scenario flow uses the server engine's node types, `data` keys and handle names —
// the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
// and .../fsm), so what we store is what the server runs.
import { createAdminClient, type AdminClient } from './adminClient.js'; import { createAdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.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;
slug: 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;
slug: string;
offers?: Array<{
id: string;
slug: 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( export async function runSeed(
baseUrl: string, baseUrl: string,
log: (m: string) => void = () => {}, log: (m: string) => void = () => {},
): Promise<SeedArtifact> { ): Promise<SeedArtifact> {
const api = createAdminClient(baseUrl); const api = createAdminClient(baseUrl);
const ts = Date.now(); const ts = Date.now();
const authoringEnv = 'staging';
const runtimeEnv = 'prod'; const runtimeEnv = 'prod';
const q = `?environment=${authoringEnv}`;
const prodQ = `?environment=${runtimeEnv}`;
const adminEmail = `e2e+${ts}@rudder.build`; const adminEmail = `e2e+${ts}@rudder.build`;
const password = `Passw0rd!e2e-${ts}`; 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 }>( const auth = await api.post<{ accessToken: string; refreshToken: string }>(
'/platform/v1/authorization/register', '/platform/v1/authorization/register',
{ email: adminEmail, password, fullName: 'E2E Bot' }, { email: adminEmail, password, fullName: 'E2E Bot' },
@@ -111,8 +23,6 @@ export async function runSeed(
api.setToken(auth.accessToken); api.setToken(auth.accessToken);
log(`registered ${adminEmail}`); log(`registered ${adminEmail}`);
// 2. Project. Creating one provisions the staging + prod environments, each with
// its own SDK key — that key is what the runtime SDK sends as projectKey.
const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', { const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
name: `e2e-${ts}`, name: `e2e-${ts}`,
}); });
@@ -126,168 +36,6 @@ export async function runSeed(
} }
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`); log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
// 3. Soft currency counter — offer prices are validated against counter slugs, and
// the purchase debit resolves the currency through the catalog counter cache.
const currency = 'soft';
await api.post(`${projPath}/counters${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Soft Currency',
slug: currency,
kind: 'currency',
});
// 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Gold Coin',
slug: 'gold-coin',
});
// 5. Remote configs — one per value type. RCs are live per-env flags served
// straight from the cache and are not copied by a promote, so they go directly
// into the runtime env.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ 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${prodQ}`, {
projectId: project.id,
environment: runtimeEnv,
...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.
// Stores and offers carry an authored slug that survives promotes.
const storeSlug = 'starter-shop';
const paidOfferSlug = 'gold-pack';
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: storeSlug,
name: 'Starter Shop',
description: 'E2E shop',
offers: [
{
slug: 'welcome-gift',
name: 'Welcome Gift',
contents: [{ itemId: item.id, amount: 10 }],
price: { currency, amount: 0 },
},
{
slug: paidOfferSlug,
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency, amount: 100 },
maxPurchases: 2,
},
],
});
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
// 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 questSlug = 'buy-gold-pack-quest';
const questMetric = `purchase.offer:${paidOfferSlug}`;
const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: questSlug,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
});
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 scenarioEvent = 'session_start';
const scenarioSlug = 'onboarding';
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: scenarioSlug,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
endAt,
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
projectId: project.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves the prod snapshot).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id,
fromEnvironment: authoringEnv,
toEnvironment: runtimeEnv,
});
await pollRelease(api, projPath, runtimeEnv, release.id, log);
// Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.slug === storeSlug);
if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
}
const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
if (!prodPaidOffer?.slug) {
throw new Error('promoted paid offer did not return a slug');
}
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
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((qq) => qq.slug === questSlug);
if (!prodQuest?.slug) {
throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`);
}
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) {
throw new Error(`promoted quest ${questSlug} did not return metric ${questObjectiveId}`);
}
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
if (!prodQuestRewardItemId) {
throw new Error(`promoted quest ${questSlug} did not return reward item id`);
}
const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
`${projPath}/scenarios${prodQ}`,
);
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
if (!prodScenario?.slug) {
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
}
return { return {
baseUrl, baseUrl,
projectId: project.id, projectId: project.id,
@@ -296,38 +44,6 @@ export async function runSeed(
adminEmail, adminEmail,
environment: runtimeEnv, environment: runtimeEnv,
seededAt: new Date(ts).toISOString(), seededAt: new Date(ts).toISOString(),
releaseId: release.id,
store: {
name: 'Starter Shop',
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferSlug: prodPaidOffer.slug,
paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
slug: prodQuest.slug,
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: { slug: prodScenario.slug, event: scenarioEvent },
}; };
} }
-33
View File
@@ -10,37 +10,4 @@ export interface SeedArtifact {
adminEmail: string; adminEmail: string;
environment: string; environment: string;
seededAt: string; seededAt: string;
releaseId: string;
store: {
name: string;
slug: string;
freeOfferName: string;
paidOfferName: string;
paidOfferSlug: string;
paidPrice: { currency: string; amount: number };
paidMaxPurchases: number;
grantedItemName: string;
paidGrantAmount: number;
};
quest: {
slug: 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: { slug: string; event: string };
} }
+1 -1
View File
@@ -20,6 +20,6 @@ describe('e2e-prod: health', () => {
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
const body = (await res.json()) as { status?: string; checks?: Record<string, string> }; const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
expect(body.status).toBe('ready'); expect(body.status).toBe('ready');
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' }); expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok' });
}); });
}); });
-30
View File
@@ -1,30 +0,0 @@
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);
});
});
-94
View File
@@ -1,94 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.js';
// 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 {
slug: 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 admin endpoint.
await grantCurrency(artifact, playerId, currency, 1000);
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>(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`,
);
const inv = details.inventory?.find((i) => i.slug === artifact.item.id);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
// 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');
});
});
-33
View File
@@ -1,33 +0,0 @@
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);
});
});
-60
View File
@@ -1,60 +0,0 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { grantCurrency, loadArtifact, makeProdClient } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.js';
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, {
loginEvent: artifact.scenario.event,
});
client.effects.onNotification((effect) => { notifications.push(effect); });
client.effects.onStoreOffer((effect) => {
storeOffer = effect;
});
const runtime = await getScenarioRuntime(client);
client.effects.onScenarioCompleted(() => {
runCompleted = true;
});
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
const playerId = (await client.player.reload()).player!.id!;
await grantCurrency(
artifact,
playerId,
artifact.store.paidPrice.currency,
artifact.store.paidPrice.amount,
);
// First notification dispatched.
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done();
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
await client.remoteConfig.reload();
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);
});
});
-37
View File
@@ -1,37 +0,0 @@
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();
});
});
-126
View File
@@ -1,126 +0,0 @@
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 { effect } from '../helpers/plan.js';
import type { StoreOfferEffect } from '../../src/index.js';
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
});
}
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', slug: 'pack-1', name: 'Starter Pack' }],
}],
});
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reward',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onPurchase', reward);
const messages: string[] = [];
const completed: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); });
client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioSlug); });
const token = (await client.auth.loginWithDevice()).accessToken;
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]);
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
expect(callback?.body).toEqual({
scenarioSlug: 'offer_flow',
nodeId: 'offer',
handle: 'onPurchase',
runId: 'offer_flow-run',
});
expect(callback?.authToken).toBe(token);
expect(messages).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('Maybe next time!');
expect(completed).toEqual([]);
});
it('decline posts callback and continues with the server-supplied next effect', async () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'consolation',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onDecline', consolation);
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss();
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(true);
expect(messages).toEqual(['Maybe next time!']);
});
it('a failing callback does not fail or crash the run', async () => {
const offerEffect = effect('store', { storeSlug: 'starter' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallbackError('offer', 'onPurchase', 500);
const failures: unknown[] = [];
let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client);
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onScenarioFailed((e) => { failures.push(e); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
expect(failures).toHaveLength(0);
expect(runtime.isRunning).toBe(true);
});
});
-173
View File
@@ -1,173 +0,0 @@
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 { effect } 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(),
});
}
function offerScenario(now: number) {
const waitIntro = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const waitReminder = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_reminder',
waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
});
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
return { waitIntro, offer, waitReminder, reminder, thanks };
}
describe('E2E: player offer journey', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
gateway = createFakeGateway({
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', slug: '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);
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();
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 () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
const notifications: NotificationEffect[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
await client.auth.loginWithDevice();
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
await offer!.dismiss();
expect(notifications).toHaveLength(0);
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 () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
let offer: StoreOfferEffect | undefined;
const notifications: NotificationEffect[] = [];
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
const accessToken = (await client.auth.loginWithDevice()).accessToken;
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
const selectedOffer = offer!.offers[0];
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
expect(purchase.success).toBe(true);
expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerSlug: 'pack-1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]);
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 () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.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);
});
});
-73
View File
@@ -1,73 +0,0 @@
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 { effect } from '../helpers/plan.js';
import type { StoreOfferEffect, WaitEffect } from '../../src/index.js';
const MINUTE = 60_000;
describe('E2E: pending effects resume after a new login', () => {
let gateway: FakeGateway;
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
}
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
gateway = createFakeGateway({
stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] }],
});
const now = Date.now();
const wait = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('session_start', wait);
gateway.onCallback('wait_intro', 'onComplete', offer);
gateway.install();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('a wait started before reload resumes from GET pending after login', async () => {
const client1 = makeClient();
await client1.auth.loginWithDevice();
const runtime1 = await getScenarioRuntime(client1);
await runtime1.send('session_start');
expect(runtime1.isRunning).toBe(true);
const client2 = makeClient();
const resumedWaits: WaitEffect[] = [];
let offer: StoreOfferEffect | undefined;
client2.effects.onWait((s) => { resumedWaits.push(s); });
client2.effects.onStoreOffer((s) => { offer = s; });
await client2.auth.loginWithDevice();
expect(resumedWaits).toHaveLength(1);
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
});
});
-78
View File
@@ -1,78 +0,0 @@
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 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: NonNullable<RemoteConfig['valueType']>, 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: { 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);
});
});
-3
View File
@@ -8,7 +8,6 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
}); });
} }
@@ -46,13 +45,11 @@ describe('E2E: player storage', () => {
{ id: 'b', type: 'prefs', 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 all = await client.storage.reload();
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs'); const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
expect(prefs).toHaveLength(1); expect(prefs).toHaveLength(1);
expect(prefs[0].id).toBe('b'); 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'); const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
expect(getReq?.query.get('limit')).toBe('100'); expect(getReq?.query.get('limit')).toBe('100');
}); });
+66 -145
View File
@@ -1,12 +1,9 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { PendingEffect } from '../../src/generated/scenarios.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js';
import type { Store } from '../../src/generated/stores.js';
import type { StorageItem } from '../../src/generated/storage.js'; import type { StorageItem } from '../../src/generated/storage.js';
import type { import type { ResourceRow } from '../../src/generated/resources.js';
ClaimQuestResponse, import type { CallModuleFunctionClientResponse } from '../../src/generated/modules.js';
ListQuestsResponse, import type { CatalogCurrency } from '../../src/generated/catalog.js';
} from '../../src/generated/quests.js'; import type { ProjectStorageItem } from '../../src/generated/project-storage.js';
export interface RecordedRequest { export interface RecordedRequest {
method: string; method: string;
@@ -17,28 +14,17 @@ export interface RecordedRequest {
} }
export interface FakeGatewayState { export interface FakeGatewayState {
scenarios: Map<string, PendingEffect[]>;
callbacks: Map<string, PendingEffect | null>;
callbackErrors: Map<string, { status: number; code?: string }>;
pendingEffects: PendingEffect[];
counterCompleted: boolean;
counterEffect?: PendingEffect;
remoteConfigs: Record<string, RemoteConfig>;
stores: Store[];
quests: ListQuestsResponse;
questClaims: Map<string, ClaimQuestResponse>;
storage: Map<string, StorageItem>; storage: Map<string, StorageItem>;
projectStorage: Map<string, ProjectStorageItem>;
currencies: CatalogCurrency[];
resources: Map<string, Map<string, ResourceRow>>;
moduleCalls: CallModuleFunctionClientResponse;
} }
export interface FakeGateway { export interface FakeGateway {
state: FakeGatewayState; state: FakeGatewayState;
recorded: RecordedRequest[]; recorded: RecordedRequest[];
purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
questClaims: Array<{ questSlug: string; authToken: string | null }>;
install(): void; install(): void;
onEvent(event: string, ...effects: PendingEffect[]): void;
onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
onCallbackError(nodeId: string, handle: string, status?: number, code?: string): void;
} }
function json(body: unknown, status = 200): Response { function json(body: unknown, status = 200): Response {
@@ -50,52 +36,16 @@ function json(body: unknown, status = 200): Response {
const noContent = (): Response => new Response(null, { status: 204 }); const noContent = (): Response => new Response(null, { status: 204 });
function replaceRun(pending: PendingEffect[], next: PendingEffect[]): PendingEffect[] {
const runIds = new Set(next.map((effect) => effect.runId));
return [...pending.filter((effect) => !runIds.has(effect.runId)), ...next];
}
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway { export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = { const state: FakeGatewayState = {
scenarios: seed?.scenarios ?? new Map(),
callbacks: seed?.callbacks ?? new Map(),
callbackErrors: seed?.callbackErrors ?? new Map(),
pendingEffects: seed?.pendingEffects ?? [],
counterCompleted: seed?.counterCompleted ?? false,
counterEffect: seed?.counterEffect,
remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] },
questClaims: seed?.questClaims ?? new Map(),
storage: seed?.storage ?? new Map(), storage: seed?.storage ?? new Map(),
projectStorage: seed?.projectStorage ?? new Map(),
currencies: seed?.currencies ?? [],
resources: seed?.resources ?? new Map(),
moduleCalls: seed?.moduleCalls ?? { result: {} },
}; };
const recorded: RecordedRequest[] = []; const recorded: RecordedRequest[] = [];
const purchases: FakeGateway['purchases'] = [];
const questClaims: FakeGateway['questClaims'] = [];
function advanceExpiredWaits(): PendingEffect[] {
const now = Date.now();
const next: PendingEffect[] = [];
for (const effect of state.pendingEffects) {
if (
effect.type === 'wait' &&
effect.waitDeadline &&
Date.parse(effect.waitDeadline) <= now
) {
const continued = state.callbacks.get(`${effect.nodeId}:onComplete`);
if (continued === undefined) {
next.push(effect);
} else if (continued !== null) {
next.push(continued);
}
} else {
next.push(effect);
}
}
state.pendingEffects = next;
return next;
}
async function handle(req: RecordedRequest): Promise<Response> { async function handle(req: RecordedRequest): Promise<Response> {
const { method, path, query, body } = req; const { method, path, query, body } = req;
@@ -117,85 +67,54 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
} }
if (method === 'GET' && path === '/sdk/v1/catalog') { if (method === 'GET' && path === '/sdk/v1/catalog') {
return json({ items: [] }); return json({ currencies: state.currencies });
} }
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') { const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/);
const event = (body as { event?: string }).event ?? ''; if (method === 'POST' && moduleMatch) {
const effects = state.scenarios.get(event) ?? []; return json(state.moduleCalls);
state.pendingEffects = replaceRun(state.pendingEffects, effects);
return json({ effects });
} }
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
return json({ effects: advanceExpiredWaits() }); const resourceItemMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)\/([^/]+)$/);
} const resourceListMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)$/);
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') { if (resourceItemMatch) {
const b = body as { nodeId?: string; handle?: string; runId?: string }; const slug = decodeURIComponent(resourceItemMatch[1]);
const key = `${b.nodeId}:${b.handle}`; const id = decodeURIComponent(resourceItemMatch[2]);
const err = state.callbackErrors.get(key); const rows = state.resources.get(slug) ?? new Map();
if (err) { if (method === 'GET') {
return json({ code: err.code ?? 'callback failed', error: 'callback failed' }, err.status); const row = rows.get(id);
return row ? json(row) : json({ error: 'not found' }, 404);
} }
const next = state.callbacks.get(key); if (method === 'PATCH') {
state.pendingEffects = state.pendingEffects.filter( const existing = rows.get(id);
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)), if (!existing) return json({ error: 'not found' }, 404);
); const next = { ...existing, ...(body as ResourceRow), id };
if (next) { rows.set(id, next);
state.pendingEffects = replaceRun(state.pendingEffects, [next]); state.resources.set(slug, rows);
return json({ effect: next }); return json(next);
} }
return json({}); if (method === 'DELETE') {
} rows.delete(id);
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') { state.resources.set(slug, rows);
if (state.counterCompleted && state.counterEffect) { return noContent();
state.pendingEffects = replaceRun(state.pendingEffects, [state.counterEffect]);
} else if (state.counterCompleted) {
const b = body as { nodeId?: string; runId?: string };
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
} }
return json({ completed: state.counterCompleted, effect: state.counterEffect });
} }
if (resourceListMatch) {
if (method === 'GET' && path === '/sdk/v1/remote-configs') { const slug = decodeURIComponent(resourceListMatch[1]);
return json({ configs: state.remoteConfigs }); const rows = state.resources.get(slug) ?? new Map();
} if (method === 'GET') {
if (method === 'GET' && path.startsWith('/sdk/v1/remote-configs/')) { return json({ items: [...rows.values()], total: rows.size });
const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length)); }
const cfg = state.remoteConfigs[key]; if (method === 'POST') {
return cfg ? json(cfg) : json({ error: 'not found' }, 404); const created: ResourceRow = {
} id: (body as ResourceRow).id ?? `row-${rows.size + 1}`,
data: (body as ResourceRow).data,
if (method === 'GET' && path === '/sdk/v1/stores') { version: 1,
return json({ stores: state.stores, total: state.stores.length }); };
} rows.set(created.id!, created);
const purchaseMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)\/offers\/([^/]+)\/purchase$/); state.resources.set(slug, rows);
if (method === 'POST' && purchaseMatch) { return json(created);
const b = body as { idempotencyKey?: string }; }
purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]),
offerSlug: 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);
}
if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests);
}
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questSlug?: string };
const questSlug = b.questSlug ?? '';
questClaims.push({ questSlug, authToken: req.authToken });
return json(state.questClaims.get(questSlug) ?? { success: false, error: 'not found' });
} }
if (path === '/sdk/v1/storage') { if (path === '/sdk/v1/storage') {
@@ -222,6 +141,19 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
} }
} }
if (path === '/sdk/v1/project-storage') {
if (method === 'GET') {
return json({ items: [...state.projectStorage.values()], nextCursor: '' });
}
if (method === 'PUT') {
const items = (body as { items?: ProjectStorageItem[] }).items ?? [];
for (const item of items) {
if (item.id) state.projectStorage.set(item.id, item);
}
return noContent();
}
}
return json({ error: `unhandled route: ${method} ${path}` }, 500); return json({ error: `unhandled route: ${method} ${path}` }, 500);
} }
@@ -248,17 +180,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return { return {
state, state,
recorded, recorded,
purchases,
questClaims,
install, install,
onEvent(event, ...effects) {
state.scenarios.set(event, effects);
},
onCallback(nodeId, handle, next) {
state.callbacks.set(`${nodeId}:${handle}`, next);
},
onCallbackError(nodeId, handle, status = 500, code) {
state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
},
}; };
} }
-15
View File
@@ -1,15 +0,0 @@
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}`;
},
},
});
}
-16
View File
@@ -1,16 +0,0 @@
import type { PendingEffect } from '../../src/generated/scenarios.js';
export function effect(
type: string,
data: Record<string, unknown> = {},
overrides: Partial<PendingEffect> = {},
): PendingEffect {
return {
runId: 'run-1',
scenarioSlug: 'scenario-1',
nodeId: `${type}-1`,
type,
data,
...overrides,
};
}
+1 -2
View File
@@ -6,8 +6,7 @@ export default defineConfig({
target: 'es2022', target: 'es2022',
dts: true, dts: true,
clean: true, clean: true,
// Code-split the lazily-imported scenario client out of the initial chunk. splitting: false,
splitting: true,
treeshake: true, treeshake: true,
platform: 'browser', platform: 'browser',
outDir: 'dist', outDir: 'dist',