feat: modules and resources domains, drop removed domains, regenerated models
This commit is contained in:
+36
-28
@@ -1,38 +1,46 @@
|
||||
# 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
|
||||
|
||||
Breaking change — major bump, required by the backend environments release.
|
||||
Every project now has exactly two environments, `staging` and `prod`, and the
|
||||
SDK key passed as `projectKey` decides which one the player belongs to. The
|
||||
environment never appears in the client API: it is resolved at login and
|
||||
carried inside the access and refresh tokens. Tokens issued before this
|
||||
release have no environment claim and are rejected with 401, so the first call
|
||||
after the backend upgrade refreshes, fails, clears the token store and emits
|
||||
Breaking change — kernel surface. Module-specific client methods are removed.
|
||||
The SDK now exposes the kernel only: auth, player (including wallets), catalog
|
||||
currencies, storage, project storage, revision sync,
|
||||
`client.modules.call(module, fn, args)`, and `client.resources(slug)`.
|
||||
|
||||
### Removed
|
||||
|
||||
- `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
|
||||
with `client.auth.loginWithDevice()` / `loginWithCustom()`.
|
||||
|
||||
Quests, scenarios and offers are addressed by their slug instead of their id,
|
||||
because ids differ between staging and prod while slugs are stable. Renamed
|
||||
accordingly: `Quest.id` is now `Quest.slug`, `client.quests.claim()` takes a
|
||||
quest slug, `reportProgress()` resolves to the slugs of the completed quests
|
||||
(`ReportQuestProgressResponse.completedQuestSlugs`),
|
||||
`client.stores.purchase(storeSlug, offerSlug, options?)` takes an offer slug
|
||||
and `OfferHandle` carries a `slug` alongside its `id`,
|
||||
`QuestMetrics.purchaseOffer(offerSlug)` builds its metric from the offer slug,
|
||||
and every scenario-scoped type exposes `scenarioSlug` instead of `scenarioId` —
|
||||
`PendingEffect`, `ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle
|
||||
pass requests and `client.battlePass.getProgress(scenarioSlug, nodeId)`.
|
||||
`offer.buy()` is unchanged; it now binds the slug for you. Leaderboards, items
|
||||
and stores already used slugs and are untouched.
|
||||
|
||||
Also shipped here, previously committed but never published: the effects client
|
||||
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run
|
||||
that disappears server-side (finished elsewhere, expired after a promote) now
|
||||
emits `onScenarioCompleted` instead of lingering; and `run_not_active` joins
|
||||
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
|
||||
emits `onScenarioFailed`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Scenario execution moved server-side. The SDK no longer walks a local DAG
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# @rudder/js-sdk
|
||||
|
||||
LiveOps Web SDK for browser games — device auth, observable player state,
|
||||
stores, remote config, leaderboards, quests, battle pass, and a scenario runtime
|
||||
surfaced as typed effects.
|
||||
catalog currencies, storage, generic resources, and module function calls.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -29,22 +28,18 @@ const client = new RudderClient({
|
||||
projectKey: 'your-project-key',
|
||||
// tokenStore is optional: defaults to localStorage with an in-memory
|
||||
// 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'
|
||||
// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' });
|
||||
|
||||
// Observable domains — read `.data`, subscribe with `onChange`, force `reload()`.
|
||||
const profile = client.player.data;
|
||||
const speed = client.remoteConfig.get('player_speed', 200);
|
||||
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();
|
||||
```
|
||||
|
||||
@@ -54,75 +49,15 @@ client.dispose();
|
||||
|---|---|
|
||||
| Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
|
||||
| Player / wallets | `client.player` (observable) |
|
||||
| Inventory | `client.inventory` (observable, catalog-merged) |
|
||||
| Catalog | `client.catalog` (observable) |
|
||||
| Stores | `client.stores` (observable) + `client.stores.purchase(slug, offerId)` |
|
||||
| Remote config | `client.remoteConfig.get(key, default)` (observable, typed) |
|
||||
| Catalog currencies | `client.catalog` (observable) |
|
||||
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
|
||||
| Leaderboards | `client.leaderboards.findBySlug(slug)` → `handle.submit(score)` / `handle.list(limit?)` |
|
||||
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) |
|
||||
| Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` |
|
||||
| Scenario effects | `client.effects.on*` |
|
||||
| Project storage | `client.projectStorage` (observable) + `.save(items)` |
|
||||
| Modules | `client.modules.call(module, fn, args?)` |
|
||||
| Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` |
|
||||
|
||||
Type the remote config for `client.remoteConfig`:
|
||||
|
||||
```ts
|
||||
interface GameConfig extends Record<string, unknown> {
|
||||
player_speed: number;
|
||||
feature_x: boolean;
|
||||
}
|
||||
const client = new RudderClient<GameConfig>({ /* … */ });
|
||||
client.remoteConfig.get('player_speed', 200); // number
|
||||
```
|
||||
|
||||
## 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`).
|
||||
Game-design features (quests, stores, inventory, leaderboards, battle pass,
|
||||
remote config) live in installed modules. Call them through
|
||||
`client.modules.call` and read their rows through `client.resources(slug)`.
|
||||
|
||||
## Error handling
|
||||
|
||||
@@ -135,7 +70,6 @@ import {
|
||||
RudderNetworkError,
|
||||
RudderHttpError,
|
||||
RudderAuthError,
|
||||
RudderErrorCodes,
|
||||
} from '@rudder/js-sdk';
|
||||
|
||||
try {
|
||||
@@ -146,9 +80,6 @@ try {
|
||||
// cleared tokens and emitted 'signed-out' via onAuthStateChange.
|
||||
showLogin();
|
||||
} else if (error instanceof RudderHttpError) {
|
||||
if (error.code === RudderErrorCodes.runExpired) {
|
||||
// typed server error code
|
||||
}
|
||||
console.error(error.status, error.code, error.body);
|
||||
} else if (error instanceof RudderNetworkError) {
|
||||
// fetch failed / timeout — the underlying error is in error.cause
|
||||
@@ -171,15 +102,14 @@ client.auth.onAuthStateChange((state) => {
|
||||
|
||||
## Data liveness model
|
||||
|
||||
Observable domains (`player`, `inventory`, `catalog`, `stores`, `remoteConfig`,
|
||||
`storage`) are warmed once at login and then kept fresh by a revision poll:
|
||||
the client asks the gateway for entity revisions every **30 s (±20% jitter)**
|
||||
and reloads only the entities whose revision grew. Polling **pauses while the
|
||||
tab is hidden** and resumes on `visibilitychange`. Mutations (purchases,
|
||||
storage writes, scenario callbacks) invalidate the affected domains
|
||||
immediately, so subscribers see fresh data without waiting for the next poll.
|
||||
Expect data to be eventually consistent within one poll interval; call
|
||||
`reload()` when you need a guarantee.
|
||||
Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are
|
||||
warmed at login (`player` and `catalog`) or on first use, then kept fresh by
|
||||
a revision poll: the client asks the gateway for entity revisions every
|
||||
**30 s (±20% jitter)** and reloads only the entities whose revision grew.
|
||||
Polling **pauses while the tab is hidden** and resumes on `visibilitychange`.
|
||||
Storage writes invalidate the affected domain immediately. Expect data to be
|
||||
eventually consistent within one poll interval; call `reload()` when you need
|
||||
a guarantee.
|
||||
|
||||
## React recipe
|
||||
|
||||
@@ -202,9 +132,6 @@ function Wallet() {
|
||||
}
|
||||
```
|
||||
|
||||
The same pattern works for `client.remoteConfig`, `client.stores`, and
|
||||
`client.auth.onAuthStateChange`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"publishConfig": {
|
||||
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
|
||||
},
|
||||
"description": "LiveOps Web SDK for browser games — player authentication, leaderboards, stores, remote config, and typed runtime effects",
|
||||
"description": "LiveOps Web SDK for browser games — player authentication, catalog currencies, storage, generic resources, and module function calls",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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)
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
|
||||
// onEffectError?: (error) => void — default console.error
|
||||
// runtime?: { loginEvent? } — advanced, see reference/scenarios.md
|
||||
});
|
||||
```
|
||||
|
||||
`client` is generic: `RudderClient<TConfig extends Record<string, unknown>>`
|
||||
types `client.remoteConfig.get()`. Missing `baseUrl`/`projectKey` throws
|
||||
`RudderError` with `code: 'sdk/invalid-options'` from the constructor.
|
||||
Missing `baseUrl`/`projectKey` throws `RudderError` with
|
||||
`code: 'sdk/invalid-options'` from the constructor.
|
||||
|
||||
Call `client.dispose()` on unmount/HMR to stop the sync poll, wait timers, and
|
||||
drop cached state.
|
||||
Call `client.dispose()` on unmount/HMR to stop the sync poll and drop cached
|
||||
state.
|
||||
|
||||
## 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.loginWithApple({ idToken, 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.linkWithApple(idToken);
|
||||
await client.auth.linkWithCustom({ customData, provider? });
|
||||
await client.auth.unlinkIdentity(provider); // 'device' | 'google' | 'apple' | 'custom' | 'custom:<name>'
|
||||
client.auth.logout();
|
||||
client.auth.isAuthenticated; // boolean
|
||||
client.auth.onAuthStateChange(cb); // fires immediately, returns unsubscribe
|
||||
client.auth.isAuthenticated;
|
||||
client.auth.onAuthStateChange(cb);
|
||||
```
|
||||
|
||||
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:
|
||||
[reference/auth.md](reference/auth.md).
|
||||
|
||||
## Client surface (all domains that exist)
|
||||
|
||||
Every member below is a property on `RudderClient`. There is no UGC, wallet
|
||||
service, or standalone economy service in this SDK — wallets live on
|
||||
`client.player.data.wallets`, and purchases go through `client.stores`.
|
||||
## Client surface
|
||||
|
||||
| Area | Access | Kind |
|
||||
|---|---|---|
|
||||
| Auth | `client.auth` | service |
|
||||
| Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
|
||||
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` |
|
||||
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` |
|
||||
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerSlug, opts?)` | observable `SyncedState<ShopHandle[]>` |
|
||||
| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
|
||||
| Catalog currencies | `client.catalog` | observable `SyncedState<Map<string, CatalogCurrency>>` |
|
||||
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
|
||||
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation |
|
||||
| Leaderboards | `client.leaderboards.findBySlug(slug)` | service → cached handle |
|
||||
| Battle pass | `client.battlePass` | plain service (no live sync) |
|
||||
| Quests | `client.quests` | plain service (no live sync) |
|
||||
| Scenario effects | `client.effects.on*` | event subscriptions |
|
||||
| Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionResponse` |
|
||||
| Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` |
|
||||
|
||||
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
|
||||
|
||||
`player`, `catalog`, `inventory`, `remoteConfig`, `storage`,
|
||||
`projectStorage`, and `stores` all extend `SyncedState<T>`:
|
||||
`player`, `catalog`, `storage`, and `projectStorage` all extend
|
||||
`SyncedState<T>`:
|
||||
|
||||
- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | '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
|
||||
- `reload(): Promise<T>` — forced refetch
|
||||
|
||||
`remoteConfig`, `player`, `stores`, and `catalog` are warmed once at login;
|
||||
`inventory`, `storage`, and `projectStorage` load on first use. All observable
|
||||
domains are then kept fresh by a revision poll every 30 s (±20% jitter);
|
||||
polling pauses while the tab is hidden. Mutations (purchases, storage writes,
|
||||
scenario callbacks) invalidate affected domains immediately. Call `reload()`
|
||||
`player` and `catalog` are warmed once at login; `storage` and
|
||||
`projectStorage` load on first use. All observable domains are then kept fresh
|
||||
by a revision poll every 30 s (±20% jitter); polling pauses while the tab is
|
||||
hidden. Storage mutations invalidate their domain immediately. Call `reload()`
|
||||
when you need a freshness guarantee.
|
||||
|
||||
`leaderboards`, `battlePass`, and `quests` are NOT observable — refetch
|
||||
explicitly after mutations.
|
||||
|
||||
## Errors
|
||||
|
||||
All SDK errors extend `RudderError` (optional machine-readable `code`):
|
||||
@@ -126,21 +121,11 @@ All SDK errors extend `RudderError` (optional machine-readable `code`):
|
||||
- `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`)
|
||||
— constructor validation
|
||||
|
||||
Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`):
|
||||
`early_completion`, `forbidden`, `level_not_reached`, `node_not_active`,
|
||||
`objectives_incomplete`, `run_expired`, `run_not_active`,
|
||||
`scenario_not_active`, `unknown_run`. Compare with
|
||||
`error.code === RudderErrorCodes.runExpired`.
|
||||
Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`) are
|
||||
compared with `error.code === RudderErrorCodes.forbidden`.
|
||||
|
||||
## Reference files
|
||||
|
||||
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
|
||||
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
|
||||
- [reference/inventory.md](reference/inventory.md) — inventory domain, merged `InventoryItem`, catalog
|
||||
- [reference/stores.md](reference/stores.md) — `ShopHandle`/`OfferHandle`, purchases, idempotency
|
||||
- [reference/remote-config.md](reference/remote-config.md) — typed `get()`, value types, overrides
|
||||
- [reference/storage.md](reference/storage.md) — player + project storage, limits
|
||||
- [reference/leaderboards.md](reference/leaderboards.md) — handles, submit, list
|
||||
- [reference/quests.md](reference/quests.md) — list/claim/reportProgress, `QuestMetrics`
|
||||
- [reference/battlepass.md](reference/battlepass.md) — progress, XP, claims, premium
|
||||
- [reference/scenarios.md](reference/scenarios.md) — effects, scenario runtime, wait/quest/battlepass nodes
|
||||
|
||||
@@ -70,9 +70,9 @@ interface LoginViaAppleResponse { accessToken?: string; refreshToken?: string }
|
||||
interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string }
|
||||
```
|
||||
|
||||
On success both tokens are saved to the client's `TokenStore`, the runtime
|
||||
starts (domains warmed, scenario engine restored, `player_login` event fired),
|
||||
and the state flips to `'signed-in'`.
|
||||
On success both tokens are saved to the client's `TokenStore`, player and
|
||||
catalog are warmed, the revision poll starts, and the state flips to
|
||||
`'signed-in'`.
|
||||
|
||||
## Identity linking
|
||||
|
||||
@@ -127,8 +127,8 @@ type AuthStateListener = (state: AuthState) => void;
|
||||
- `isAuthenticated` is `true` while an access token is present in the token store.
|
||||
- `onAuthStateChange` fires the listener **immediately** with the current state
|
||||
and returns an unsubscribe function.
|
||||
- `logout()` clears tokens, stops the runtime (sync poll, scenario runs,
|
||||
cached domain data), and emits `'signed-out'`.
|
||||
- `logout()` clears tokens, stops the revision poll, drops cached domain
|
||||
data, and emits `'signed-out'`.
|
||||
|
||||
## 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`.
|
||||
@@ -4,8 +4,8 @@
|
||||
profile: identity plus currency wallets. Synced under revision key `profile`.
|
||||
|
||||
There is no separate wallet service in this SDK. Wallet balances are read from
|
||||
the profile and are credited/debited server-side (purchases, quest/battle pass
|
||||
rewards, scenario nodes).
|
||||
the profile and are credited/debited server-side (admin wallet adjust, module
|
||||
functions).
|
||||
|
||||
## Surface
|
||||
|
||||
@@ -51,10 +51,9 @@ interface Wallet {
|
||||
|
||||
## Behavior notes
|
||||
|
||||
- Warmed automatically at login (one of the four parallel warm loads).
|
||||
- Invalidated (refetched) after every successful store purchase and after
|
||||
scenario server callbacks — subscribers see fresh balances without waiting
|
||||
for the revision poll.
|
||||
- Warmed automatically at login (with catalog).
|
||||
- Kept fresh by the revision poll; call `reload()` after a module function
|
||||
that mutates wallets.
|
||||
- All fields are optional on the wire (`?`); code defensively.
|
||||
- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK
|
||||
README "React recipe").
|
||||
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
|
||||
import { getOrCreateDeviceId } from '../device/DeviceId.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type {
|
||||
@@ -72,7 +71,7 @@ export interface LinkWithCustomOptions {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
|
||||
export class AuthService {
|
||||
private readonly listeners = new Set<AuthStateListener>();
|
||||
private state: AuthState;
|
||||
|
||||
@@ -118,6 +117,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
region,
|
||||
language,
|
||||
nickname,
|
||||
platform: 'web',
|
||||
appVersion: this.ctx.options.appVersion,
|
||||
});
|
||||
|
||||
this.ctx.options.tokenStore.saveTokens(
|
||||
@@ -145,6 +146,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
region,
|
||||
language,
|
||||
nickname,
|
||||
platform: 'web',
|
||||
appVersion: this.ctx.options.appVersion,
|
||||
});
|
||||
|
||||
this.ctx.options.tokenStore.saveTokens(
|
||||
@@ -169,6 +172,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
region,
|
||||
language,
|
||||
nickname,
|
||||
platform: 'web',
|
||||
appVersion: this.ctx.options.appVersion,
|
||||
});
|
||||
|
||||
this.ctx.options.tokenStore.saveTokens(
|
||||
@@ -193,6 +198,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
region,
|
||||
language,
|
||||
nickname,
|
||||
platform: 'web',
|
||||
appVersion: this.ctx.options.appVersion,
|
||||
});
|
||||
|
||||
this.ctx.options.tokenStore.saveTokens(
|
||||
|
||||
@@ -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
@@ -1,16 +1,12 @@
|
||||
/**
|
||||
* RudderClient — the main entry point for the LiveOps Web SDK.
|
||||
*
|
||||
* Composes the observable domains, the scenario runtime, and the core HTTP
|
||||
* transport that every service uses to reach the LiveOps API gateway.
|
||||
* Composes the kernel domains (auth, player, catalog, storage, modules,
|
||||
* resources) and the HTTP transport every service uses to reach the API.
|
||||
*
|
||||
* The client builds a {@link RudderContext} first and injects it into every
|
||||
* domain and service, so they depend on that narrow surface rather than on the
|
||||
* client (which keeps the module graph acyclic). Cross-domain wiring (the
|
||||
* inventory↔catalog link, purchase invalidation) is done here at construction.
|
||||
*
|
||||
* The scenario effects client is loaded lazily on first use — a client that
|
||||
* never logs in never pays for the scenario machinery.
|
||||
* domain, so they depend on that narrow surface rather than on the client
|
||||
* (which keeps the module graph acyclic).
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -22,45 +18,32 @@ import type { RudderContext } from '../core/context.js';
|
||||
import { request, type RequestContext } from '../transport/request.js';
|
||||
import { createDefaultTokenStore } from '../token/TokenStore.js';
|
||||
import { AuthService } from '../auth/AuthService.js';
|
||||
import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
|
||||
import { BattlePassService } from '../battlepass/BattlePassService.js';
|
||||
import { QuestsService } from '../quests/QuestsService.js';
|
||||
import type { ScenarioService } from '../scenario/ScenarioService.js';
|
||||
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
|
||||
import { SyncEngine } from '../state/SyncEngine.js';
|
||||
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
|
||||
import { PlayerDomain } from '../domains/PlayerDomain.js';
|
||||
import { CatalogDomain } from '../domains/CatalogDomain.js';
|
||||
import { InventoryDomain } from '../domains/InventoryDomain.js';
|
||||
import { ConfigDomain } from '../domains/ConfigDomain.js';
|
||||
import { StorageDomain } from '../domains/StorageDomain.js';
|
||||
import { 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 };
|
||||
|
||||
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
|
||||
export class RudderClient {
|
||||
public readonly options: ResolvedRudderClientOptions;
|
||||
|
||||
public readonly auth: AuthService<TConfig>;
|
||||
public readonly leaderboards: LeaderboardsService;
|
||||
public readonly battlePass: BattlePassService;
|
||||
public readonly quests: QuestsService;
|
||||
public readonly auth: AuthService;
|
||||
public readonly effects: Effects;
|
||||
|
||||
public readonly player: PlayerDomain;
|
||||
public readonly catalog: CatalogDomain;
|
||||
public readonly inventory: InventoryDomain;
|
||||
public readonly remoteConfig: ConfigDomain<TConfig>;
|
||||
public readonly storage: StorageDomain;
|
||||
public readonly projectStorage: ProjectStorageDomain;
|
||||
public readonly stores: StoresDomain;
|
||||
public readonly modules: ModulesDomain;
|
||||
private readonly resourcesDomain: ResourcesDomain;
|
||||
|
||||
private readonly effectsCenter: EffectsCenter;
|
||||
private scenarioRuntime: ScenarioService | null = null;
|
||||
private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
|
||||
private readonly syncEngine: SyncEngine;
|
||||
private readonly loginEvent: string | null;
|
||||
private readonly ctx: RudderContext;
|
||||
private readonly transportContext: RequestContext;
|
||||
private runtimeStarted = false;
|
||||
@@ -78,13 +61,12 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
}
|
||||
|
||||
this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() };
|
||||
this.loginEvent =
|
||||
options.runtime?.loginEvent === undefined ? 'player_login' : options.runtime.loginEvent;
|
||||
|
||||
this.transportContext = {
|
||||
baseUrl: this.options.baseUrl,
|
||||
tokenStore: this.options.tokenStore,
|
||||
requestTimeoutMs: this.options.requestTimeoutMs,
|
||||
appVersion: this.options.appVersion,
|
||||
refreshState: {},
|
||||
onAuthFailure: () => {
|
||||
this.auth.notifySessionExpired();
|
||||
@@ -105,29 +87,18 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
|
||||
this.player = new PlayerDomain(ctx);
|
||||
this.catalog = new CatalogDomain(ctx);
|
||||
this.inventory = new InventoryDomain(ctx, this.catalog);
|
||||
this.remoteConfig = new ConfigDomain<TConfig>(ctx);
|
||||
this.storage = new StorageDomain(ctx);
|
||||
this.projectStorage = new ProjectStorageDomain(ctx);
|
||||
this.stores = new StoresDomain(ctx, () => {
|
||||
this.player.invalidate();
|
||||
this.inventory.invalidate();
|
||||
this.stores.invalidate();
|
||||
});
|
||||
this.modules = new ModulesDomain(ctx);
|
||||
this.resourcesDomain = new ResourcesDomain(ctx);
|
||||
|
||||
this.leaderboards = new LeaderboardsService(ctx);
|
||||
this.battlePass = new BattlePassService(ctx);
|
||||
this.quests = new QuestsService(ctx);
|
||||
this.syncEngine = new SyncEngine(ctx, [
|
||||
this.player,
|
||||
this.catalog,
|
||||
this.inventory,
|
||||
this.remoteConfig,
|
||||
this.storage,
|
||||
this.projectStorage,
|
||||
this.stores,
|
||||
]);
|
||||
this.auth = new AuthService<TConfig>(
|
||||
this.auth = new AuthService(
|
||||
ctx,
|
||||
() => this.startRuntimeAfterLogin(),
|
||||
() => this.stopRuntimeAfterLogout(),
|
||||
@@ -136,81 +107,33 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
|
||||
dispose(): void {
|
||||
this.syncEngine.stop();
|
||||
this.scenarioRuntime?.clear();
|
||||
this.resetAll();
|
||||
this.runtimeStarted = false;
|
||||
}
|
||||
|
||||
resources(slug: string): ResourceHandle {
|
||||
return this.resourcesDomain.of(slug);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async startRuntimeAfterLogin(): Promise<void> {
|
||||
const runtimePromise = this.ensureScenarioRuntime();
|
||||
if (this.runtimeStarted) {
|
||||
this.scenarioRuntime?.clear();
|
||||
this.invalidateAll();
|
||||
}
|
||||
this.runtimeStarted = true;
|
||||
await Promise.all([
|
||||
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);
|
||||
}
|
||||
await Promise.all([this.player.load(), this.catalog.load()]);
|
||||
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 */
|
||||
stopRuntimeAfterLogout(): void {
|
||||
this.runtimeStarted = false;
|
||||
this.syncEngine.stop();
|
||||
this.scenarioRuntime?.clear();
|
||||
this.resetAll();
|
||||
}
|
||||
|
||||
private get managedDomains(): ManagedDomain[] {
|
||||
return [
|
||||
this.player,
|
||||
this.catalog,
|
||||
this.inventory,
|
||||
this.remoteConfig,
|
||||
this.storage,
|
||||
this.projectStorage,
|
||||
this.stores,
|
||||
];
|
||||
return [this.player, this.catalog, this.storage, this.projectStorage];
|
||||
}
|
||||
|
||||
private invalidateAll(): void {
|
||||
@@ -221,18 +144,3 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { TokenStore } from '../token/TokenStore.js';
|
||||
|
||||
export interface RudderRuntimeOptions {
|
||||
loginEvent?: string | null;
|
||||
}
|
||||
|
||||
export interface RudderClientOptions {
|
||||
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
|
||||
baseUrl: string;
|
||||
@@ -18,6 +14,12 @@ export interface RudderClientOptions {
|
||||
*/
|
||||
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). */
|
||||
requestTimeoutMs?: number;
|
||||
|
||||
@@ -29,9 +31,6 @@ export interface RudderClientOptions {
|
||||
* Defaults to `console.error`.
|
||||
*/
|
||||
onEffectError?: (error: unknown) => void;
|
||||
|
||||
/** Advanced runtime knobs (login event). */
|
||||
runtime?: RudderRuntimeOptions;
|
||||
}
|
||||
|
||||
/** Client options with all defaults resolved — what `client.options` exposes. */
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
*
|
||||
* All errors extend `RudderError`. Network errors, HTTP errors, and
|
||||
* authentication errors have dedicated subclasses for granular handling.
|
||||
* The machine-readable `code` is the server-provided {@link RudderErrorCode}
|
||||
* when known, or any other string for unknown servers / SDK-local codes
|
||||
* The machine-readable `code` is the server-provided code (open-ended: platform
|
||||
* codes and module business error codes) or an SDK-local code
|
||||
* (e.g. `sdk/invalid-options` for client configuration errors).
|
||||
*/
|
||||
|
||||
import type { RudderErrorCode } from '../generated/errors.js';
|
||||
|
||||
/** Code carried by SDK errors: a known server code, or any other string. */
|
||||
export type RudderErrorCodeLike = RudderErrorCode | (string & {});
|
||||
/** Code carried by SDK errors: a server code or an SDK-local code. */
|
||||
export type RudderErrorCodeLike = string;
|
||||
|
||||
/** Code for client-side configuration validation failures. */
|
||||
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { SyncedState } from '../state/SyncedState.js';
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type { CatalogItem } from '../generated/catalog.js';
|
||||
import type { CatalogCurrency } from '../generated/catalog.js';
|
||||
|
||||
/**
|
||||
* CatalogDomain — the item catalog, keyed by slug. Synced under revision
|
||||
* key `catalog`. Feeds the merged {@link InventoryDomain} view.
|
||||
* CatalogDomain — currency definitions, keyed by slug. Synced under revision
|
||||
* key `catalog`.
|
||||
*/
|
||||
export class CatalogDomain extends SyncedState<Map<string, CatalogItem>> {
|
||||
export class CatalogDomain extends SyncedState<Map<string, CatalogCurrency>> {
|
||||
readonly syncKey = 'catalog';
|
||||
|
||||
constructor(ctx: RudderContext) {
|
||||
super(async () => {
|
||||
const response = await api.listCatalogItems(ctx);
|
||||
const map = new Map<string, CatalogItem>();
|
||||
for (const item of response.items ?? []) {
|
||||
if (!item.slug) continue;
|
||||
map.set(item.slug, item);
|
||||
const response = await api.listCatalogCurrencies(ctx);
|
||||
const map = new Map<string, CatalogCurrency>();
|
||||
for (const currency of response.currencies ?? []) {
|
||||
if (!currency.slug) continue;
|
||||
map.set(currency.slug, currency);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 interface NotificationEffect {
|
||||
@@ -15,25 +6,6 @@ export interface NotificationEffect {
|
||||
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 {
|
||||
readonly deadlineUtc: Date;
|
||||
}
|
||||
@@ -50,52 +22,20 @@ export interface ScenarioFailedEffect {
|
||||
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>;
|
||||
|
||||
export interface Effects {
|
||||
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
|
||||
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe;
|
||||
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe;
|
||||
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe;
|
||||
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
|
||||
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
|
||||
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
|
||||
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe;
|
||||
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe;
|
||||
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe;
|
||||
}
|
||||
|
||||
export class EffectsCenter implements Effects {
|
||||
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
|
||||
private readonly storeOfferHandlers = new Set<EffectHandler<StoreOfferEffect>>();
|
||||
private readonly leaderboardHandlers = new Set<EffectHandler<LeaderboardEffect>>();
|
||||
private readonly configChangedHandlers = new Set<EffectHandler<ConfigChangedEffect>>();
|
||||
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
|
||||
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
|
||||
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
|
||||
private readonly questHandlers = new Set<EffectHandler<QuestEffect>>();
|
||||
private readonly battlePassHandlers = new Set<EffectHandler<BattlePassEffect>>();
|
||||
private readonly battlePassLevelHandlers = new Set<EffectHandler<BattlePassLevelEffect>>();
|
||||
|
||||
constructor(
|
||||
private readonly onError: (error: unknown) => void = (error) =>
|
||||
@@ -106,18 +46,6 @@ export class EffectsCenter implements Effects {
|
||||
return this.addHandler(this.notificationHandlers, handler);
|
||||
}
|
||||
|
||||
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.storeOfferHandlers, handler);
|
||||
}
|
||||
|
||||
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.leaderboardHandlers, handler);
|
||||
}
|
||||
|
||||
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.configChangedHandlers, handler);
|
||||
}
|
||||
|
||||
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.waitHandlers, handler);
|
||||
}
|
||||
@@ -130,40 +58,11 @@ export class EffectsCenter implements Effects {
|
||||
return this.addHandler(this.scenarioFailedHandlers, handler);
|
||||
}
|
||||
|
||||
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.questHandlers, handler);
|
||||
}
|
||||
|
||||
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.battlePassHandlers, handler);
|
||||
}
|
||||
|
||||
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.battlePassLevelHandlers, handler);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitNotification(effect: NotificationEffect): void {
|
||||
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 */
|
||||
emitWait(effect: WaitEffect): void {
|
||||
this.emit(this.waitHandlers, effect);
|
||||
@@ -179,21 +78,6 @@ export class EffectsCenter implements Effects {
|
||||
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>(
|
||||
handlers: Set<EffectHandler<TEffect>>,
|
||||
handler: EffectHandler<TEffect>,
|
||||
|
||||
+33
-111
@@ -1,17 +1,12 @@
|
||||
// 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 { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js';
|
||||
import type { ListCatalogItemsResponse } from './catalog.js';
|
||||
import type { GetInventoryResponse } from './inventory.js';
|
||||
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
|
||||
import type { ListCatalogCurrenciesResponse } from './catalog.js';
|
||||
import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
|
||||
import type { PlayerProfile } from './player.js';
|
||||
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
|
||||
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js';
|
||||
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
|
||||
import type { HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, ListPendingScenarioEffectsResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
|
||||
import type { CreateResourceRowRequest, ListResourceRowsResponse, ResourceRow, UpdateResourceRowRequest } from './resources.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. */
|
||||
export interface Transport {
|
||||
@@ -36,23 +31,25 @@ function qs(
|
||||
|
||||
/** Typed request functions, one per SDK operation. */
|
||||
export const api = {
|
||||
addBattlePassXp: (
|
||||
callModuleFunction: (
|
||||
t: Transport,
|
||||
body: AddBattlePassXpRequest,
|
||||
): Promise<AddBattlePassXpResponse> =>
|
||||
t.request<AddBattlePassXpResponse>('POST', '/sdk/v1/battlepass/xp', body),
|
||||
path: { module: string; fn: string },
|
||||
body: CallModuleFunctionRequest,
|
||||
): Promise<CallModuleFunctionClientResponse> =>
|
||||
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
|
||||
|
||||
claimBattlePassReward: (
|
||||
createResourceRow: (
|
||||
t: Transport,
|
||||
body: ClaimBattlePassRewardRequest,
|
||||
): Promise<ClaimBattlePassRewardResponse> =>
|
||||
t.request<ClaimBattlePassRewardResponse>('POST', '/sdk/v1/battlepass/claim', body),
|
||||
path: { slug: string },
|
||||
body: CreateResourceRowRequest,
|
||||
): Promise<ResourceRow> =>
|
||||
t.request<ResourceRow>('POST', `/sdk/v1/resources/${enc(path.slug)}`, body),
|
||||
|
||||
claimQuest: (
|
||||
deleteResourceRow: (
|
||||
t: Transport,
|
||||
body: ClaimQuestRequest,
|
||||
): Promise<ClaimQuestResponse> =>
|
||||
t.request<ClaimQuestResponse>('POST', '/sdk/v1/quests/claim', body),
|
||||
path: { slug: string; id: string },
|
||||
): Promise<void> =>
|
||||
t.request<void>('DELETE', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
|
||||
|
||||
deleteStorage: (
|
||||
t: Transport,
|
||||
@@ -60,17 +57,6 @@ export const api = {
|
||||
): Promise<void> =>
|
||||
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: (
|
||||
t: Transport,
|
||||
): Promise<PlayerProfile> =>
|
||||
@@ -82,18 +68,11 @@ export const api = {
|
||||
): Promise<GetProjectStorageResponse> =>
|
||||
t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
||||
|
||||
getRanking: (
|
||||
getResourceRow: (
|
||||
t: Transport,
|
||||
path: { slug: string },
|
||||
query?: { limit?: number },
|
||||
): Promise<GetRankingResponse> =>
|
||||
t.request<GetRankingResponse>('GET', `/sdk/v1/leaderboards/${enc(path.slug)}/ranking${qs([['limit', query?.limit]])}`),
|
||||
|
||||
getRemoteConfig: (
|
||||
t: Transport,
|
||||
path: { key: string },
|
||||
): Promise<RemoteConfig> =>
|
||||
t.request<RemoteConfig>('GET', `/sdk/v1/remote-configs/${enc(path.key)}`),
|
||||
path: { slug: string; id: string },
|
||||
): Promise<ResourceRow> =>
|
||||
t.request<ResourceRow>('GET', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
|
||||
|
||||
getRevisions: (
|
||||
t: Transport,
|
||||
@@ -106,18 +85,6 @@ export const api = {
|
||||
): Promise<GetStorageResponse> =>
|
||||
t.request<GetStorageResponse>('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
||||
|
||||
getStore: (
|
||||
t: Transport,
|
||||
path: { slug: string },
|
||||
): Promise<Store> =>
|
||||
t.request<Store>('GET', `/sdk/v1/stores/${enc(path.slug)}`),
|
||||
|
||||
handleScenarioCallback: (
|
||||
t: Transport,
|
||||
body: HandleScenarioCallbackRequest,
|
||||
): Promise<HandleScenarioCallbackResponse> =>
|
||||
t.request<HandleScenarioCallbackResponse>('POST', '/sdk/v1/scenarios/callback', body),
|
||||
|
||||
linkViaApple: (
|
||||
t: Transport,
|
||||
body: LinkViaAppleRequest,
|
||||
@@ -136,30 +103,16 @@ export const api = {
|
||||
): Promise<void> =>
|
||||
t.request<void>('POST', '/sdk/v1/authorization/google/link', body),
|
||||
|
||||
listCatalogItems: (
|
||||
listCatalogCurrencies: (
|
||||
t: Transport,
|
||||
): Promise<ListCatalogItemsResponse> =>
|
||||
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
|
||||
): Promise<ListCatalogCurrenciesResponse> =>
|
||||
t.request<ListCatalogCurrenciesResponse>('GET', '/sdk/v1/catalog'),
|
||||
|
||||
listPendingScenarioEffects: (
|
||||
listResourceRows: (
|
||||
t: Transport,
|
||||
): Promise<ListPendingScenarioEffectsResponse> =>
|
||||
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
|
||||
|
||||
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'),
|
||||
path: { slug: string },
|
||||
): Promise<ListResourceRowsResponse> =>
|
||||
t.request<ListResourceRowsResponse>('GET', `/sdk/v1/resources/${enc(path.slug)}`),
|
||||
|
||||
loginViaApple: (
|
||||
t: Transport,
|
||||
@@ -185,44 +138,12 @@ export const api = {
|
||||
): Promise<LoginViaGoogleResponse> =>
|
||||
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: (
|
||||
t: Transport,
|
||||
body: RefreshAccessTokenRequest,
|
||||
): Promise<RefreshAccessTokenResponse> =>
|
||||
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: (
|
||||
t: Transport,
|
||||
body: UnlinkIdentityRequest,
|
||||
@@ -235,11 +156,12 @@ export const api = {
|
||||
): Promise<void> =>
|
||||
t.request<void>('PUT', '/sdk/v1/project-storage', body),
|
||||
|
||||
updateScenarioCounter: (
|
||||
updateResourceRow: (
|
||||
t: Transport,
|
||||
body: UpdateScenarioCounterRequest,
|
||||
): Promise<UpdateScenarioCounterResponse> =>
|
||||
t.request<UpdateScenarioCounterResponse>('POST', '/sdk/v1/scenarios/counter', body),
|
||||
path: { slug: string; id: string },
|
||||
body: UpdateResourceRowRequest,
|
||||
): Promise<ResourceRow> =>
|
||||
t.request<ResourceRow>('PATCH', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`, body),
|
||||
|
||||
updateStorage: (
|
||||
t: Transport,
|
||||
|
||||
@@ -14,10 +14,12 @@ export interface LinkViaGoogleRequest {
|
||||
}
|
||||
|
||||
export interface LoginViaAppleRequest {
|
||||
"appVersion"?: string;
|
||||
"idToken": string;
|
||||
"key": string;
|
||||
"language": string;
|
||||
"nickname"?: string;
|
||||
"platform"?: string;
|
||||
"region": string;
|
||||
}
|
||||
|
||||
@@ -27,10 +29,12 @@ export interface LoginViaAppleResponse {
|
||||
}
|
||||
|
||||
export interface LoginViaCustomRequest {
|
||||
"appVersion"?: string;
|
||||
"customData"?: { [key: string]: unknown };
|
||||
"key"?: string;
|
||||
"language"?: string;
|
||||
"nickname"?: string;
|
||||
"platform"?: string;
|
||||
"provider"?: string;
|
||||
"region"?: string;
|
||||
}
|
||||
@@ -41,10 +45,12 @@ export interface LoginViaCustomResponse {
|
||||
}
|
||||
|
||||
export interface LoginViaDeviceRequest {
|
||||
"appVersion"?: string;
|
||||
"deviceId"?: string;
|
||||
"key"?: string;
|
||||
"language"?: string;
|
||||
"nickname"?: string;
|
||||
"platform"?: string;
|
||||
"region"?: string;
|
||||
}
|
||||
|
||||
@@ -54,10 +60,12 @@ export interface LoginViaDeviceResponse {
|
||||
}
|
||||
|
||||
export interface LoginViaGoogleRequest {
|
||||
"appVersion"?: string;
|
||||
"idToken": string;
|
||||
"key": string;
|
||||
"language": string;
|
||||
"nickname"?: string;
|
||||
"platform"?: string;
|
||||
"region": string;
|
||||
}
|
||||
|
||||
@@ -67,6 +75,8 @@ export interface LoginViaGoogleResponse {
|
||||
}
|
||||
|
||||
export interface RefreshAccessTokenRequest {
|
||||
"appVersion"?: string;
|
||||
"platform"?: string;
|
||||
"refreshToken"?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface CatalogItem {
|
||||
export interface CatalogCurrency {
|
||||
"data"?: { [key: string]: unknown };
|
||||
"icon"?: string;
|
||||
"name"?: string;
|
||||
"properties"?: { [key: string]: unknown };
|
||||
"slug"?: string;
|
||||
"tags"?: string[];
|
||||
}
|
||||
|
||||
export interface ListCatalogItemsResponse {
|
||||
"items"?: CatalogItem[];
|
||||
export interface ListCatalogCurrenciesResponse {
|
||||
"currencies"?: CatalogCurrency[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
import type { ResourceRowUsage } from './resources.js';
|
||||
|
||||
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;
|
||||
"index"?: number;
|
||||
"path"?: string;
|
||||
"playerId"?: string;
|
||||
"requestId"?: string;
|
||||
}
|
||||
|
||||
export interface Reward {
|
||||
"amount"?: number;
|
||||
"currency"?: string;
|
||||
"itemId"?: string;
|
||||
"usages"?: ResourceRowUsage[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
// 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 = {
|
||||
earlyCompletion: "early_completion",
|
||||
forbidden: "forbidden",
|
||||
levelNotReached: "level_not_reached",
|
||||
nodeNotActive: "node_not_active",
|
||||
objectivesIncomplete: "objectives_incomplete",
|
||||
runExpired: "run_expired",
|
||||
runNotActive: "run_not_active",
|
||||
scenarioNotActive: "scenario_not_active",
|
||||
unknownRun: "unknown_run",
|
||||
rowReferenced: "row_referenced",
|
||||
} as const;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export * from './auth.js';
|
||||
export * from './battlepass.js';
|
||||
export * from './catalog.js';
|
||||
export * from './common.js';
|
||||
export * from './inventory.js';
|
||||
export * from './leaderboards.js';
|
||||
export * from './modules.js';
|
||||
export * from './player.js';
|
||||
export * from './project-storage.js';
|
||||
export * from './quests.js';
|
||||
export * from './remote-config.js';
|
||||
export * from './scenarios.js';
|
||||
export * from './resources.js';
|
||||
export * from './storage.js';
|
||||
export * from './stores.js';
|
||||
export * from './api.js';
|
||||
export * from './errors.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -16,8 +16,7 @@ export type { RudderErrorCodeLike } from './client/RudderError.js';
|
||||
export type { TokenStore } from './token/TokenStore.js';
|
||||
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
|
||||
|
||||
// Services — types only; instances live on the client
|
||||
// (client.auth, client.leaderboards, client.battlePass, client.quests).
|
||||
// Services — types only; instances live on the client (client.auth).
|
||||
export type {
|
||||
AuthService,
|
||||
AuthState,
|
||||
@@ -28,10 +27,6 @@ export type {
|
||||
LoginWithDeviceOptions,
|
||||
LoginWithGoogleOptions,
|
||||
} 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
|
||||
export { SyncedState } from './state/SyncedState.js';
|
||||
@@ -40,74 +35,43 @@ export type {
|
||||
SyncedStatus,
|
||||
SyncedListener,
|
||||
} from './state/SyncedState.js';
|
||||
export { RemoteConfigState } from './state/RemoteConfigState.js';
|
||||
export type { RemoteConfigShape } from './state/RemoteConfigState.js';
|
||||
export { ShopHandle, OfferHandle } from './state/shops.js';
|
||||
export type { BuyOptions } from './state/shops.js';
|
||||
export type { InventoryItem } from './state/inventory.js';
|
||||
|
||||
// Domains — types only; instances live on the client
|
||||
// (client.player, client.stores, client.remoteConfig, …).
|
||||
// (client.player, client.modules, client.resources(slug), …).
|
||||
export type { PlayerDomain } from './domains/PlayerDomain.js';
|
||||
export type { CatalogDomain } from './domains/CatalogDomain.js';
|
||||
export type { InventoryDomain } from './domains/InventoryDomain.js';
|
||||
export type { ConfigDomain } from './domains/ConfigDomain.js';
|
||||
export type { ModulesDomain } from './domains/ModulesDomain.js';
|
||||
export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.js';
|
||||
export type { StorageDomain } from './domains/StorageDomain.js';
|
||||
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
|
||||
export type { StoresDomain } from './domains/StoresDomain.js';
|
||||
|
||||
// Effects
|
||||
export type {
|
||||
ConfigChangedEffect,
|
||||
Effects,
|
||||
EffectUnsubscribe,
|
||||
LeaderboardEffect,
|
||||
NotificationEffect,
|
||||
StoreOfferEffect,
|
||||
WaitEffect,
|
||||
ScenarioCompletedEffect,
|
||||
ScenarioFailedEffect,
|
||||
QuestEffect,
|
||||
BattlePassEffect,
|
||||
BattlePassLevelEffect,
|
||||
} from './effects/EffectsCenter.js';
|
||||
|
||||
// Generated wire types — the shapes returned by the observable state and
|
||||
// handles, so consumers can name them instead of re-declaring mirrors.
|
||||
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
|
||||
export type { CatalogItem } from './generated/catalog.js';
|
||||
export type { PlayerInventoryItem } from './generated/inventory.js';
|
||||
export type {
|
||||
Store,
|
||||
Offer,
|
||||
OfferContent,
|
||||
OfferPrice,
|
||||
PurchaseOfferResponse,
|
||||
} from './generated/stores.js';
|
||||
export type { RankEntry } from './generated/leaderboards.js';
|
||||
export type { CatalogCurrency } from './generated/catalog.js';
|
||||
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
|
||||
export type {
|
||||
ProjectStorageItem,
|
||||
GetProjectStorageResponse,
|
||||
ProjectStorageUpdateItem,
|
||||
} from './generated/project-storage.js';
|
||||
export type { RemoteConfig } from './generated/remote-config.js';
|
||||
export type { Reward } from './generated/common.js';
|
||||
export type {
|
||||
Quest,
|
||||
QuestObjectiveProgress,
|
||||
ClaimQuestResponse,
|
||||
} from './generated/quests.js';
|
||||
CallModuleFunctionRequest,
|
||||
CallModuleFunctionClientResponse,
|
||||
} from './generated/modules.js';
|
||||
export type {
|
||||
ClaimedTier,
|
||||
AddBattlePassXpRequest,
|
||||
AddBattlePassXpResponse,
|
||||
ClaimBattlePassRewardRequest,
|
||||
ClaimBattlePassRewardResponse,
|
||||
PurchaseBattlePassPremiumRequest,
|
||||
PurchaseBattlePassPremiumResponse,
|
||||
GetBattlePassProgressRequest,
|
||||
GetBattlePassProgressResponse,
|
||||
} from './generated/battlepass.js';
|
||||
export type { RudderErrorCode } from './generated/errors.js';
|
||||
export { RudderErrorCodes } from './generated/errors.js';
|
||||
ResourceRow,
|
||||
ListResourceRowsResponse,
|
||||
CreateResourceRowRequest,
|
||||
UpdateResourceRowRequest,
|
||||
} from './generated/resources.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface RequestContext {
|
||||
baseUrl: string;
|
||||
tokenStore: TokenStore;
|
||||
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. */
|
||||
refreshState?: { pending?: Promise<boolean> };
|
||||
/** 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, {
|
||||
method: 'POST',
|
||||
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,
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
|
||||
+41
-115
@@ -6,9 +6,30 @@ function jsonResponse(body: unknown): Response {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
// Mock crypto.randomUUID for deterministic device ID.
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => '00000000-0000-4000-a000-000000000001',
|
||||
});
|
||||
@@ -17,20 +38,11 @@ describe('AuthService', () => {
|
||||
|
||||
it('loginWithDevice sends correct request body', async () => {
|
||||
const client = createTestClient();
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/sdk/v1/remote-configs')) {
|
||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({
|
||||
accessToken: 'access-token-123',
|
||||
refreshToken: 'refresh-token-456',
|
||||
}));
|
||||
});
|
||||
const fetchMock = stubKernelFetch();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(9);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain('/sdk/v1/authorization/device');
|
||||
const body = JSON.parse(init.body);
|
||||
@@ -40,29 +52,24 @@ describe('AuthService', () => {
|
||||
expect(body.language).toBe('en');
|
||||
expect(response.accessToken).toBe('access-token-123');
|
||||
expect(response.refreshToken).toBe('refresh-token-456');
|
||||
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
|
||||
'/sdk/v1/authorization/device',
|
||||
'/sdk/v1/remote-configs',
|
||||
'/sdk/v1/player/information',
|
||||
'/sdk/v1/stores',
|
||||
'/sdk/v1/catalog',
|
||||
'/sdk/v1/scenarios/pending',
|
||||
'/sdk/v1/scenarios/trigger',
|
||||
'/sdk/v1/player/information',
|
||||
'/sdk/v1/sync',
|
||||
]);
|
||||
const paths = fetchMock.mock.calls.map(([callUrl]) => new URL(String(callUrl)).pathname);
|
||||
expect(paths[0]).toBe('/sdk/v1/authorization/device');
|
||||
expect(paths).toContain('/sdk/v1/player/information');
|
||||
expect(paths).toContain('/sdk/v1/catalog');
|
||||
expect(paths).toContain('/sdk/v1/sync');
|
||||
expect(paths).not.toContain('/sdk/v1/remote-configs');
|
||||
expect(paths).not.toContain('/sdk/v1/stores');
|
||||
expect(paths).not.toContain('/sdk/v1/scenarios/pending');
|
||||
expect(paths).not.toContain('/sdk/v1/scenarios/trigger');
|
||||
});
|
||||
|
||||
it('loginWithDevice saves tokens on success', async () => {
|
||||
const client = createTestClient();
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/sdk/v1/remote-configs')) {
|
||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||
vi.stubGlobal('fetch', stubKernelFetch((url) => {
|
||||
if (url.includes('/sdk/v1/authorization/device')) {
|
||||
return jsonResponse({ accessToken: 'at', refreshToken: 'rt' });
|
||||
}
|
||||
return Promise.resolve(jsonResponse({
|
||||
accessToken: 'at',
|
||||
refreshToken: 'rt',
|
||||
}));
|
||||
return undefined;
|
||||
}));
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
@@ -94,23 +101,16 @@ describe('AuthService', () => {
|
||||
|
||||
it('loginWithDevice sends token in Authorization header after login', async () => {
|
||||
const client = createTestClient();
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
const fetchMock = stubKernelFetch((url) => {
|
||||
if (url.includes('/sdk/v1/authorization/device')) {
|
||||
return Promise.resolve(jsonResponse({
|
||||
accessToken: 'at2',
|
||||
refreshToken: 'rt2',
|
||||
}));
|
||||
return jsonResponse({ accessToken: 'at2', refreshToken: 'rt2' });
|
||||
}
|
||||
if (url.includes('/sdk/v1/remote-configs')) {
|
||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
||||
return undefined;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
|
||||
// Subsequent authenticated calls should include the token.
|
||||
const response = await client.player.reload();
|
||||
expect(response).toEqual({ player: null, wallets: [] });
|
||||
const playerCall = fetchMock.mock.calls.find(([url]) =>
|
||||
@@ -120,71 +120,9 @@ describe('AuthService', () => {
|
||||
expect(headers['Authorization']).toBe('Bearer at2');
|
||||
});
|
||||
|
||||
it('loginWithDevice only fires player_login from the client runtime', async () => {
|
||||
const client = createTestClient();
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/sdk/v1/authorization/device')) {
|
||||
return Promise.resolve(jsonResponse({
|
||||
accessToken: 'at-new',
|
||||
refreshToken: 'rt-new',
|
||||
}));
|
||||
}
|
||||
if (url.includes('/sdk/v1/remote-configs')) {
|
||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||
}
|
||||
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
||||
return Promise.resolve(jsonResponse({ plans: [] }));
|
||||
}
|
||||
if (url.includes('/sdk/v1/stores')) {
|
||||
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
|
||||
const triggerBodies = fetchMock.mock.calls
|
||||
.filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger'))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']);
|
||||
});
|
||||
|
||||
it('loginWithDevice isolates post-login scenario trigger failures', async () => {
|
||||
const client = createTestClient();
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/sdk/v1/authorization/device')) {
|
||||
return Promise.resolve(jsonResponse({
|
||||
accessToken: 'at',
|
||||
refreshToken: 'rt',
|
||||
}));
|
||||
}
|
||||
if (url.includes('/sdk/v1/remote-configs')) {
|
||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||
}
|
||||
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
||||
return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' }));
|
||||
}
|
||||
if (url.includes('/sdk/v1/stores')) {
|
||||
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
||||
}));
|
||||
|
||||
await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' });
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('loginWithDevice defaults to region global and language en', async () => {
|
||||
const client = createTestClient();
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/sdk/v1/remote-configs')) {
|
||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
|
||||
});
|
||||
const fetchMock = stubKernelFetch();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
@@ -203,21 +141,9 @@ describe('auth state', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function stubLoginFlow(): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/sdk/v1/remote-configs')) {
|
||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
||||
}
|
||||
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
||||
return Promise.resolve(jsonResponse({ plans: [] }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
|
||||
}));
|
||||
}
|
||||
|
||||
it('onAuthStateChange fires immediately, then on login and logout', async () => {
|
||||
const client = createTestClient();
|
||||
stubLoginFlow();
|
||||
vi.stubGlobal('fetch', stubKernelFetch());
|
||||
|
||||
const states: string[] = [];
|
||||
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
|
||||
|
||||
+28
-29
@@ -14,20 +14,17 @@ describe('public API surface', () => {
|
||||
});
|
||||
|
||||
it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
|
||||
// Device id helper — internal.
|
||||
expect('getOrCreateDeviceId' in sdk).toBe(false);
|
||||
// Domain classes — exported as types only.
|
||||
for (const name of [
|
||||
'PlayerDomain',
|
||||
'CatalogDomain',
|
||||
'InventoryDomain',
|
||||
'ConfigDomain',
|
||||
'StorageDomain',
|
||||
'StoresDomain',
|
||||
'ProjectStorageDomain',
|
||||
'ModulesDomain',
|
||||
'ResourcesDomain',
|
||||
]) {
|
||||
expect(name in sdk).toBe(false);
|
||||
}
|
||||
// Service constructors — exported as types only.
|
||||
for (const name of [
|
||||
'AuthService',
|
||||
'LeaderboardsService',
|
||||
@@ -48,43 +45,45 @@ describe('public API surface', () => {
|
||||
'RudderHttpError',
|
||||
'RudderAuthError',
|
||||
'SDK_ERROR_INVALID_OPTIONS',
|
||||
'RudderErrorCodes',
|
||||
'createDefaultTokenStore',
|
||||
'createLocalStorageTokenStore',
|
||||
'SyncedState',
|
||||
'RemoteConfigState',
|
||||
'ShopHandle',
|
||||
'OfferHandle',
|
||||
]) {
|
||||
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({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
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('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);
|
||||
});
|
||||
|
||||
it('uses the canonical glossary property names on the client', () => {
|
||||
const client = new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
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);
|
||||
expect('onBattlePass' in client.effects).toBe(false);
|
||||
expect('onStoreOffer' in client.effects).toBe(false);
|
||||
expect('onQuest' in client.effects).toBe(false);
|
||||
expect('onLeaderboard' in client.effects).toBe(false);
|
||||
expect('onConfigChanged' in client.effects).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+41
-57
@@ -135,83 +135,67 @@ describe('transport error mapping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('lazy scenario runtime', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('modules and resources', () => {
|
||||
afterEach(() => {
|
||||
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({
|
||||
baseUrl: 'https://api.example.com',
|
||||
projectKey: 'proj_123',
|
||||
runtime: { loginEvent: null },
|
||||
tokenStore: createFakeTokenStore(),
|
||||
});
|
||||
const internals = client as unknown as {
|
||||
scenarioRuntime: unknown;
|
||||
ensureScenarioRuntime(): Promise<unknown>;
|
||||
};
|
||||
expect(internals.scenarioRuntime).toBeNull();
|
||||
await internals.ensureScenarioRuntime();
|
||||
expect(internals.scenarioRuntime).not.toBeNull();
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ result: { ok: true }, log: [] }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await client.modules.call('quests', 'list', { limit: 10 });
|
||||
|
||||
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({
|
||||
baseUrl: 'https://api.example.com',
|
||||
projectKey: 'proj_123',
|
||||
runtime: {},
|
||||
tokenStore: createFakeTokenStore(),
|
||||
});
|
||||
|
||||
const notifications: string[] = [];
|
||||
client.effects.onNotification((effect) => {
|
||||
notifications.push(effect.message);
|
||||
return effect.done();
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
const row = { id: 'row-1', data: { name: 'Sword' }, version: 1 };
|
||||
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === '/sdk/v1/authorization/device') {
|
||||
return Promise.resolve(new Response(
|
||||
JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }),
|
||||
{ status: 200 },
|
||||
));
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
if (method === 'GET' && path === '/sdk/v1/resources/items') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ items: [row], total: 1 }), { status: 200 }));
|
||||
}
|
||||
if (path === '/sdk/v1/scenarios/trigger') {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
effects: [{
|
||||
runId: 'run-1',
|
||||
scenarioSlug: 'scenario-1',
|
||||
nodeId: 'start',
|
||||
type: 'notification',
|
||||
data: { message: 'Welcome!' },
|
||||
}],
|
||||
}), { status: 200 }));
|
||||
if (method === 'GET' && path === '/sdk/v1/resources/items/row-1') {
|
||||
return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
|
||||
}
|
||||
if (path === '/sdk/v1/scenarios/pending') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 }));
|
||||
if (method === 'POST' && path === '/sdk/v1/resources/items') {
|
||||
return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
|
||||
}
|
||||
if (path === '/sdk/v1/remote-configs') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
|
||||
if (method === 'PATCH' && path === '/sdk/v1/resources/items/row-1') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ ...row, version: 2 }), { status: 200 }));
|
||||
}
|
||||
if (path === '/sdk/v1/stores') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 }));
|
||||
if (method === 'DELETE' && path === '/sdk/v1/resources/items/row-1') {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
if (path === '/sdk/v1/catalog') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||
}
|
||||
if (path === '/sdk/v1/sync') {
|
||||
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
||||
}
|
||||
void init;
|
||||
return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 }));
|
||||
}));
|
||||
return Promise.resolve(new Response('unhandled', { status: 500 }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await client.auth.loginWithDevice();
|
||||
await vi.waitFor(() => expect(notifications).toEqual(['Welcome!']));
|
||||
client.dispose();
|
||||
const handle = client.resources('items');
|
||||
expect(await handle.list()).toEqual({ items: [row], total: 1 });
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,11 @@
|
||||
// reach the admin API for wallet grants / player inspection.
|
||||
|
||||
import { RudderClient } from '../../../src/client/RudderClient.js';
|
||||
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
|
||||
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
||||
import { createAdminClient, type AdminClient } from './adminClient.js';
|
||||
import { readArtifact } from './artifact.js';
|
||||
import type { SeedArtifact } from './types.js';
|
||||
|
||||
type RuntimeOptions = {
|
||||
loginEvent?: string | null;
|
||||
};
|
||||
|
||||
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
|
||||
export function loadArtifact(): SeedArtifact {
|
||||
const file = readArtifact();
|
||||
@@ -44,25 +39,22 @@ export function createInMemoryTokenStore(): TokenStore {
|
||||
};
|
||||
}
|
||||
|
||||
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
|
||||
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||
/** A client with no auth yet (in-memory tokens). */
|
||||
export function makeProdClient(
|
||||
artifact: SeedArtifact = loadArtifact(),
|
||||
runtime: RuntimeOptions = { loginEvent: null },
|
||||
): RudderClient<TConfig> {
|
||||
return new RudderClient<TConfig>({
|
||||
): RudderClient {
|
||||
return new RudderClient({
|
||||
baseUrl: artifact.baseUrl,
|
||||
projectKey: artifact.projectKey,
|
||||
tokenStore: createInMemoryTokenStore(),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
|
||||
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||
export async function freshPlayer(
|
||||
artifact: SeedArtifact = loadArtifact(),
|
||||
runtime?: RuntimeOptions,
|
||||
): Promise<RudderClient<TConfig>> {
|
||||
const client = makeProdClient<TConfig>(artifact, runtime);
|
||||
): Promise<RudderClient> {
|
||||
const client = makeProdClient(artifact);
|
||||
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -1,108 +1,20 @@
|
||||
// Production seed: registers an operator, creates a project, configures staging
|
||||
// content (counter, item, remote configs, leaderboard, store with free+paid offers, a
|
||||
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
|
||||
//
|
||||
// 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.
|
||||
// Production seed: registers an operator and creates a project. Creating a
|
||||
// project provisions staging + prod environments; the prod SDK key is what
|
||||
// the runtime SDK sends as projectKey.
|
||||
|
||||
import { createAdminClient, type AdminClient } from './adminClient.js';
|
||||
import { createAdminClient } from './adminClient.js';
|
||||
import type { SeedArtifact } from './types.js';
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
interface ReleaseResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface QuestResponse {
|
||||
id: string;
|
||||
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(
|
||||
baseUrl: string,
|
||||
log: (m: string) => void = () => {},
|
||||
): Promise<SeedArtifact> {
|
||||
const api = createAdminClient(baseUrl);
|
||||
const ts = Date.now();
|
||||
const authoringEnv = 'staging';
|
||||
const runtimeEnv = 'prod';
|
||||
const q = `?environment=${authoringEnv}`;
|
||||
const prodQ = `?environment=${runtimeEnv}`;
|
||||
const adminEmail = `e2e+${ts}@rudder.build`;
|
||||
const password = `Passw0rd!e2e-${ts}`;
|
||||
|
||||
// 1. Register operator (open registration; no approval gate at the API layer).
|
||||
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
|
||||
'/platform/v1/authorization/register',
|
||||
{ email: adminEmail, password, fullName: 'E2E Bot' },
|
||||
@@ -111,8 +23,6 @@ export async function runSeed(
|
||||
api.setToken(auth.accessToken);
|
||||
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', {
|
||||
name: `e2e-${ts}`,
|
||||
});
|
||||
@@ -126,168 +36,6 @@ export async function runSeed(
|
||||
}
|
||||
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 {
|
||||
baseUrl,
|
||||
projectId: project.id,
|
||||
@@ -296,38 +44,6 @@ export async function runSeed(
|
||||
adminEmail,
|
||||
environment: runtimeEnv,
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,37 +10,4 @@ export interface SeedArtifact {
|
||||
adminEmail: string;
|
||||
environment: 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 };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ describe('e2e-prod: health', () => {
|
||||
expect(res.ok).toBe(true);
|
||||
const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
|
||||
expect(body.status).toBe('ready');
|
||||
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
|
||||
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ function makeClient(): RudderClient {
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
runtime: { loginEvent: null },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,13 +45,11 @@ describe('E2E: player storage', () => {
|
||||
{ id: 'b', type: 'prefs', data: '{}' },
|
||||
]);
|
||||
|
||||
// v0.2 fetches the whole storage; filtering by type is client-side.
|
||||
const all = await client.storage.reload();
|
||||
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
|
||||
expect(prefs).toHaveLength(1);
|
||||
expect(prefs[0].id).toBe('b');
|
||||
|
||||
// The read went out as a storage fetch with limit as a query param, not a path segment.
|
||||
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
|
||||
expect(getReq?.query.get('limit')).toBe('100');
|
||||
});
|
||||
|
||||
+63
-142
@@ -1,12 +1,9 @@
|
||||
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 {
|
||||
ClaimQuestResponse,
|
||||
ListQuestsResponse,
|
||||
} from '../../src/generated/quests.js';
|
||||
import type { ResourceRow } from '../../src/generated/resources.js';
|
||||
import type { CallModuleFunctionClientResponse } from '../../src/generated/modules.js';
|
||||
import type { CatalogCurrency } from '../../src/generated/catalog.js';
|
||||
import type { ProjectStorageItem } from '../../src/generated/project-storage.js';
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
@@ -17,28 +14,17 @@ export interface RecordedRequest {
|
||||
}
|
||||
|
||||
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>;
|
||||
projectStorage: Map<string, ProjectStorageItem>;
|
||||
currencies: CatalogCurrency[];
|
||||
resources: Map<string, Map<string, ResourceRow>>;
|
||||
moduleCalls: CallModuleFunctionClientResponse;
|
||||
}
|
||||
|
||||
export interface FakeGateway {
|
||||
state: FakeGatewayState;
|
||||
recorded: RecordedRequest[];
|
||||
purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
|
||||
questClaims: Array<{ questSlug: string; authToken: string | null }>;
|
||||
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 {
|
||||
@@ -50,52 +36,16 @@ function json(body: unknown, status = 200): Response {
|
||||
|
||||
const noContent = (): Response => new Response(null, { status: 204 });
|
||||
|
||||
function replaceRun(pending: PendingEffect[], next: PendingEffect[]): PendingEffect[] {
|
||||
const runIds = new Set(next.map((effect) => effect.runId));
|
||||
return [...pending.filter((effect) => !runIds.has(effect.runId)), ...next];
|
||||
}
|
||||
|
||||
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
|
||||
const state: FakeGatewayState = {
|
||||
scenarios: seed?.scenarios ?? new Map(),
|
||||
callbacks: seed?.callbacks ?? new Map(),
|
||||
callbackErrors: seed?.callbackErrors ?? new Map(),
|
||||
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(),
|
||||
projectStorage: seed?.projectStorage ?? new Map(),
|
||||
currencies: seed?.currencies ?? [],
|
||||
resources: seed?.resources ?? new Map(),
|
||||
moduleCalls: seed?.moduleCalls ?? { result: {} },
|
||||
};
|
||||
|
||||
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> {
|
||||
const { method, path, query, body } = req;
|
||||
@@ -117,85 +67,54 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/sdk/v1/catalog') {
|
||||
return json({ items: [] });
|
||||
return json({ currencies: state.currencies });
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
||||
const event = (body as { event?: string }).event ?? '';
|
||||
const effects = state.scenarios.get(event) ?? [];
|
||||
state.pendingEffects = replaceRun(state.pendingEffects, effects);
|
||||
return json({ effects });
|
||||
}
|
||||
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
|
||||
return json({ effects: advanceExpiredWaits() });
|
||||
}
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
|
||||
const b = body as { nodeId?: string; handle?: string; runId?: string };
|
||||
const key = `${b.nodeId}:${b.handle}`;
|
||||
const err = state.callbackErrors.get(key);
|
||||
if (err) {
|
||||
return json({ code: err.code ?? 'callback failed', error: 'callback failed' }, err.status);
|
||||
}
|
||||
const next = state.callbacks.get(key);
|
||||
state.pendingEffects = state.pendingEffects.filter(
|
||||
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
|
||||
);
|
||||
if (next) {
|
||||
state.pendingEffects = replaceRun(state.pendingEffects, [next]);
|
||||
return json({ effect: next });
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
|
||||
if (state.counterCompleted && state.counterEffect) {
|
||||
state.pendingEffects = replaceRun(state.pendingEffects, [state.counterEffect]);
|
||||
} else if (state.counterCompleted) {
|
||||
const b = body as { nodeId?: string; runId?: string };
|
||||
state.pendingEffects = state.pendingEffects.filter(
|
||||
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
|
||||
);
|
||||
}
|
||||
return json({ completed: state.counterCompleted, effect: state.counterEffect });
|
||||
const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/);
|
||||
if (method === 'POST' && moduleMatch) {
|
||||
return json(state.moduleCalls);
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
|
||||
return json({ configs: state.remoteConfigs });
|
||||
const resourceItemMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)\/([^/]+)$/);
|
||||
const resourceListMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)$/);
|
||||
if (resourceItemMatch) {
|
||||
const slug = decodeURIComponent(resourceItemMatch[1]);
|
||||
const id = decodeURIComponent(resourceItemMatch[2]);
|
||||
const rows = state.resources.get(slug) ?? new Map();
|
||||
if (method === 'GET') {
|
||||
const row = rows.get(id);
|
||||
return row ? json(row) : json({ error: 'not found' }, 404);
|
||||
}
|
||||
if (method === 'GET' && path.startsWith('/sdk/v1/remote-configs/')) {
|
||||
const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length));
|
||||
const cfg = state.remoteConfigs[key];
|
||||
return cfg ? json(cfg) : json({ error: 'not found' }, 404);
|
||||
if (method === 'PATCH') {
|
||||
const existing = rows.get(id);
|
||||
if (!existing) return json({ error: 'not found' }, 404);
|
||||
const next = { ...existing, ...(body as ResourceRow), id };
|
||||
rows.set(id, next);
|
||||
state.resources.set(slug, rows);
|
||||
return json(next);
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/sdk/v1/stores') {
|
||||
return json({ stores: state.stores, total: state.stores.length });
|
||||
if (method === 'DELETE') {
|
||||
rows.delete(id);
|
||||
state.resources.set(slug, rows);
|
||||
return noContent();
|
||||
}
|
||||
const purchaseMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)\/offers\/([^/]+)\/purchase$/);
|
||||
if (method === 'POST' && purchaseMatch) {
|
||||
const b = body as { idempotencyKey?: string };
|
||||
purchases.push({
|
||||
storeSlug: decodeURIComponent(purchaseMatch[1]),
|
||||
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 (resourceListMatch) {
|
||||
const slug = decodeURIComponent(resourceListMatch[1]);
|
||||
const rows = state.resources.get(slug) ?? new Map();
|
||||
if (method === 'GET') {
|
||||
return json({ items: [...rows.values()], total: rows.size });
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/sdk/v1/quests/list') {
|
||||
return json(state.quests);
|
||||
if (method === 'POST') {
|
||||
const created: ResourceRow = {
|
||||
id: (body as ResourceRow).id ?? `row-${rows.size + 1}`,
|
||||
data: (body as ResourceRow).data,
|
||||
version: 1,
|
||||
};
|
||||
rows.set(created.id!, created);
|
||||
state.resources.set(slug, rows);
|
||||
return json(created);
|
||||
}
|
||||
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') {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -248,17 +180,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
return {
|
||||
state,
|
||||
recorded,
|
||||
purchases,
|
||||
questClaims,
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -6,8 +6,7 @@ export default defineConfig({
|
||||
target: 'es2022',
|
||||
dts: true,
|
||||
clean: true,
|
||||
// Code-split the lazily-imported scenario client out of the initial chunk.
|
||||
splitting: true,
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
platform: 'browser',
|
||||
outDir: 'dist',
|
||||
|
||||
Reference in New Issue
Block a user