feat: modules and resources domains, drop removed domains, regenerated models
This commit is contained in:
+36
-28
@@ -1,38 +1,46 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Login and token refresh send `platform: 'web'` and, when the new optional
|
||||||
|
`appVersion` client option is set, `appVersion`. The backend stores them on
|
||||||
|
the player for segment rules (`player.platform`, `player.appVersion`).
|
||||||
|
|
||||||
## 2.0.0
|
## 2.0.0
|
||||||
|
|
||||||
Breaking change — major bump, required by the backend environments release.
|
Breaking change — kernel surface. Module-specific client methods are removed.
|
||||||
Every project now has exactly two environments, `staging` and `prod`, and the
|
The SDK now exposes the kernel only: auth, player (including wallets), catalog
|
||||||
SDK key passed as `projectKey` decides which one the player belongs to. The
|
currencies, storage, project storage, revision sync,
|
||||||
environment never appears in the client API: it is resolved at login and
|
`client.modules.call(module, fn, args)`, and `client.resources(slug)`.
|
||||||
carried inside the access and refresh tokens. Tokens issued before this
|
|
||||||
release have no environment claim and are rejected with 401, so the first call
|
### Removed
|
||||||
after the backend upgrade refreshes, fails, clears the token store and emits
|
|
||||||
|
- `client.battlePass` / `BattlePassService`
|
||||||
|
- `client.quests` / `QuestsService` / `QuestMetrics`
|
||||||
|
- `client.leaderboards` / `LeaderboardsService` / `LeaderboardHandle`
|
||||||
|
- `client.stores` / `StoresDomain` / `ShopHandle` / `OfferHandle`
|
||||||
|
- `client.inventory` / `InventoryDomain`
|
||||||
|
- `client.remoteConfig` / `ConfigDomain` / `RemoteConfigState`
|
||||||
|
- Scenario runtime (`ScenarioService`, login-event trigger, pending poll)
|
||||||
|
- Module-specific effects: `onStoreOffer`, `onLeaderboard`, `onQuest`,
|
||||||
|
`onBattlePass`, `onBattlePassLevel`, `onConfigChanged`
|
||||||
|
- Generated wire types for battle pass, quests, stores, inventory,
|
||||||
|
leaderboards, remote config, and scenarios
|
||||||
|
|
||||||
|
Call module functions with `client.modules.call(module, fn, args)` and read or
|
||||||
|
write resource rows with `client.resources(slug)` (`list` / `get` / `create` /
|
||||||
|
`update` / `delete`).
|
||||||
|
|
||||||
|
Also required by the backend environments release: every project now has
|
||||||
|
exactly two environments, `staging` and `prod`, and the SDK key passed as
|
||||||
|
`projectKey` decides which one the player belongs to. The environment never
|
||||||
|
appears in the client API: it is resolved at login and carried inside the
|
||||||
|
access and refresh tokens. Tokens issued before this release have no
|
||||||
|
environment claim and are rejected with 401, so the first call after the
|
||||||
|
backend upgrade refreshes, fails, clears the token store and emits
|
||||||
`'signed-out'` to every `onAuthStateChange` listener. Log the player in again
|
`'signed-out'` to every `onAuthStateChange` listener. Log the player in again
|
||||||
with `client.auth.loginWithDevice()` / `loginWithCustom()`.
|
with `client.auth.loginWithDevice()` / `loginWithCustom()`.
|
||||||
|
|
||||||
Quests, scenarios and offers are addressed by their slug instead of their id,
|
|
||||||
because ids differ between staging and prod while slugs are stable. Renamed
|
|
||||||
accordingly: `Quest.id` is now `Quest.slug`, `client.quests.claim()` takes a
|
|
||||||
quest slug, `reportProgress()` resolves to the slugs of the completed quests
|
|
||||||
(`ReportQuestProgressResponse.completedQuestSlugs`),
|
|
||||||
`client.stores.purchase(storeSlug, offerSlug, options?)` takes an offer slug
|
|
||||||
and `OfferHandle` carries a `slug` alongside its `id`,
|
|
||||||
`QuestMetrics.purchaseOffer(offerSlug)` builds its metric from the offer slug,
|
|
||||||
and every scenario-scoped type exposes `scenarioSlug` instead of `scenarioId` —
|
|
||||||
`PendingEffect`, `ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle
|
|
||||||
pass requests and `client.battlePass.getProgress(scenarioSlug, nodeId)`.
|
|
||||||
`offer.buy()` is unchanged; it now binds the slug for you. Leaderboards, items
|
|
||||||
and stores already used slugs and are untouched.
|
|
||||||
|
|
||||||
Also shipped here, previously committed but never published: the effects client
|
|
||||||
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run
|
|
||||||
that disappears server-side (finished elsewhere, expired after a promote) now
|
|
||||||
emits `onScenarioCompleted` instead of lingering; and `run_not_active` joins
|
|
||||||
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
|
|
||||||
emits `onScenarioFailed`.
|
|
||||||
|
|
||||||
## 1.0.0
|
## 1.0.0
|
||||||
|
|
||||||
- Scenario execution moved server-side. The SDK no longer walks a local DAG
|
- Scenario execution moved server-side. The SDK no longer walks a local DAG
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# @rudder/js-sdk
|
# @rudder/js-sdk
|
||||||
|
|
||||||
LiveOps Web SDK for browser games — device auth, observable player state,
|
LiveOps Web SDK for browser games — device auth, observable player state,
|
||||||
stores, remote config, leaderboards, quests, battle pass, and a scenario runtime
|
catalog currencies, storage, generic resources, and module function calls.
|
||||||
surfaced as typed effects.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -29,22 +28,18 @@ const client = new RudderClient({
|
|||||||
projectKey: 'your-project-key',
|
projectKey: 'your-project-key',
|
||||||
// tokenStore is optional: defaults to localStorage with an in-memory
|
// tokenStore is optional: defaults to localStorage with an in-memory
|
||||||
// fallback where localStorage is unavailable (SSR, private mode).
|
// fallback where localStorage is unavailable (SSR, private mode).
|
||||||
|
appVersion: '1.4.2', // optional; sent with platform 'web' on login and refresh
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.auth.loginWithDevice(); // region 'global', language 'en'
|
await client.auth.loginWithDevice(); // region 'global', language 'en'
|
||||||
// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' });
|
// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' });
|
||||||
|
|
||||||
// Observable domains — read `.data`, subscribe with `onChange`, force `reload()`.
|
|
||||||
const profile = client.player.data;
|
const profile = client.player.data;
|
||||||
const speed = client.remoteConfig.get('player_speed', 200);
|
const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance;
|
||||||
|
|
||||||
client.stores.onChange(({ data: stores }) => renderStores(stores ?? []));
|
const items = await client.resources('items').list();
|
||||||
|
const result = await client.modules.call('quests', 'list');
|
||||||
|
|
||||||
// Scenario-driven UI arrives through effects.
|
|
||||||
client.effects.onStoreOffer((offer) => showOffer(offer));
|
|
||||||
client.effects.onScenarioFailed(({ error }) => console.error('scenario failed', error));
|
|
||||||
|
|
||||||
// Tear down background work (sync poll, wait timers) on unmount / HMR.
|
|
||||||
client.dispose();
|
client.dispose();
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,75 +49,15 @@ client.dispose();
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
|
| Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
|
||||||
| Player / wallets | `client.player` (observable) |
|
| Player / wallets | `client.player` (observable) |
|
||||||
| Inventory | `client.inventory` (observable, catalog-merged) |
|
| Catalog currencies | `client.catalog` (observable) |
|
||||||
| Catalog | `client.catalog` (observable) |
|
|
||||||
| Stores | `client.stores` (observable) + `client.stores.purchase(slug, offerId)` |
|
|
||||||
| Remote config | `client.remoteConfig.get(key, default)` (observable, typed) |
|
|
||||||
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
|
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
|
||||||
| Leaderboards | `client.leaderboards.findBySlug(slug)` → `handle.submit(score)` / `handle.list(limit?)` |
|
| Project storage | `client.projectStorage` (observable) + `.save(items)` |
|
||||||
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) |
|
| Modules | `client.modules.call(module, fn, args?)` |
|
||||||
| Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` |
|
| Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` |
|
||||||
| Scenario effects | `client.effects.on*` |
|
|
||||||
|
|
||||||
Type the remote config for `client.remoteConfig`:
|
Game-design features (quests, stores, inventory, leaderboards, battle pass,
|
||||||
|
remote config) live in installed modules. Call them through
|
||||||
```ts
|
`client.modules.call` and read their rows through `client.resources(slug)`.
|
||||||
interface GameConfig extends Record<string, unknown> {
|
|
||||||
player_speed: number;
|
|
||||||
feature_x: boolean;
|
|
||||||
}
|
|
||||||
const client = new RudderClient<GameConfig>({ /* … */ });
|
|
||||||
client.remoteConfig.get('player_speed', 200); // number
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quests
|
|
||||||
|
|
||||||
`client.quests` covers the player's global quests — list with per-objective
|
|
||||||
progress, claim, and metric reports. These are distinct from scenario quest
|
|
||||||
nodes, which advance through the `onQuest` effect's `QuestSession`. Global
|
|
||||||
quests have no live sync: re-list after a claim or report.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const quests = await client.quests.list();
|
|
||||||
for (const quest of quests) {
|
|
||||||
if (quest.status === 'completed' && quest.id) {
|
|
||||||
await client.quests.claim(quest.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom metrics advance matching objectives server-side; the call returns
|
|
||||||
// the ids of quests completed by this report.
|
|
||||||
const completedIds = await client.quests.reportProgress('kills', 1);
|
|
||||||
```
|
|
||||||
|
|
||||||
Purchase metrics are reported automatically when a purchase goes through
|
|
||||||
`client.stores`; the `QuestMetrics` helpers name the format so quest configs
|
|
||||||
and client code agree on it:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { QuestMetrics } from '@rudder/js-sdk';
|
|
||||||
|
|
||||||
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
|
|
||||||
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scenario effects
|
|
||||||
|
|
||||||
The scenario runtime is not exposed directly; scenario nodes surface through
|
|
||||||
`client.effects`:
|
|
||||||
|
|
||||||
- `onNotification`, `onStoreOffer`, `onLeaderboard`, `onConfigChanged`
|
|
||||||
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
|
|
||||||
- `onScenarioCompleted`, `onScenarioFailed`
|
|
||||||
|
|
||||||
The server executes the scenario graph. The SDK is a thin effects client
|
|
||||||
(trigger, pending poll, callbacks) loaded lazily on first login — a client
|
|
||||||
that only reads domains never pulls it into the page. `onConfigChanged`
|
|
||||||
stays on the surface, but scenario `remote_config_override` nodes are
|
|
||||||
applied server-side and no longer emit that effect.
|
|
||||||
|
|
||||||
Errors thrown inside effect handlers are reported via the `onEffectError`
|
|
||||||
client option (default: `console.error`).
|
|
||||||
|
|
||||||
## Error handling
|
## Error handling
|
||||||
|
|
||||||
@@ -135,7 +70,6 @@ import {
|
|||||||
RudderNetworkError,
|
RudderNetworkError,
|
||||||
RudderHttpError,
|
RudderHttpError,
|
||||||
RudderAuthError,
|
RudderAuthError,
|
||||||
RudderErrorCodes,
|
|
||||||
} from '@rudder/js-sdk';
|
} from '@rudder/js-sdk';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -146,9 +80,6 @@ try {
|
|||||||
// cleared tokens and emitted 'signed-out' via onAuthStateChange.
|
// cleared tokens and emitted 'signed-out' via onAuthStateChange.
|
||||||
showLogin();
|
showLogin();
|
||||||
} else if (error instanceof RudderHttpError) {
|
} else if (error instanceof RudderHttpError) {
|
||||||
if (error.code === RudderErrorCodes.runExpired) {
|
|
||||||
// typed server error code
|
|
||||||
}
|
|
||||||
console.error(error.status, error.code, error.body);
|
console.error(error.status, error.code, error.body);
|
||||||
} else if (error instanceof RudderNetworkError) {
|
} else if (error instanceof RudderNetworkError) {
|
||||||
// fetch failed / timeout — the underlying error is in error.cause
|
// fetch failed / timeout — the underlying error is in error.cause
|
||||||
@@ -171,15 +102,14 @@ client.auth.onAuthStateChange((state) => {
|
|||||||
|
|
||||||
## Data liveness model
|
## Data liveness model
|
||||||
|
|
||||||
Observable domains (`player`, `inventory`, `catalog`, `stores`, `remoteConfig`,
|
Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are
|
||||||
`storage`) are warmed once at login and then kept fresh by a revision poll:
|
warmed at login (`player` and `catalog`) or on first use, then kept fresh by
|
||||||
the client asks the gateway for entity revisions every **30 s (±20% jitter)**
|
a revision poll: the client asks the gateway for entity revisions every
|
||||||
and reloads only the entities whose revision grew. Polling **pauses while the
|
**30 s (±20% jitter)** and reloads only the entities whose revision grew.
|
||||||
tab is hidden** and resumes on `visibilitychange`. Mutations (purchases,
|
Polling **pauses while the tab is hidden** and resumes on `visibilitychange`.
|
||||||
storage writes, scenario callbacks) invalidate the affected domains
|
Storage writes invalidate the affected domain immediately. Expect data to be
|
||||||
immediately, so subscribers see fresh data without waiting for the next poll.
|
eventually consistent within one poll interval; call `reload()` when you need
|
||||||
Expect data to be eventually consistent within one poll interval; call
|
a guarantee.
|
||||||
`reload()` when you need a guarantee.
|
|
||||||
|
|
||||||
## React recipe
|
## React recipe
|
||||||
|
|
||||||
@@ -202,9 +132,6 @@ function Wallet() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The same pattern works for `client.remoteConfig`, `client.stores`, and
|
|
||||||
`client.auth.onAuthStateChange`.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
|
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
|
||||||
},
|
},
|
||||||
"description": "LiveOps Web SDK for browser games — player authentication, leaderboards, stores, remote config, and typed runtime effects",
|
"description": "LiveOps Web SDK for browser games — player authentication, catalog currencies, storage, generic resources, and module function calls",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: rudder-web-sdk
|
name: rudder-web-sdk
|
||||||
description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/google/apple/custom auth, identity linking, observable domains (player, inventory, catalog, stores, remote config, storage), leaderboards, quests, battle pass, scenario effects, and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears.
|
description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/google/apple/custom auth, identity linking, observable kernel domains (player, catalog, storage), modules.call, resources(slug), and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Rudder Web SDK (@rudder/js-sdk)
|
# Rudder Web SDK (@rudder/js-sdk)
|
||||||
@@ -9,6 +9,11 @@ Browser-facing player SDK for the Rudder LiveOps platform. Source of truth:
|
|||||||
`liveops-web-sdk/src` (generated wire types in `src/generated` come from the
|
`liveops-web-sdk/src` (generated wire types in `src/generated` come from the
|
||||||
gateway's apigen — never edit them by hand).
|
gateway's apigen — never edit them by hand).
|
||||||
|
|
||||||
|
2.0 kernel surface: auth, player (wallets), catalog currencies, storage,
|
||||||
|
project storage, revision sync, `client.modules.call(module, fn, args)`,
|
||||||
|
and `client.resources(slug)`. Module-specific methods (battle pass, quests,
|
||||||
|
leaderboards, stores, inventory, remote config, scenarios) are gone.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Published to a private registry, not npmjs. Point the `@rudder` scope at it in
|
Published to a private registry, not npmjs. Point the `@rudder` scope at it in
|
||||||
@@ -36,16 +41,14 @@ const client = new RudderClient({
|
|||||||
// requestTimeoutMs?: number — default 10000
|
// requestTimeoutMs?: number — default 10000
|
||||||
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
|
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
|
||||||
// onEffectError?: (error) => void — default console.error
|
// onEffectError?: (error) => void — default console.error
|
||||||
// runtime?: { loginEvent? } — advanced, see reference/scenarios.md
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
`client` is generic: `RudderClient<TConfig extends Record<string, unknown>>`
|
Missing `baseUrl`/`projectKey` throws `RudderError` with
|
||||||
types `client.remoteConfig.get()`. Missing `baseUrl`/`projectKey` throws
|
`code: 'sdk/invalid-options'` from the constructor.
|
||||||
`RudderError` with `code: 'sdk/invalid-options'` from the constructor.
|
|
||||||
|
|
||||||
Call `client.dispose()` on unmount/HMR to stop the sync poll, wait timers, and
|
Call `client.dispose()` on unmount/HMR to stop the sync poll and drop cached
|
||||||
drop cached state.
|
state.
|
||||||
|
|
||||||
## Auth essentials
|
## Auth essentials
|
||||||
|
|
||||||
@@ -54,14 +57,13 @@ await client.auth.loginWithDevice({ region, language, nickname }); // device id
|
|||||||
await client.auth.loginWithGoogle({ idToken, region, language, nickname });
|
await client.auth.loginWithGoogle({ idToken, region, language, nickname });
|
||||||
await client.auth.loginWithApple({ idToken, region, language, nickname });
|
await client.auth.loginWithApple({ idToken, region, language, nickname });
|
||||||
await client.auth.loginWithCustom({ customData, provider?, region, language, nickname });
|
await client.auth.loginWithCustom({ customData, provider?, region, language, nickname });
|
||||||
// identity linking (requires an active session):
|
|
||||||
await client.auth.linkWithGoogle(idToken);
|
await client.auth.linkWithGoogle(idToken);
|
||||||
await client.auth.linkWithApple(idToken);
|
await client.auth.linkWithApple(idToken);
|
||||||
await client.auth.linkWithCustom({ customData, provider? });
|
await client.auth.linkWithCustom({ customData, provider? });
|
||||||
await client.auth.unlinkIdentity(provider); // 'device' | 'google' | 'apple' | 'custom' | 'custom:<name>'
|
await client.auth.unlinkIdentity(provider); // 'device' | 'google' | 'apple' | 'custom' | 'custom:<name>'
|
||||||
client.auth.logout();
|
client.auth.logout();
|
||||||
client.auth.isAuthenticated; // boolean
|
client.auth.isAuthenticated;
|
||||||
client.auth.onAuthStateChange(cb); // fires immediately, returns unsubscribe
|
client.auth.onAuthStateChange(cb);
|
||||||
```
|
```
|
||||||
|
|
||||||
Linked identities are listed on the profile: `client.player.data.identities`
|
Linked identities are listed on the profile: `client.player.data.identities`
|
||||||
@@ -72,31 +74,28 @@ Tokens are saved to the `TokenStore` automatically. The transport injects
|
|||||||
401; a failed refresh clears tokens and emits `signed-out`. Details:
|
401; a failed refresh clears tokens and emits `signed-out`. Details:
|
||||||
[reference/auth.md](reference/auth.md).
|
[reference/auth.md](reference/auth.md).
|
||||||
|
|
||||||
## Client surface (all domains that exist)
|
## Client surface
|
||||||
|
|
||||||
Every member below is a property on `RudderClient`. There is no UGC, wallet
|
|
||||||
service, or standalone economy service in this SDK — wallets live on
|
|
||||||
`client.player.data.wallets`, and purchases go through `client.stores`.
|
|
||||||
|
|
||||||
| Area | Access | Kind |
|
| Area | Access | Kind |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Auth | `client.auth` | service |
|
| Auth | `client.auth` | service |
|
||||||
| Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
|
| Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
|
||||||
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` |
|
| Catalog currencies | `client.catalog` | observable `SyncedState<Map<string, CatalogCurrency>>` |
|
||||||
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` |
|
|
||||||
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerSlug, opts?)` | observable `SyncedState<ShopHandle[]>` |
|
|
||||||
| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
|
|
||||||
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
|
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
|
||||||
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation |
|
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation |
|
||||||
| Leaderboards | `client.leaderboards.findBySlug(slug)` | service → cached handle |
|
| Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionResponse` |
|
||||||
| Battle pass | `client.battlePass` | plain service (no live sync) |
|
| Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` |
|
||||||
| Quests | `client.quests` | plain service (no live sync) |
|
|
||||||
| Scenario effects | `client.effects.on*` | event subscriptions |
|
There is no UGC, wallet service, or standalone economy service — wallets live
|
||||||
|
on `client.player.data.wallets`. Quests, stores, inventory, leaderboards,
|
||||||
|
battle pass, and remote config are installed modules: call
|
||||||
|
`client.modules.call('quests', 'list')` (etc.) and read rows with
|
||||||
|
`client.resources('items')`.
|
||||||
|
|
||||||
## Observable domains — the shared pattern
|
## Observable domains — the shared pattern
|
||||||
|
|
||||||
`player`, `catalog`, `inventory`, `remoteConfig`, `storage`,
|
`player`, `catalog`, `storage`, and `projectStorage` all extend
|
||||||
`projectStorage`, and `stores` all extend `SyncedState<T>`:
|
`SyncedState<T>`:
|
||||||
|
|
||||||
- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`,
|
- `.data: T | undefined`, `.status: 'idle' | 'loading' | 'ready' | 'error'`,
|
||||||
`.error?: Error`
|
`.error?: Error`
|
||||||
@@ -105,16 +104,12 @@ service, or standalone economy service in this SDK — wallets live on
|
|||||||
- `load(): Promise<T>` — deduplicated, no-op when ready
|
- `load(): Promise<T>` — deduplicated, no-op when ready
|
||||||
- `reload(): Promise<T>` — forced refetch
|
- `reload(): Promise<T>` — forced refetch
|
||||||
|
|
||||||
`remoteConfig`, `player`, `stores`, and `catalog` are warmed once at login;
|
`player` and `catalog` are warmed once at login; `storage` and
|
||||||
`inventory`, `storage`, and `projectStorage` load on first use. All observable
|
`projectStorage` load on first use. All observable domains are then kept fresh
|
||||||
domains are then kept fresh by a revision poll every 30 s (±20% jitter);
|
by a revision poll every 30 s (±20% jitter); polling pauses while the tab is
|
||||||
polling pauses while the tab is hidden. Mutations (purchases, storage writes,
|
hidden. Storage mutations invalidate their domain immediately. Call `reload()`
|
||||||
scenario callbacks) invalidate affected domains immediately. Call `reload()`
|
|
||||||
when you need a freshness guarantee.
|
when you need a freshness guarantee.
|
||||||
|
|
||||||
`leaderboards`, `battlePass`, and `quests` are NOT observable — refetch
|
|
||||||
explicitly after mutations.
|
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
All SDK errors extend `RudderError` (optional machine-readable `code`):
|
All SDK errors extend `RudderError` (optional machine-readable `code`):
|
||||||
@@ -126,21 +121,11 @@ All SDK errors extend `RudderError` (optional machine-readable `code`):
|
|||||||
- `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`)
|
- `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`)
|
||||||
— constructor validation
|
— constructor validation
|
||||||
|
|
||||||
Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`):
|
Server error codes (`RudderErrorCodes`, from `src/generated/errors.ts`) are
|
||||||
`early_completion`, `forbidden`, `level_not_reached`, `node_not_active`,
|
compared with `error.code === RudderErrorCodes.forbidden`.
|
||||||
`objectives_incomplete`, `run_expired`, `run_not_active`,
|
|
||||||
`scenario_not_active`, `unknown_run`. Compare with
|
|
||||||
`error.code === RudderErrorCodes.runExpired`.
|
|
||||||
|
|
||||||
## Reference files
|
## Reference files
|
||||||
|
|
||||||
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
|
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
|
||||||
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
|
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
|
||||||
- [reference/inventory.md](reference/inventory.md) — inventory domain, merged `InventoryItem`, catalog
|
|
||||||
- [reference/stores.md](reference/stores.md) — `ShopHandle`/`OfferHandle`, purchases, idempotency
|
|
||||||
- [reference/remote-config.md](reference/remote-config.md) — typed `get()`, value types, overrides
|
|
||||||
- [reference/storage.md](reference/storage.md) — player + project storage, limits
|
- [reference/storage.md](reference/storage.md) — player + project storage, limits
|
||||||
- [reference/leaderboards.md](reference/leaderboards.md) — handles, submit, list
|
|
||||||
- [reference/quests.md](reference/quests.md) — list/claim/reportProgress, `QuestMetrics`
|
|
||||||
- [reference/battlepass.md](reference/battlepass.md) — progress, XP, claims, premium
|
|
||||||
- [reference/scenarios.md](reference/scenarios.md) — effects, scenario runtime, wait/quest/battlepass nodes
|
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ interface LoginViaAppleResponse { accessToken?: string; refreshToken?: string }
|
|||||||
interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string }
|
interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string }
|
||||||
```
|
```
|
||||||
|
|
||||||
On success both tokens are saved to the client's `TokenStore`, the runtime
|
On success both tokens are saved to the client's `TokenStore`, player and
|
||||||
starts (domains warmed, scenario engine restored, `player_login` event fired),
|
catalog are warmed, the revision poll starts, and the state flips to
|
||||||
and the state flips to `'signed-in'`.
|
`'signed-in'`.
|
||||||
|
|
||||||
## Identity linking
|
## Identity linking
|
||||||
|
|
||||||
@@ -127,8 +127,8 @@ type AuthStateListener = (state: AuthState) => void;
|
|||||||
- `isAuthenticated` is `true` while an access token is present in the token store.
|
- `isAuthenticated` is `true` while an access token is present in the token store.
|
||||||
- `onAuthStateChange` fires the listener **immediately** with the current state
|
- `onAuthStateChange` fires the listener **immediately** with the current state
|
||||||
and returns an unsubscribe function.
|
and returns an unsubscribe function.
|
||||||
- `logout()` clears tokens, stops the runtime (sync poll, scenario runs,
|
- `logout()` clears tokens, stops the revision poll, drops cached domain
|
||||||
cached domain data), and emits `'signed-out'`.
|
data, and emits `'signed-out'`.
|
||||||
|
|
||||||
## Token refresh (automatic, transport level)
|
## Token refresh (automatic, transport level)
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
# Battle pass — `client.battlePass`
|
|
||||||
|
|
||||||
`BattlePassService` (source: `src/battlepass/BattlePassService.ts`).
|
|
||||||
Call-and-response access to battle pass endpoints. Battle pass state is tied to
|
|
||||||
a **scenario battle pass node**, so calls carry `scenarioSlug` + `nodeId` (plus
|
|
||||||
`runId` for mutating calls). NOT observable — re-fetch progress explicitly
|
|
||||||
after a mutation.
|
|
||||||
|
|
||||||
In most games you do not call this service directly: a scenario battle pass
|
|
||||||
node surfaces through `client.effects.onBattlePass` with a session object that
|
|
||||||
wraps these calls (see `reference/scenarios.md`). Use the service directly when
|
|
||||||
you already know the scenario/node/run identifiers.
|
|
||||||
|
|
||||||
## Methods
|
|
||||||
|
|
||||||
```ts
|
|
||||||
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse>
|
|
||||||
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
|
|
||||||
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
|
|
||||||
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Request / response types
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface AddBattlePassXpRequest {
|
|
||||||
amount?: number;
|
|
||||||
nodeId?: string;
|
|
||||||
runId?: string;
|
|
||||||
scenarioSlug?: string;
|
|
||||||
source?: string; // configured XP source
|
|
||||||
}
|
|
||||||
interface AddBattlePassXpResponse {
|
|
||||||
level?: number;
|
|
||||||
leveledUp?: boolean;
|
|
||||||
maxLevel?: boolean;
|
|
||||||
xp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClaimBattlePassRewardRequest {
|
|
||||||
level?: number;
|
|
||||||
nodeId?: string;
|
|
||||||
runId?: string;
|
|
||||||
scenarioSlug?: string;
|
|
||||||
track?: 'free' | 'premium';
|
|
||||||
}
|
|
||||||
interface ClaimBattlePassRewardResponse {
|
|
||||||
alreadyClaimed?: boolean;
|
|
||||||
error?: string;
|
|
||||||
granted?: Reward[]; // { amount?, currency?, itemId? }
|
|
||||||
success?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GetBattlePassProgressResponse {
|
|
||||||
claimedTiers?: ClaimedTier[]; // { level?, track? }
|
|
||||||
level?: number;
|
|
||||||
premiumOwned?: boolean;
|
|
||||||
xp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PurchaseBattlePassPremiumRequest {
|
|
||||||
idempotencyKey?: string;
|
|
||||||
nodeId?: string;
|
|
||||||
runId?: string;
|
|
||||||
scenarioSlug?: string;
|
|
||||||
}
|
|
||||||
interface PurchaseBattlePassPremiumResponse {
|
|
||||||
error?: string;
|
|
||||||
success?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Semantics
|
|
||||||
|
|
||||||
- `addXp` credits XP from a configured source; returns the new `xp`/`level`
|
|
||||||
plus `leveledUp` / `maxLevel` flags.
|
|
||||||
- `claimReward` claims a tier reward at a reached level; idempotent
|
|
||||||
server-side (`alreadyClaimed`). Claiming a tier above the current level (or
|
|
||||||
a premium tier without premium) fails server-side — check `success`/`error`
|
|
||||||
in the response and the typed error `code` (`RudderErrorCodes`).
|
|
||||||
- `purchasePremium` charges the player's wallet; idempotent; pass your own
|
|
||||||
`idempotencyKey` for safe retries.
|
|
||||||
- Prefer the `onBattlePass` effect session, which binds `scenarioSlug` /
|
|
||||||
`nodeId` / `runId` and posts scenario callbacks for you.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Inventory + catalog — `client.inventory`, `client.catalog`
|
|
||||||
|
|
||||||
## Inventory
|
|
||||||
|
|
||||||
`InventoryDomain` (source: `src/domains/InventoryDomain.ts`) — owned items
|
|
||||||
merged with their catalog entries. Synced under revision key `inventory`;
|
|
||||||
also refreshed whenever the catalog changes.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
client.inventory.data // InventoryItem[] | undefined
|
|
||||||
client.inventory.onChange(cb);
|
|
||||||
await client.inventory.load() / .reload();
|
|
||||||
```
|
|
||||||
|
|
||||||
### `InventoryItem` (merged view, source: `src/state/inventory.ts`)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface InventoryItem {
|
|
||||||
slug: string; // '' when the wire item has no slug
|
|
||||||
amount: number; // 0 when absent on the wire
|
|
||||||
name: string; // nameOverride || catalog name || slug
|
|
||||||
properties: Record<string, unknown>; // catalog properties + propertiesOverride (override wins)
|
|
||||||
tags: string[]; // from the catalog entry
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The raw wire shape is `PlayerInventoryItem`
|
|
||||||
(`slug?, amount?, nameOverride?, propertiesOverride?, updatedAt?`) — you rarely
|
|
||||||
need it; the domain hands out the merged `InventoryItem`.
|
|
||||||
|
|
||||||
## Catalog
|
|
||||||
|
|
||||||
`CatalogDomain` (source: `src/domains/CatalogDomain.ts`) — the item catalog
|
|
||||||
keyed by slug. Synced under revision key `catalog`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
client.catalog.data // Map<string, CatalogItem> | undefined
|
|
||||||
|
|
||||||
interface CatalogItem {
|
|
||||||
name?: string;
|
|
||||||
properties?: { [key: string]: unknown };
|
|
||||||
slug?: string;
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Items without a `slug` are skipped when the map is built.
|
|
||||||
|
|
||||||
## Behavior notes
|
|
||||||
|
|
||||||
- The catalog is warmed at login; inventory loads on first use, waits on the
|
|
||||||
catalog load, and merges, so `client.inventory.data` always has catalog
|
|
||||||
fields filled in.
|
|
||||||
- Invalidated after store purchases and scenario callbacks.
|
|
||||||
- Inventory has no client-side mutations — items change via purchases, quest /
|
|
||||||
battle pass rewards, and scenario nodes (all server-side).
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Leaderboards — `client.leaderboards`
|
|
||||||
|
|
||||||
`LeaderboardsService` (source: `src/leaderboards/LeaderboardsService.ts`).
|
|
||||||
Plain call-and-response service, NOT observable — refetch explicitly.
|
|
||||||
|
|
||||||
## Surface
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const board = client.leaderboards.findBySlug('weekly-kills'); // cached handle
|
|
||||||
await board.submit(score);
|
|
||||||
const entries = await board.list(limit?);
|
|
||||||
board.getEntries();
|
|
||||||
```
|
|
||||||
|
|
||||||
## `LeaderboardHandle`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
class LeaderboardHandle {
|
|
||||||
readonly slug: string;
|
|
||||||
|
|
||||||
getEntries(): readonly RankEntry[]; // last fetched list, [] initially
|
|
||||||
submit(score: number): Promise<void>;
|
|
||||||
list(limit = 100): Promise<readonly RankEntry[]>; // fetches and caches
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `findBySlug(slug)` caches handles per slug — repeated calls return the same
|
|
||||||
instance (and its cached entries).
|
|
||||||
- `list(limit)`: `limit <= 0` sends no limit to the server; default is 100.
|
|
||||||
- `submit` does not update the cached entries; call `list()` afterwards to see
|
|
||||||
the effect.
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface RankEntry {
|
|
||||||
playerId?: string;
|
|
||||||
playerName?: string;
|
|
||||||
rank?: number;
|
|
||||||
score?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- No player-around-me or metadata endpoints are exposed by this SDK — submit
|
|
||||||
and top-N list only.
|
|
||||||
- Scenario `leaderboard` nodes surface through
|
|
||||||
`client.effects.onLeaderboard` (`end()`, `rewardClaimed()`) — see
|
|
||||||
`reference/scenarios.md`.
|
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
profile: identity plus currency wallets. Synced under revision key `profile`.
|
profile: identity plus currency wallets. Synced under revision key `profile`.
|
||||||
|
|
||||||
There is no separate wallet service in this SDK. Wallet balances are read from
|
There is no separate wallet service in this SDK. Wallet balances are read from
|
||||||
the profile and are credited/debited server-side (purchases, quest/battle pass
|
the profile and are credited/debited server-side (admin wallet adjust, module
|
||||||
rewards, scenario nodes).
|
functions).
|
||||||
|
|
||||||
## Surface
|
## Surface
|
||||||
|
|
||||||
@@ -51,10 +51,9 @@ interface Wallet {
|
|||||||
|
|
||||||
## Behavior notes
|
## Behavior notes
|
||||||
|
|
||||||
- Warmed automatically at login (one of the four parallel warm loads).
|
- Warmed automatically at login (with catalog).
|
||||||
- Invalidated (refetched) after every successful store purchase and after
|
- Kept fresh by the revision poll; call `reload()` after a module function
|
||||||
scenario server callbacks — subscribers see fresh balances without waiting
|
that mutates wallets.
|
||||||
for the revision poll.
|
|
||||||
- All fields are optional on the wire (`?`); code defensively.
|
- All fields are optional on the wire (`?`); code defensively.
|
||||||
- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK
|
- React: `useSyncExternalStore` maps directly onto `onChange` (see the SDK
|
||||||
README "React recipe").
|
README "React recipe").
|
||||||
|
|||||||
@@ -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 { RudderContext } from '../core/context.js';
|
||||||
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
|
|
||||||
import { getOrCreateDeviceId } from '../device/DeviceId.js';
|
import { getOrCreateDeviceId } from '../device/DeviceId.js';
|
||||||
import { api } from '../generated/api.js';
|
import { api } from '../generated/api.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -72,7 +71,7 @@ export interface LinkWithCustomOptions {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
|
export class AuthService {
|
||||||
private readonly listeners = new Set<AuthStateListener>();
|
private readonly listeners = new Set<AuthStateListener>();
|
||||||
private state: AuthState;
|
private state: AuthState;
|
||||||
|
|
||||||
@@ -118,6 +117,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
|||||||
region,
|
region,
|
||||||
language,
|
language,
|
||||||
nickname,
|
nickname,
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: this.ctx.options.appVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ctx.options.tokenStore.saveTokens(
|
this.ctx.options.tokenStore.saveTokens(
|
||||||
@@ -145,6 +146,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
|||||||
region,
|
region,
|
||||||
language,
|
language,
|
||||||
nickname,
|
nickname,
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: this.ctx.options.appVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ctx.options.tokenStore.saveTokens(
|
this.ctx.options.tokenStore.saveTokens(
|
||||||
@@ -169,6 +172,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
|||||||
region,
|
region,
|
||||||
language,
|
language,
|
||||||
nickname,
|
nickname,
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: this.ctx.options.appVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ctx.options.tokenStore.saveTokens(
|
this.ctx.options.tokenStore.saveTokens(
|
||||||
@@ -193,6 +198,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
|||||||
region,
|
region,
|
||||||
language,
|
language,
|
||||||
nickname,
|
nickname,
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: this.ctx.options.appVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ctx.options.tokenStore.saveTokens(
|
this.ctx.options.tokenStore.saveTokens(
|
||||||
|
|||||||
@@ -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.
|
* RudderClient — the main entry point for the LiveOps Web SDK.
|
||||||
*
|
*
|
||||||
* Composes the observable domains, the scenario runtime, and the core HTTP
|
* Composes the kernel domains (auth, player, catalog, storage, modules,
|
||||||
* transport that every service uses to reach the LiveOps API gateway.
|
* resources) and the HTTP transport every service uses to reach the API.
|
||||||
*
|
*
|
||||||
* The client builds a {@link RudderContext} first and injects it into every
|
* The client builds a {@link RudderContext} first and injects it into every
|
||||||
* domain and service, so they depend on that narrow surface rather than on the
|
* domain, so they depend on that narrow surface rather than on the client
|
||||||
* client (which keeps the module graph acyclic). Cross-domain wiring (the
|
* (which keeps the module graph acyclic).
|
||||||
* inventory↔catalog link, purchase invalidation) is done here at construction.
|
|
||||||
*
|
|
||||||
* The scenario effects client is loaded lazily on first use — a client that
|
|
||||||
* never logs in never pays for the scenario machinery.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -22,45 +18,32 @@ import type { RudderContext } from '../core/context.js';
|
|||||||
import { request, type RequestContext } from '../transport/request.js';
|
import { request, type RequestContext } from '../transport/request.js';
|
||||||
import { createDefaultTokenStore } from '../token/TokenStore.js';
|
import { createDefaultTokenStore } from '../token/TokenStore.js';
|
||||||
import { AuthService } from '../auth/AuthService.js';
|
import { AuthService } from '../auth/AuthService.js';
|
||||||
import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
|
|
||||||
import { BattlePassService } from '../battlepass/BattlePassService.js';
|
|
||||||
import { QuestsService } from '../quests/QuestsService.js';
|
|
||||||
import type { ScenarioService } from '../scenario/ScenarioService.js';
|
|
||||||
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
|
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
|
||||||
import { SyncEngine } from '../state/SyncEngine.js';
|
import { SyncEngine } from '../state/SyncEngine.js';
|
||||||
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
|
|
||||||
import { PlayerDomain } from '../domains/PlayerDomain.js';
|
import { PlayerDomain } from '../domains/PlayerDomain.js';
|
||||||
import { CatalogDomain } from '../domains/CatalogDomain.js';
|
import { CatalogDomain } from '../domains/CatalogDomain.js';
|
||||||
import { InventoryDomain } from '../domains/InventoryDomain.js';
|
|
||||||
import { ConfigDomain } from '../domains/ConfigDomain.js';
|
|
||||||
import { StorageDomain } from '../domains/StorageDomain.js';
|
import { StorageDomain } from '../domains/StorageDomain.js';
|
||||||
import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
|
import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
|
||||||
import { StoresDomain } from '../domains/StoresDomain.js';
|
import { ModulesDomain } from '../domains/ModulesDomain.js';
|
||||||
|
import { ResourcesDomain, type ResourceHandle } from '../domains/ResourcesDomain.js';
|
||||||
|
|
||||||
type ManagedDomain = { invalidate(): void; reset(): void };
|
type ManagedDomain = { invalidate(): void; reset(): void };
|
||||||
|
|
||||||
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
|
export class RudderClient {
|
||||||
public readonly options: ResolvedRudderClientOptions;
|
public readonly options: ResolvedRudderClientOptions;
|
||||||
|
|
||||||
public readonly auth: AuthService<TConfig>;
|
public readonly auth: AuthService;
|
||||||
public readonly leaderboards: LeaderboardsService;
|
|
||||||
public readonly battlePass: BattlePassService;
|
|
||||||
public readonly quests: QuestsService;
|
|
||||||
public readonly effects: Effects;
|
public readonly effects: Effects;
|
||||||
|
|
||||||
public readonly player: PlayerDomain;
|
public readonly player: PlayerDomain;
|
||||||
public readonly catalog: CatalogDomain;
|
public readonly catalog: CatalogDomain;
|
||||||
public readonly inventory: InventoryDomain;
|
|
||||||
public readonly remoteConfig: ConfigDomain<TConfig>;
|
|
||||||
public readonly storage: StorageDomain;
|
public readonly storage: StorageDomain;
|
||||||
public readonly projectStorage: ProjectStorageDomain;
|
public readonly projectStorage: ProjectStorageDomain;
|
||||||
public readonly stores: StoresDomain;
|
public readonly modules: ModulesDomain;
|
||||||
|
private readonly resourcesDomain: ResourcesDomain;
|
||||||
|
|
||||||
private readonly effectsCenter: EffectsCenter;
|
private readonly effectsCenter: EffectsCenter;
|
||||||
private scenarioRuntime: ScenarioService | null = null;
|
|
||||||
private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
|
|
||||||
private readonly syncEngine: SyncEngine;
|
private readonly syncEngine: SyncEngine;
|
||||||
private readonly loginEvent: string | null;
|
|
||||||
private readonly ctx: RudderContext;
|
private readonly ctx: RudderContext;
|
||||||
private readonly transportContext: RequestContext;
|
private readonly transportContext: RequestContext;
|
||||||
private runtimeStarted = false;
|
private runtimeStarted = false;
|
||||||
@@ -78,13 +61,12 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() };
|
this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() };
|
||||||
this.loginEvent =
|
|
||||||
options.runtime?.loginEvent === undefined ? 'player_login' : options.runtime.loginEvent;
|
|
||||||
|
|
||||||
this.transportContext = {
|
this.transportContext = {
|
||||||
baseUrl: this.options.baseUrl,
|
baseUrl: this.options.baseUrl,
|
||||||
tokenStore: this.options.tokenStore,
|
tokenStore: this.options.tokenStore,
|
||||||
requestTimeoutMs: this.options.requestTimeoutMs,
|
requestTimeoutMs: this.options.requestTimeoutMs,
|
||||||
|
appVersion: this.options.appVersion,
|
||||||
refreshState: {},
|
refreshState: {},
|
||||||
onAuthFailure: () => {
|
onAuthFailure: () => {
|
||||||
this.auth.notifySessionExpired();
|
this.auth.notifySessionExpired();
|
||||||
@@ -105,29 +87,18 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
|||||||
|
|
||||||
this.player = new PlayerDomain(ctx);
|
this.player = new PlayerDomain(ctx);
|
||||||
this.catalog = new CatalogDomain(ctx);
|
this.catalog = new CatalogDomain(ctx);
|
||||||
this.inventory = new InventoryDomain(ctx, this.catalog);
|
|
||||||
this.remoteConfig = new ConfigDomain<TConfig>(ctx);
|
|
||||||
this.storage = new StorageDomain(ctx);
|
this.storage = new StorageDomain(ctx);
|
||||||
this.projectStorage = new ProjectStorageDomain(ctx);
|
this.projectStorage = new ProjectStorageDomain(ctx);
|
||||||
this.stores = new StoresDomain(ctx, () => {
|
this.modules = new ModulesDomain(ctx);
|
||||||
this.player.invalidate();
|
this.resourcesDomain = new ResourcesDomain(ctx);
|
||||||
this.inventory.invalidate();
|
|
||||||
this.stores.invalidate();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.leaderboards = new LeaderboardsService(ctx);
|
|
||||||
this.battlePass = new BattlePassService(ctx);
|
|
||||||
this.quests = new QuestsService(ctx);
|
|
||||||
this.syncEngine = new SyncEngine(ctx, [
|
this.syncEngine = new SyncEngine(ctx, [
|
||||||
this.player,
|
this.player,
|
||||||
this.catalog,
|
this.catalog,
|
||||||
this.inventory,
|
|
||||||
this.remoteConfig,
|
|
||||||
this.storage,
|
this.storage,
|
||||||
this.projectStorage,
|
this.projectStorage,
|
||||||
this.stores,
|
|
||||||
]);
|
]);
|
||||||
this.auth = new AuthService<TConfig>(
|
this.auth = new AuthService(
|
||||||
ctx,
|
ctx,
|
||||||
() => this.startRuntimeAfterLogin(),
|
() => this.startRuntimeAfterLogin(),
|
||||||
() => this.stopRuntimeAfterLogout(),
|
() => this.stopRuntimeAfterLogout(),
|
||||||
@@ -136,81 +107,33 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
|||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.syncEngine.stop();
|
this.syncEngine.stop();
|
||||||
this.scenarioRuntime?.clear();
|
|
||||||
this.resetAll();
|
this.resetAll();
|
||||||
this.runtimeStarted = false;
|
this.runtimeStarted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resources(slug: string): ResourceHandle {
|
||||||
|
return this.resourcesDomain.of(slug);
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
async startRuntimeAfterLogin(): Promise<void> {
|
async startRuntimeAfterLogin(): Promise<void> {
|
||||||
const runtimePromise = this.ensureScenarioRuntime();
|
|
||||||
if (this.runtimeStarted) {
|
if (this.runtimeStarted) {
|
||||||
this.scenarioRuntime?.clear();
|
|
||||||
this.invalidateAll();
|
this.invalidateAll();
|
||||||
}
|
}
|
||||||
this.runtimeStarted = true;
|
this.runtimeStarted = true;
|
||||||
await Promise.all([
|
await Promise.all([this.player.load(), this.catalog.load()]);
|
||||||
this.remoteConfig.load(),
|
|
||||||
this.player.load(),
|
|
||||||
this.stores.load(),
|
|
||||||
this.catalog.load(),
|
|
||||||
]);
|
|
||||||
const runtime = await runtimePromise;
|
|
||||||
await runtime.start();
|
|
||||||
if (this.loginEvent) {
|
|
||||||
await this.sendRuntimeEvent(runtime, this.loginEvent);
|
|
||||||
}
|
|
||||||
this.syncEngine.start();
|
this.syncEngine.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureScenarioRuntime(): Promise<ScenarioService> {
|
|
||||||
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
|
|
||||||
return this.scenarioRuntimePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createScenarioRuntime(): Promise<ScenarioService> {
|
|
||||||
const { ScenarioService } = await import('../scenario/ScenarioService.js');
|
|
||||||
this.scenarioRuntime = new ScenarioService(
|
|
||||||
this.ctx,
|
|
||||||
{
|
|
||||||
player: this.player,
|
|
||||||
inventory: this.inventory,
|
|
||||||
stores: this.stores,
|
|
||||||
},
|
|
||||||
this.battlePass,
|
|
||||||
);
|
|
||||||
return this.scenarioRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendRuntimeEvent(
|
|
||||||
runtime: ScenarioService,
|
|
||||||
event: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await runtime.send(event);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`RudderClient: scenario event "${event}" failed after login`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
stopRuntimeAfterLogout(): void {
|
stopRuntimeAfterLogout(): void {
|
||||||
this.runtimeStarted = false;
|
this.runtimeStarted = false;
|
||||||
this.syncEngine.stop();
|
this.syncEngine.stop();
|
||||||
this.scenarioRuntime?.clear();
|
|
||||||
this.resetAll();
|
this.resetAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
private get managedDomains(): ManagedDomain[] {
|
private get managedDomains(): ManagedDomain[] {
|
||||||
return [
|
return [this.player, this.catalog, this.storage, this.projectStorage];
|
||||||
this.player,
|
|
||||||
this.catalog,
|
|
||||||
this.inventory,
|
|
||||||
this.remoteConfig,
|
|
||||||
this.storage,
|
|
||||||
this.projectStorage,
|
|
||||||
this.stores,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private invalidateAll(): void {
|
private invalidateAll(): void {
|
||||||
@@ -221,18 +144,3 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
|||||||
for (const domain of this.managedDomains) domain.reset();
|
for (const domain of this.managedDomains) domain.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Test/internal access to the scenario runtime, which is deliberately not part
|
|
||||||
* of the public client surface (it is driven through `client.effects`).
|
|
||||||
* Lazily loads the scenario effects client on first call.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
|
||||||
client: RudderClient<TConfig>,
|
|
||||||
): Promise<ScenarioService> {
|
|
||||||
return (
|
|
||||||
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
|
|
||||||
).ensureScenarioRuntime();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { TokenStore } from '../token/TokenStore.js';
|
import type { TokenStore } from '../token/TokenStore.js';
|
||||||
|
|
||||||
export interface RudderRuntimeOptions {
|
|
||||||
loginEvent?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RudderClientOptions {
|
export interface RudderClientOptions {
|
||||||
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
|
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -18,6 +14,12 @@ export interface RudderClientOptions {
|
|||||||
*/
|
*/
|
||||||
tokenStore?: TokenStore;
|
tokenStore?: TokenStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game build version sent as `appVersion` on every login and token refresh
|
||||||
|
* (used by segments). Omitted when not set.
|
||||||
|
*/
|
||||||
|
appVersion?: string;
|
||||||
|
|
||||||
/** Per-request timeout in milliseconds (default: 10000). */
|
/** Per-request timeout in milliseconds (default: 10000). */
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
|
|
||||||
@@ -29,9 +31,6 @@ export interface RudderClientOptions {
|
|||||||
* Defaults to `console.error`.
|
* Defaults to `console.error`.
|
||||||
*/
|
*/
|
||||||
onEffectError?: (error: unknown) => void;
|
onEffectError?: (error: unknown) => void;
|
||||||
|
|
||||||
/** Advanced runtime knobs (login event). */
|
|
||||||
runtime?: RudderRuntimeOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Client options with all defaults resolved — what `client.options` exposes. */
|
/** Client options with all defaults resolved — what `client.options` exposes. */
|
||||||
|
|||||||
@@ -3,15 +3,13 @@
|
|||||||
*
|
*
|
||||||
* All errors extend `RudderError`. Network errors, HTTP errors, and
|
* All errors extend `RudderError`. Network errors, HTTP errors, and
|
||||||
* authentication errors have dedicated subclasses for granular handling.
|
* authentication errors have dedicated subclasses for granular handling.
|
||||||
* The machine-readable `code` is the server-provided {@link RudderErrorCode}
|
* The machine-readable `code` is the server-provided code (open-ended: platform
|
||||||
* when known, or any other string for unknown servers / SDK-local codes
|
* codes and module business error codes) or an SDK-local code
|
||||||
* (e.g. `sdk/invalid-options` for client configuration errors).
|
* (e.g. `sdk/invalid-options` for client configuration errors).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RudderErrorCode } from '../generated/errors.js';
|
/** Code carried by SDK errors: a server code or an SDK-local code. */
|
||||||
|
export type RudderErrorCodeLike = string;
|
||||||
/** Code carried by SDK errors: a known server code, or any other string. */
|
|
||||||
export type RudderErrorCodeLike = RudderErrorCode | (string & {});
|
|
||||||
|
|
||||||
/** Code for client-side configuration validation failures. */
|
/** Code for client-side configuration validation failures. */
|
||||||
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
|
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { SyncedState } from '../state/SyncedState.js';
|
import { SyncedState } from '../state/SyncedState.js';
|
||||||
import type { RudderContext } from '../core/context.js';
|
import type { RudderContext } from '../core/context.js';
|
||||||
import { api } from '../generated/api.js';
|
import { api } from '../generated/api.js';
|
||||||
import type { CatalogItem } from '../generated/catalog.js';
|
import type { CatalogCurrency } from '../generated/catalog.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CatalogDomain — the item catalog, keyed by slug. Synced under revision
|
* CatalogDomain — currency definitions, keyed by slug. Synced under revision
|
||||||
* key `catalog`. Feeds the merged {@link InventoryDomain} view.
|
* key `catalog`.
|
||||||
*/
|
*/
|
||||||
export class CatalogDomain extends SyncedState<Map<string, CatalogItem>> {
|
export class CatalogDomain extends SyncedState<Map<string, CatalogCurrency>> {
|
||||||
readonly syncKey = 'catalog';
|
readonly syncKey = 'catalog';
|
||||||
|
|
||||||
constructor(ctx: RudderContext) {
|
constructor(ctx: RudderContext) {
|
||||||
super(async () => {
|
super(async () => {
|
||||||
const response = await api.listCatalogItems(ctx);
|
const response = await api.listCatalogCurrencies(ctx);
|
||||||
const map = new Map<string, CatalogItem>();
|
const map = new Map<string, CatalogCurrency>();
|
||||||
for (const item of response.items ?? []) {
|
for (const currency of response.currencies ?? []) {
|
||||||
if (!item.slug) continue;
|
if (!currency.slug) continue;
|
||||||
map.set(item.slug, item);
|
map.set(currency.slug, currency);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 type EffectUnsubscribe = () => void;
|
||||||
|
|
||||||
export interface NotificationEffect {
|
export interface NotificationEffect {
|
||||||
@@ -15,25 +6,6 @@ export interface NotificationEffect {
|
|||||||
done(): Promise<void>;
|
done(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoreOfferEffect {
|
|
||||||
readonly store: ShopHandle;
|
|
||||||
readonly offers: readonly OfferHandle[];
|
|
||||||
readonly message?: string;
|
|
||||||
buy(offer: OfferHandle, options?: BuyOptions): Promise<PurchaseOfferResponse>;
|
|
||||||
dismiss(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeaderboardEffect {
|
|
||||||
end(): Promise<void>;
|
|
||||||
claim(): Promise<void>;
|
|
||||||
/** @deprecated Use {@link LeaderboardEffect.claim}. Removed in the next SDK version. */
|
|
||||||
rewardClaimed(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigChangedEffect {
|
|
||||||
readonly key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WaitEffect {
|
export interface WaitEffect {
|
||||||
readonly deadlineUtc: Date;
|
readonly deadlineUtc: Date;
|
||||||
}
|
}
|
||||||
@@ -50,52 +22,20 @@ export interface ScenarioFailedEffect {
|
|||||||
readonly error: Error;
|
readonly error: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestEffect {
|
|
||||||
readonly name: string;
|
|
||||||
readonly objectives: ReadonlyArray<Record<string, unknown>>;
|
|
||||||
reportProgress(objectiveId: string, amount?: number): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BattlePassEffect {
|
|
||||||
getProgress(): Promise<GetBattlePassProgressResponse>;
|
|
||||||
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
|
|
||||||
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
|
|
||||||
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
|
|
||||||
levelUp(): Promise<void>;
|
|
||||||
end(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BattlePassLevelEffect {
|
|
||||||
readonly level: number;
|
|
||||||
claim(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
|
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
|
||||||
|
|
||||||
export interface Effects {
|
export interface Effects {
|
||||||
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
|
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
|
||||||
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe;
|
|
||||||
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe;
|
|
||||||
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe;
|
|
||||||
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
|
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
|
||||||
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
|
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
|
||||||
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
|
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
|
||||||
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe;
|
|
||||||
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe;
|
|
||||||
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EffectsCenter implements Effects {
|
export class EffectsCenter implements Effects {
|
||||||
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
|
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
|
||||||
private readonly storeOfferHandlers = new Set<EffectHandler<StoreOfferEffect>>();
|
|
||||||
private readonly leaderboardHandlers = new Set<EffectHandler<LeaderboardEffect>>();
|
|
||||||
private readonly configChangedHandlers = new Set<EffectHandler<ConfigChangedEffect>>();
|
|
||||||
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
|
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
|
||||||
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
|
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
|
||||||
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
|
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
|
||||||
private readonly questHandlers = new Set<EffectHandler<QuestEffect>>();
|
|
||||||
private readonly battlePassHandlers = new Set<EffectHandler<BattlePassEffect>>();
|
|
||||||
private readonly battlePassLevelHandlers = new Set<EffectHandler<BattlePassLevelEffect>>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly onError: (error: unknown) => void = (error) =>
|
private readonly onError: (error: unknown) => void = (error) =>
|
||||||
@@ -106,18 +46,6 @@ export class EffectsCenter implements Effects {
|
|||||||
return this.addHandler(this.notificationHandlers, handler);
|
return this.addHandler(this.notificationHandlers, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.storeOfferHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.leaderboardHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.configChangedHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
|
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
|
||||||
return this.addHandler(this.waitHandlers, handler);
|
return this.addHandler(this.waitHandlers, handler);
|
||||||
}
|
}
|
||||||
@@ -130,40 +58,11 @@ export class EffectsCenter implements Effects {
|
|||||||
return this.addHandler(this.scenarioFailedHandlers, handler);
|
return this.addHandler(this.scenarioFailedHandlers, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.questHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.battlePassHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.battlePassLevelHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
emitNotification(effect: NotificationEffect): void {
|
emitNotification(effect: NotificationEffect): void {
|
||||||
this.emit(this.notificationHandlers, effect);
|
this.emit(this.notificationHandlers, effect);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
|
|
||||||
Promise.resolve(effect)
|
|
||||||
.then((value) => this.emit(this.storeOfferHandlers, value))
|
|
||||||
.catch((error) => this.onError(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitLeaderboard(effect: LeaderboardEffect): void {
|
|
||||||
this.emit(this.leaderboardHandlers, effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitConfigChanged(key: string): void {
|
|
||||||
this.emit(this.configChangedHandlers, { key });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
emitWait(effect: WaitEffect): void {
|
emitWait(effect: WaitEffect): void {
|
||||||
this.emit(this.waitHandlers, effect);
|
this.emit(this.waitHandlers, effect);
|
||||||
@@ -179,21 +78,6 @@ export class EffectsCenter implements Effects {
|
|||||||
this.emit(this.scenarioFailedHandlers, effect);
|
this.emit(this.scenarioFailedHandlers, effect);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitQuest(effect: QuestEffect): void {
|
|
||||||
this.emit(this.questHandlers, effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitBattlePass(effect: BattlePassEffect): void {
|
|
||||||
this.emit(this.battlePassHandlers, effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitBattlePassLevel(effect: BattlePassLevelEffect): void {
|
|
||||||
this.emit(this.battlePassLevelHandlers, effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
private addHandler<TEffect>(
|
private addHandler<TEffect>(
|
||||||
handlers: Set<EffectHandler<TEffect>>,
|
handlers: Set<EffectHandler<TEffect>>,
|
||||||
handler: EffectHandler<TEffect>,
|
handler: EffectHandler<TEffect>,
|
||||||
|
|||||||
+33
-111
@@ -1,17 +1,12 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, LoginViaAppleRequest, LoginViaAppleResponse, LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, LoginViaGoogleRequest, LoginViaGoogleResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse, UnlinkIdentityRequest } from './auth.js';
|
import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, LoginViaAppleRequest, LoginViaAppleResponse, LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, LoginViaGoogleRequest, LoginViaGoogleResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse, UnlinkIdentityRequest } from './auth.js';
|
||||||
import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js';
|
import type { ListCatalogCurrenciesResponse } from './catalog.js';
|
||||||
import type { ListCatalogItemsResponse } from './catalog.js';
|
import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
|
||||||
import type { GetInventoryResponse } from './inventory.js';
|
|
||||||
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
|
|
||||||
import type { PlayerProfile } from './player.js';
|
import type { PlayerProfile } from './player.js';
|
||||||
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
|
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
|
||||||
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js';
|
import type { CreateResourceRowRequest, ListResourceRowsResponse, ResourceRow, UpdateResourceRowRequest } from './resources.js';
|
||||||
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
|
|
||||||
import type { HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, ListPendingScenarioEffectsResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
|
|
||||||
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
|
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
|
||||||
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
|
|
||||||
|
|
||||||
/** The minimal transport contract the generated API functions call through. */
|
/** The minimal transport contract the generated API functions call through. */
|
||||||
export interface Transport {
|
export interface Transport {
|
||||||
@@ -36,23 +31,25 @@ function qs(
|
|||||||
|
|
||||||
/** Typed request functions, one per SDK operation. */
|
/** Typed request functions, one per SDK operation. */
|
||||||
export const api = {
|
export const api = {
|
||||||
addBattlePassXp: (
|
callModuleFunction: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
body: AddBattlePassXpRequest,
|
path: { module: string; fn: string },
|
||||||
): Promise<AddBattlePassXpResponse> =>
|
body: CallModuleFunctionRequest,
|
||||||
t.request<AddBattlePassXpResponse>('POST', '/sdk/v1/battlepass/xp', body),
|
): Promise<CallModuleFunctionClientResponse> =>
|
||||||
|
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
|
||||||
|
|
||||||
claimBattlePassReward: (
|
createResourceRow: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
body: ClaimBattlePassRewardRequest,
|
path: { slug: string },
|
||||||
): Promise<ClaimBattlePassRewardResponse> =>
|
body: CreateResourceRowRequest,
|
||||||
t.request<ClaimBattlePassRewardResponse>('POST', '/sdk/v1/battlepass/claim', body),
|
): Promise<ResourceRow> =>
|
||||||
|
t.request<ResourceRow>('POST', `/sdk/v1/resources/${enc(path.slug)}`, body),
|
||||||
|
|
||||||
claimQuest: (
|
deleteResourceRow: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
body: ClaimQuestRequest,
|
path: { slug: string; id: string },
|
||||||
): Promise<ClaimQuestResponse> =>
|
): Promise<void> =>
|
||||||
t.request<ClaimQuestResponse>('POST', '/sdk/v1/quests/claim', body),
|
t.request<void>('DELETE', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
|
||||||
|
|
||||||
deleteStorage: (
|
deleteStorage: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
@@ -60,17 +57,6 @@ export const api = {
|
|||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
|
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
|
||||||
|
|
||||||
getBattlePassProgress: (
|
|
||||||
t: Transport,
|
|
||||||
body: GetBattlePassProgressRequest,
|
|
||||||
): Promise<GetBattlePassProgressResponse> =>
|
|
||||||
t.request<GetBattlePassProgressResponse>('POST', '/sdk/v1/battlepass/progress', body),
|
|
||||||
|
|
||||||
getInventory: (
|
|
||||||
t: Transport,
|
|
||||||
): Promise<GetInventoryResponse> =>
|
|
||||||
t.request<GetInventoryResponse>('GET', '/sdk/v1/inventory'),
|
|
||||||
|
|
||||||
getPlayerInformation: (
|
getPlayerInformation: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
): Promise<PlayerProfile> =>
|
): Promise<PlayerProfile> =>
|
||||||
@@ -82,18 +68,11 @@ export const api = {
|
|||||||
): Promise<GetProjectStorageResponse> =>
|
): Promise<GetProjectStorageResponse> =>
|
||||||
t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
||||||
|
|
||||||
getRanking: (
|
getResourceRow: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
path: { slug: string },
|
path: { slug: string; id: string },
|
||||||
query?: { limit?: number },
|
): Promise<ResourceRow> =>
|
||||||
): Promise<GetRankingResponse> =>
|
t.request<ResourceRow>('GET', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
|
||||||
t.request<GetRankingResponse>('GET', `/sdk/v1/leaderboards/${enc(path.slug)}/ranking${qs([['limit', query?.limit]])}`),
|
|
||||||
|
|
||||||
getRemoteConfig: (
|
|
||||||
t: Transport,
|
|
||||||
path: { key: string },
|
|
||||||
): Promise<RemoteConfig> =>
|
|
||||||
t.request<RemoteConfig>('GET', `/sdk/v1/remote-configs/${enc(path.key)}`),
|
|
||||||
|
|
||||||
getRevisions: (
|
getRevisions: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
@@ -106,18 +85,6 @@ export const api = {
|
|||||||
): Promise<GetStorageResponse> =>
|
): Promise<GetStorageResponse> =>
|
||||||
t.request<GetStorageResponse>('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
t.request<GetStorageResponse>('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
||||||
|
|
||||||
getStore: (
|
|
||||||
t: Transport,
|
|
||||||
path: { slug: string },
|
|
||||||
): Promise<Store> =>
|
|
||||||
t.request<Store>('GET', `/sdk/v1/stores/${enc(path.slug)}`),
|
|
||||||
|
|
||||||
handleScenarioCallback: (
|
|
||||||
t: Transport,
|
|
||||||
body: HandleScenarioCallbackRequest,
|
|
||||||
): Promise<HandleScenarioCallbackResponse> =>
|
|
||||||
t.request<HandleScenarioCallbackResponse>('POST', '/sdk/v1/scenarios/callback', body),
|
|
||||||
|
|
||||||
linkViaApple: (
|
linkViaApple: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
body: LinkViaAppleRequest,
|
body: LinkViaAppleRequest,
|
||||||
@@ -136,30 +103,16 @@ export const api = {
|
|||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
t.request<void>('POST', '/sdk/v1/authorization/google/link', body),
|
t.request<void>('POST', '/sdk/v1/authorization/google/link', body),
|
||||||
|
|
||||||
listCatalogItems: (
|
listCatalogCurrencies: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
): Promise<ListCatalogItemsResponse> =>
|
): Promise<ListCatalogCurrenciesResponse> =>
|
||||||
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
|
t.request<ListCatalogCurrenciesResponse>('GET', '/sdk/v1/catalog'),
|
||||||
|
|
||||||
listPendingScenarioEffects: (
|
listResourceRows: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
): Promise<ListPendingScenarioEffectsResponse> =>
|
path: { slug: string },
|
||||||
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
|
): Promise<ListResourceRowsResponse> =>
|
||||||
|
t.request<ListResourceRowsResponse>('GET', `/sdk/v1/resources/${enc(path.slug)}`),
|
||||||
listQuests: (
|
|
||||||
t: Transport,
|
|
||||||
): Promise<ListQuestsResponse> =>
|
|
||||||
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list'),
|
|
||||||
|
|
||||||
listSdkRemoteConfigs: (
|
|
||||||
t: Transport,
|
|
||||||
): Promise<ListRemoteConfigsResponse> =>
|
|
||||||
t.request<ListRemoteConfigsResponse>('GET', '/sdk/v1/remote-configs'),
|
|
||||||
|
|
||||||
listSdkStores: (
|
|
||||||
t: Transport,
|
|
||||||
): Promise<ListStoresResponse> =>
|
|
||||||
t.request<ListStoresResponse>('GET', '/sdk/v1/stores'),
|
|
||||||
|
|
||||||
loginViaApple: (
|
loginViaApple: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
@@ -185,44 +138,12 @@ export const api = {
|
|||||||
): Promise<LoginViaGoogleResponse> =>
|
): Promise<LoginViaGoogleResponse> =>
|
||||||
t.request<LoginViaGoogleResponse>('POST', '/sdk/v1/authorization/google', body),
|
t.request<LoginViaGoogleResponse>('POST', '/sdk/v1/authorization/google', body),
|
||||||
|
|
||||||
purchaseBattlePassPremium: (
|
|
||||||
t: Transport,
|
|
||||||
body: PurchaseBattlePassPremiumRequest,
|
|
||||||
): Promise<PurchaseBattlePassPremiumResponse> =>
|
|
||||||
t.request<PurchaseBattlePassPremiumResponse>('POST', '/sdk/v1/battlepass/premium', body),
|
|
||||||
|
|
||||||
purchaseOffer: (
|
|
||||||
t: Transport,
|
|
||||||
path: { storeSlug: string; offerSlug: string },
|
|
||||||
body: PurchaseOfferRequest,
|
|
||||||
): Promise<PurchaseOfferResponse> =>
|
|
||||||
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
|
|
||||||
|
|
||||||
refreshAccessToken: (
|
refreshAccessToken: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
body: RefreshAccessTokenRequest,
|
body: RefreshAccessTokenRequest,
|
||||||
): Promise<RefreshAccessTokenResponse> =>
|
): Promise<RefreshAccessTokenResponse> =>
|
||||||
t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body),
|
t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body),
|
||||||
|
|
||||||
reportQuestProgress: (
|
|
||||||
t: Transport,
|
|
||||||
body: ReportQuestProgressRequest,
|
|
||||||
): Promise<ReportQuestProgressResponse> =>
|
|
||||||
t.request<ReportQuestProgressResponse>('POST', '/sdk/v1/quests/progress', body),
|
|
||||||
|
|
||||||
submitScore: (
|
|
||||||
t: Transport,
|
|
||||||
path: { slug: string },
|
|
||||||
body: SubmitScoreRequest,
|
|
||||||
): Promise<void> =>
|
|
||||||
t.request<void>('POST', `/sdk/v1/leaderboards/${enc(path.slug)}/submit-score`, body),
|
|
||||||
|
|
||||||
triggerScenario: (
|
|
||||||
t: Transport,
|
|
||||||
body: TriggerScenarioRequest,
|
|
||||||
): Promise<TriggerScenarioResponse> =>
|
|
||||||
t.request<TriggerScenarioResponse>('POST', '/sdk/v1/scenarios/trigger', body),
|
|
||||||
|
|
||||||
unlinkIdentity: (
|
unlinkIdentity: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
body: UnlinkIdentityRequest,
|
body: UnlinkIdentityRequest,
|
||||||
@@ -235,11 +156,12 @@ export const api = {
|
|||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
t.request<void>('PUT', '/sdk/v1/project-storage', body),
|
t.request<void>('PUT', '/sdk/v1/project-storage', body),
|
||||||
|
|
||||||
updateScenarioCounter: (
|
updateResourceRow: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
body: UpdateScenarioCounterRequest,
|
path: { slug: string; id: string },
|
||||||
): Promise<UpdateScenarioCounterResponse> =>
|
body: UpdateResourceRowRequest,
|
||||||
t.request<UpdateScenarioCounterResponse>('POST', '/sdk/v1/scenarios/counter', body),
|
): Promise<ResourceRow> =>
|
||||||
|
t.request<ResourceRow>('PATCH', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`, body),
|
||||||
|
|
||||||
updateStorage: (
|
updateStorage: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ export interface LinkViaGoogleRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginViaAppleRequest {
|
export interface LoginViaAppleRequest {
|
||||||
|
"appVersion"?: string;
|
||||||
"idToken": string;
|
"idToken": string;
|
||||||
"key": string;
|
"key": string;
|
||||||
"language": string;
|
"language": string;
|
||||||
"nickname"?: string;
|
"nickname"?: string;
|
||||||
|
"platform"?: string;
|
||||||
"region": string;
|
"region": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +29,12 @@ export interface LoginViaAppleResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginViaCustomRequest {
|
export interface LoginViaCustomRequest {
|
||||||
|
"appVersion"?: string;
|
||||||
"customData"?: { [key: string]: unknown };
|
"customData"?: { [key: string]: unknown };
|
||||||
"key"?: string;
|
"key"?: string;
|
||||||
"language"?: string;
|
"language"?: string;
|
||||||
"nickname"?: string;
|
"nickname"?: string;
|
||||||
|
"platform"?: string;
|
||||||
"provider"?: string;
|
"provider"?: string;
|
||||||
"region"?: string;
|
"region"?: string;
|
||||||
}
|
}
|
||||||
@@ -41,10 +45,12 @@ export interface LoginViaCustomResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginViaDeviceRequest {
|
export interface LoginViaDeviceRequest {
|
||||||
|
"appVersion"?: string;
|
||||||
"deviceId"?: string;
|
"deviceId"?: string;
|
||||||
"key"?: string;
|
"key"?: string;
|
||||||
"language"?: string;
|
"language"?: string;
|
||||||
"nickname"?: string;
|
"nickname"?: string;
|
||||||
|
"platform"?: string;
|
||||||
"region"?: string;
|
"region"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +60,12 @@ export interface LoginViaDeviceResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginViaGoogleRequest {
|
export interface LoginViaGoogleRequest {
|
||||||
|
"appVersion"?: string;
|
||||||
"idToken": string;
|
"idToken": string;
|
||||||
"key": string;
|
"key": string;
|
||||||
"language": string;
|
"language": string;
|
||||||
"nickname"?: string;
|
"nickname"?: string;
|
||||||
|
"platform"?: string;
|
||||||
"region": string;
|
"region": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +75,8 @@ export interface LoginViaGoogleResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshAccessTokenRequest {
|
export interface RefreshAccessTokenRequest {
|
||||||
|
"appVersion"?: string;
|
||||||
|
"platform"?: string;
|
||||||
"refreshToken"?: string;
|
"refreshToken"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
export interface CatalogItem {
|
export interface CatalogCurrency {
|
||||||
|
"data"?: { [key: string]: unknown };
|
||||||
|
"icon"?: string;
|
||||||
"name"?: string;
|
"name"?: string;
|
||||||
"properties"?: { [key: string]: unknown };
|
|
||||||
"slug"?: string;
|
"slug"?: string;
|
||||||
"tags"?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListCatalogItemsResponse {
|
export interface ListCatalogCurrenciesResponse {
|
||||||
"items"?: CatalogItem[];
|
"currencies"?: CatalogCurrency[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
import type { ResourceRowUsage } from './resources.js';
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
"code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
|
"code"?: "forbidden" | "row_referenced";
|
||||||
"error"?: string;
|
"error"?: string;
|
||||||
"index"?: number;
|
"index"?: number;
|
||||||
|
"path"?: string;
|
||||||
"playerId"?: string;
|
"playerId"?: string;
|
||||||
"requestId"?: string;
|
"requestId"?: string;
|
||||||
}
|
"usages"?: ResourceRowUsage[];
|
||||||
|
|
||||||
export interface Reward {
|
|
||||||
"amount"?: number;
|
|
||||||
"currency"?: string;
|
|
||||||
"itemId"?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
export type RudderErrorCode = "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
|
export type RudderErrorCode = "forbidden" | "row_referenced";
|
||||||
|
|
||||||
export const RudderErrorCodes = {
|
export const RudderErrorCodes = {
|
||||||
earlyCompletion: "early_completion",
|
|
||||||
forbidden: "forbidden",
|
forbidden: "forbidden",
|
||||||
levelNotReached: "level_not_reached",
|
rowReferenced: "row_referenced",
|
||||||
nodeNotActive: "node_not_active",
|
|
||||||
objectivesIncomplete: "objectives_incomplete",
|
|
||||||
runExpired: "run_expired",
|
|
||||||
runNotActive: "run_not_active",
|
|
||||||
scenarioNotActive: "scenario_not_active",
|
|
||||||
unknownRun: "unknown_run",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
export * from './auth.js';
|
export * from './auth.js';
|
||||||
export * from './battlepass.js';
|
|
||||||
export * from './catalog.js';
|
export * from './catalog.js';
|
||||||
export * from './common.js';
|
export * from './common.js';
|
||||||
export * from './inventory.js';
|
export * from './modules.js';
|
||||||
export * from './leaderboards.js';
|
|
||||||
export * from './player.js';
|
export * from './player.js';
|
||||||
export * from './project-storage.js';
|
export * from './project-storage.js';
|
||||||
export * from './quests.js';
|
export * from './resources.js';
|
||||||
export * from './remote-config.js';
|
|
||||||
export * from './scenarios.js';
|
|
||||||
export * from './storage.js';
|
export * from './storage.js';
|
||||||
export * from './stores.js';
|
|
||||||
export * from './api.js';
|
export * from './api.js';
|
||||||
export * from './errors.js';
|
export * from './errors.js';
|
||||||
|
|||||||
@@ -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 type { TokenStore } from './token/TokenStore.js';
|
||||||
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
|
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
|
||||||
|
|
||||||
// Services — types only; instances live on the client
|
// Services — types only; instances live on the client (client.auth).
|
||||||
// (client.auth, client.leaderboards, client.battlePass, client.quests).
|
|
||||||
export type {
|
export type {
|
||||||
AuthService,
|
AuthService,
|
||||||
AuthState,
|
AuthState,
|
||||||
@@ -28,10 +27,6 @@ export type {
|
|||||||
LoginWithDeviceOptions,
|
LoginWithDeviceOptions,
|
||||||
LoginWithGoogleOptions,
|
LoginWithGoogleOptions,
|
||||||
} from './auth/AuthService.js';
|
} from './auth/AuthService.js';
|
||||||
export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js';
|
|
||||||
export type { BattlePassService } from './battlepass/BattlePassService.js';
|
|
||||||
export type { QuestsService } from './quests/QuestsService.js';
|
|
||||||
export * as QuestMetrics from './quests/QuestMetrics.js';
|
|
||||||
|
|
||||||
// Observable state primitives
|
// Observable state primitives
|
||||||
export { SyncedState } from './state/SyncedState.js';
|
export { SyncedState } from './state/SyncedState.js';
|
||||||
@@ -40,74 +35,43 @@ export type {
|
|||||||
SyncedStatus,
|
SyncedStatus,
|
||||||
SyncedListener,
|
SyncedListener,
|
||||||
} from './state/SyncedState.js';
|
} from './state/SyncedState.js';
|
||||||
export { RemoteConfigState } from './state/RemoteConfigState.js';
|
|
||||||
export type { RemoteConfigShape } from './state/RemoteConfigState.js';
|
|
||||||
export { ShopHandle, OfferHandle } from './state/shops.js';
|
|
||||||
export type { BuyOptions } from './state/shops.js';
|
|
||||||
export type { InventoryItem } from './state/inventory.js';
|
|
||||||
|
|
||||||
// Domains — types only; instances live on the client
|
// Domains — types only; instances live on the client
|
||||||
// (client.player, client.stores, client.remoteConfig, …).
|
// (client.player, client.modules, client.resources(slug), …).
|
||||||
export type { PlayerDomain } from './domains/PlayerDomain.js';
|
export type { PlayerDomain } from './domains/PlayerDomain.js';
|
||||||
export type { CatalogDomain } from './domains/CatalogDomain.js';
|
export type { CatalogDomain } from './domains/CatalogDomain.js';
|
||||||
export type { InventoryDomain } from './domains/InventoryDomain.js';
|
export type { ModulesDomain } from './domains/ModulesDomain.js';
|
||||||
export type { ConfigDomain } from './domains/ConfigDomain.js';
|
export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.js';
|
||||||
export type { StorageDomain } from './domains/StorageDomain.js';
|
export type { StorageDomain } from './domains/StorageDomain.js';
|
||||||
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
|
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
|
||||||
export type { StoresDomain } from './domains/StoresDomain.js';
|
|
||||||
|
|
||||||
// Effects
|
// Effects
|
||||||
export type {
|
export type {
|
||||||
ConfigChangedEffect,
|
|
||||||
Effects,
|
Effects,
|
||||||
EffectUnsubscribe,
|
EffectUnsubscribe,
|
||||||
LeaderboardEffect,
|
|
||||||
NotificationEffect,
|
NotificationEffect,
|
||||||
StoreOfferEffect,
|
|
||||||
WaitEffect,
|
WaitEffect,
|
||||||
ScenarioCompletedEffect,
|
ScenarioCompletedEffect,
|
||||||
ScenarioFailedEffect,
|
ScenarioFailedEffect,
|
||||||
QuestEffect,
|
|
||||||
BattlePassEffect,
|
|
||||||
BattlePassLevelEffect,
|
|
||||||
} from './effects/EffectsCenter.js';
|
} from './effects/EffectsCenter.js';
|
||||||
|
|
||||||
// Generated wire types — the shapes returned by the observable state and
|
// Generated wire types — the shapes returned by the observable state and
|
||||||
// handles, so consumers can name them instead of re-declaring mirrors.
|
// handles, so consumers can name them instead of re-declaring mirrors.
|
||||||
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
|
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
|
||||||
export type { CatalogItem } from './generated/catalog.js';
|
export type { CatalogCurrency } from './generated/catalog.js';
|
||||||
export type { PlayerInventoryItem } from './generated/inventory.js';
|
|
||||||
export type {
|
|
||||||
Store,
|
|
||||||
Offer,
|
|
||||||
OfferContent,
|
|
||||||
OfferPrice,
|
|
||||||
PurchaseOfferResponse,
|
|
||||||
} from './generated/stores.js';
|
|
||||||
export type { RankEntry } from './generated/leaderboards.js';
|
|
||||||
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
|
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
|
||||||
export type {
|
export type {
|
||||||
ProjectStorageItem,
|
ProjectStorageItem,
|
||||||
GetProjectStorageResponse,
|
GetProjectStorageResponse,
|
||||||
ProjectStorageUpdateItem,
|
ProjectStorageUpdateItem,
|
||||||
} from './generated/project-storage.js';
|
} from './generated/project-storage.js';
|
||||||
export type { RemoteConfig } from './generated/remote-config.js';
|
|
||||||
export type { Reward } from './generated/common.js';
|
|
||||||
export type {
|
export type {
|
||||||
Quest,
|
CallModuleFunctionRequest,
|
||||||
QuestObjectiveProgress,
|
CallModuleFunctionClientResponse,
|
||||||
ClaimQuestResponse,
|
} from './generated/modules.js';
|
||||||
} from './generated/quests.js';
|
|
||||||
export type {
|
export type {
|
||||||
ClaimedTier,
|
ResourceRow,
|
||||||
AddBattlePassXpRequest,
|
ListResourceRowsResponse,
|
||||||
AddBattlePassXpResponse,
|
CreateResourceRowRequest,
|
||||||
ClaimBattlePassRewardRequest,
|
UpdateResourceRowRequest,
|
||||||
ClaimBattlePassRewardResponse,
|
} from './generated/resources.js';
|
||||||
PurchaseBattlePassPremiumRequest,
|
|
||||||
PurchaseBattlePassPremiumResponse,
|
|
||||||
GetBattlePassProgressRequest,
|
|
||||||
GetBattlePassProgressResponse,
|
|
||||||
} from './generated/battlepass.js';
|
|
||||||
export type { RudderErrorCode } from './generated/errors.js';
|
|
||||||
export { RudderErrorCodes } from './generated/errors.js';
|
|
||||||
|
|||||||
@@ -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;
|
baseUrl: string;
|
||||||
tokenStore: TokenStore;
|
tokenStore: TokenStore;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
|
/** Sent with `platform: 'web'` on token refresh; omitted when not set. */
|
||||||
|
appVersion?: string;
|
||||||
/** Single-flight refresh state shared by all requests of one client. */
|
/** Single-flight refresh state shared by all requests of one client. */
|
||||||
refreshState?: { pending?: Promise<boolean> };
|
refreshState?: { pending?: Promise<boolean> };
|
||||||
/** Called when the refresh flow fails — lets the client stop its runtime. */
|
/** Called when the refresh flow fails — lets the client stop its runtime. */
|
||||||
@@ -168,7 +170,11 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refreshToken } satisfies RefreshAccessTokenRequest),
|
body: JSON.stringify({
|
||||||
|
refreshToken,
|
||||||
|
platform: 'web',
|
||||||
|
appVersion: ctx.appVersion,
|
||||||
|
} satisfies RefreshAccessTokenRequest),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok) return false;
|
if (!response.ok) return false;
|
||||||
|
|||||||
+41
-115
@@ -6,9 +6,30 @@ function jsonResponse(body: unknown): Response {
|
|||||||
return new Response(JSON.stringify(body), { status: 200 });
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubKernelFetch(
|
||||||
|
extra?: (url: string) => Response | undefined,
|
||||||
|
): ReturnType<typeof vi.fn> {
|
||||||
|
return vi.fn().mockImplementation((url: string) => {
|
||||||
|
const custom = extra?.(url);
|
||||||
|
if (custom) return Promise.resolve(custom);
|
||||||
|
if (String(url).includes('/sdk/v1/authorization/device')) {
|
||||||
|
return Promise.resolve(jsonResponse({
|
||||||
|
accessToken: 'access-token-123',
|
||||||
|
refreshToken: 'refresh-token-456',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (String(url).includes('/sdk/v1/catalog')) {
|
||||||
|
return Promise.resolve(jsonResponse({ currencies: [] }));
|
||||||
|
}
|
||||||
|
if (String(url).includes('/sdk/v1/sync')) {
|
||||||
|
return Promise.resolve(jsonResponse({}));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Mock crypto.randomUUID for deterministic device ID.
|
|
||||||
vi.stubGlobal('crypto', {
|
vi.stubGlobal('crypto', {
|
||||||
randomUUID: () => '00000000-0000-4000-a000-000000000001',
|
randomUUID: () => '00000000-0000-4000-a000-000000000001',
|
||||||
});
|
});
|
||||||
@@ -17,20 +38,11 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
it('loginWithDevice sends correct request body', async () => {
|
it('loginWithDevice sends correct request body', async () => {
|
||||||
const client = createTestClient();
|
const client = createTestClient();
|
||||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
const fetchMock = stubKernelFetch();
|
||||||
if (url.includes('/sdk/v1/remote-configs')) {
|
|
||||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
|
||||||
}
|
|
||||||
return Promise.resolve(jsonResponse({
|
|
||||||
accessToken: 'access-token-123',
|
|
||||||
refreshToken: 'refresh-token-456',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
|
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(9);
|
|
||||||
const [url, init] = fetchMock.mock.calls[0];
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
expect(url).toContain('/sdk/v1/authorization/device');
|
expect(url).toContain('/sdk/v1/authorization/device');
|
||||||
const body = JSON.parse(init.body);
|
const body = JSON.parse(init.body);
|
||||||
@@ -40,29 +52,24 @@ describe('AuthService', () => {
|
|||||||
expect(body.language).toBe('en');
|
expect(body.language).toBe('en');
|
||||||
expect(response.accessToken).toBe('access-token-123');
|
expect(response.accessToken).toBe('access-token-123');
|
||||||
expect(response.refreshToken).toBe('refresh-token-456');
|
expect(response.refreshToken).toBe('refresh-token-456');
|
||||||
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
|
const paths = fetchMock.mock.calls.map(([callUrl]) => new URL(String(callUrl)).pathname);
|
||||||
'/sdk/v1/authorization/device',
|
expect(paths[0]).toBe('/sdk/v1/authorization/device');
|
||||||
'/sdk/v1/remote-configs',
|
expect(paths).toContain('/sdk/v1/player/information');
|
||||||
'/sdk/v1/player/information',
|
expect(paths).toContain('/sdk/v1/catalog');
|
||||||
'/sdk/v1/stores',
|
expect(paths).toContain('/sdk/v1/sync');
|
||||||
'/sdk/v1/catalog',
|
expect(paths).not.toContain('/sdk/v1/remote-configs');
|
||||||
'/sdk/v1/scenarios/pending',
|
expect(paths).not.toContain('/sdk/v1/stores');
|
||||||
'/sdk/v1/scenarios/trigger',
|
expect(paths).not.toContain('/sdk/v1/scenarios/pending');
|
||||||
'/sdk/v1/player/information',
|
expect(paths).not.toContain('/sdk/v1/scenarios/trigger');
|
||||||
'/sdk/v1/sync',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loginWithDevice saves tokens on success', async () => {
|
it('loginWithDevice saves tokens on success', async () => {
|
||||||
const client = createTestClient();
|
const client = createTestClient();
|
||||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
vi.stubGlobal('fetch', stubKernelFetch((url) => {
|
||||||
if (url.includes('/sdk/v1/remote-configs')) {
|
if (url.includes('/sdk/v1/authorization/device')) {
|
||||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
return jsonResponse({ accessToken: 'at', refreshToken: 'rt' });
|
||||||
}
|
}
|
||||||
return Promise.resolve(jsonResponse({
|
return undefined;
|
||||||
accessToken: 'at',
|
|
||||||
refreshToken: 'rt',
|
|
||||||
}));
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await client.auth.loginWithDevice();
|
await client.auth.loginWithDevice();
|
||||||
@@ -94,23 +101,16 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
it('loginWithDevice sends token in Authorization header after login', async () => {
|
it('loginWithDevice sends token in Authorization header after login', async () => {
|
||||||
const client = createTestClient();
|
const client = createTestClient();
|
||||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
const fetchMock = stubKernelFetch((url) => {
|
||||||
if (url.includes('/sdk/v1/authorization/device')) {
|
if (url.includes('/sdk/v1/authorization/device')) {
|
||||||
return Promise.resolve(jsonResponse({
|
return jsonResponse({ accessToken: 'at2', refreshToken: 'rt2' });
|
||||||
accessToken: 'at2',
|
|
||||||
refreshToken: 'rt2',
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
if (url.includes('/sdk/v1/remote-configs')) {
|
return undefined;
|
||||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
|
||||||
}
|
|
||||||
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
|
||||||
});
|
});
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
await client.auth.loginWithDevice();
|
await client.auth.loginWithDevice();
|
||||||
|
|
||||||
// Subsequent authenticated calls should include the token.
|
|
||||||
const response = await client.player.reload();
|
const response = await client.player.reload();
|
||||||
expect(response).toEqual({ player: null, wallets: [] });
|
expect(response).toEqual({ player: null, wallets: [] });
|
||||||
const playerCall = fetchMock.mock.calls.find(([url]) =>
|
const playerCall = fetchMock.mock.calls.find(([url]) =>
|
||||||
@@ -120,71 +120,9 @@ describe('AuthService', () => {
|
|||||||
expect(headers['Authorization']).toBe('Bearer at2');
|
expect(headers['Authorization']).toBe('Bearer at2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loginWithDevice only fires player_login from the client runtime', async () => {
|
|
||||||
const client = createTestClient();
|
|
||||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
|
||||||
if (url.includes('/sdk/v1/authorization/device')) {
|
|
||||||
return Promise.resolve(jsonResponse({
|
|
||||||
accessToken: 'at-new',
|
|
||||||
refreshToken: 'rt-new',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (url.includes('/sdk/v1/remote-configs')) {
|
|
||||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
|
||||||
}
|
|
||||||
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
|
||||||
return Promise.resolve(jsonResponse({ plans: [] }));
|
|
||||||
}
|
|
||||||
if (url.includes('/sdk/v1/stores')) {
|
|
||||||
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
|
|
||||||
}
|
|
||||||
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
await client.auth.loginWithDevice();
|
|
||||||
|
|
||||||
const triggerBodies = fetchMock.mock.calls
|
|
||||||
.filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger'))
|
|
||||||
.map(([, init]) => JSON.parse(init.body));
|
|
||||||
expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loginWithDevice isolates post-login scenario trigger failures', async () => {
|
|
||||||
const client = createTestClient();
|
|
||||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
|
||||||
if (url.includes('/sdk/v1/authorization/device')) {
|
|
||||||
return Promise.resolve(jsonResponse({
|
|
||||||
accessToken: 'at',
|
|
||||||
refreshToken: 'rt',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (url.includes('/sdk/v1/remote-configs')) {
|
|
||||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
|
||||||
}
|
|
||||||
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
|
||||||
return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' }));
|
|
||||||
}
|
|
||||||
if (url.includes('/sdk/v1/stores')) {
|
|
||||||
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
|
|
||||||
}
|
|
||||||
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
|
|
||||||
}));
|
|
||||||
|
|
||||||
await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' });
|
|
||||||
expect(warn).toHaveBeenCalled();
|
|
||||||
warn.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loginWithDevice defaults to region global and language en', async () => {
|
it('loginWithDevice defaults to region global and language en', async () => {
|
||||||
const client = createTestClient();
|
const client = createTestClient();
|
||||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
const fetchMock = stubKernelFetch();
|
||||||
if (url.includes('/sdk/v1/remote-configs')) {
|
|
||||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
|
||||||
}
|
|
||||||
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
await client.auth.loginWithDevice();
|
await client.auth.loginWithDevice();
|
||||||
@@ -203,21 +141,9 @@ describe('auth state', () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
function stubLoginFlow(): void {
|
|
||||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
|
||||||
if (url.includes('/sdk/v1/remote-configs')) {
|
|
||||||
return Promise.resolve(jsonResponse({ configs: {} }));
|
|
||||||
}
|
|
||||||
if (url.includes('/sdk/v1/scenarios/trigger')) {
|
|
||||||
return Promise.resolve(jsonResponse({ plans: [] }));
|
|
||||||
}
|
|
||||||
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
it('onAuthStateChange fires immediately, then on login and logout', async () => {
|
it('onAuthStateChange fires immediately, then on login and logout', async () => {
|
||||||
const client = createTestClient();
|
const client = createTestClient();
|
||||||
stubLoginFlow();
|
vi.stubGlobal('fetch', stubKernelFetch());
|
||||||
|
|
||||||
const states: string[] = [];
|
const states: string[] = [];
|
||||||
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
|
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
|
||||||
|
|||||||
+28
-29
@@ -14,20 +14,17 @@ describe('public API surface', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
|
it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
|
||||||
// Device id helper — internal.
|
|
||||||
expect('getOrCreateDeviceId' in sdk).toBe(false);
|
expect('getOrCreateDeviceId' in sdk).toBe(false);
|
||||||
// Domain classes — exported as types only.
|
|
||||||
for (const name of [
|
for (const name of [
|
||||||
'PlayerDomain',
|
'PlayerDomain',
|
||||||
'CatalogDomain',
|
'CatalogDomain',
|
||||||
'InventoryDomain',
|
|
||||||
'ConfigDomain',
|
|
||||||
'StorageDomain',
|
'StorageDomain',
|
||||||
'StoresDomain',
|
'ProjectStorageDomain',
|
||||||
|
'ModulesDomain',
|
||||||
|
'ResourcesDomain',
|
||||||
]) {
|
]) {
|
||||||
expect(name in sdk).toBe(false);
|
expect(name in sdk).toBe(false);
|
||||||
}
|
}
|
||||||
// Service constructors — exported as types only.
|
|
||||||
for (const name of [
|
for (const name of [
|
||||||
'AuthService',
|
'AuthService',
|
||||||
'LeaderboardsService',
|
'LeaderboardsService',
|
||||||
@@ -48,43 +45,45 @@ describe('public API surface', () => {
|
|||||||
'RudderHttpError',
|
'RudderHttpError',
|
||||||
'RudderAuthError',
|
'RudderAuthError',
|
||||||
'SDK_ERROR_INVALID_OPTIONS',
|
'SDK_ERROR_INVALID_OPTIONS',
|
||||||
'RudderErrorCodes',
|
|
||||||
'createDefaultTokenStore',
|
'createDefaultTokenStore',
|
||||||
'createLocalStorageTokenStore',
|
'createLocalStorageTokenStore',
|
||||||
'SyncedState',
|
'SyncedState',
|
||||||
'RemoteConfigState',
|
|
||||||
'ShopHandle',
|
|
||||||
'OfferHandle',
|
|
||||||
]) {
|
]) {
|
||||||
expect(name in sdk, name).toBe(true);
|
expect(name in sdk, name).toBe(true);
|
||||||
}
|
}
|
||||||
|
expect('RemoteConfigState' in sdk).toBe(false);
|
||||||
|
expect('ShopHandle' in sdk).toBe(false);
|
||||||
|
expect('OfferHandle' in sdk).toBe(false);
|
||||||
|
expect('QuestMetrics' in sdk).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes effects without a public scenarios service on the client', () => {
|
it('exposes kernel members and no module-specific namespaces on the client', () => {
|
||||||
const client = new RudderClient({
|
const client = new RudderClient({
|
||||||
baseUrl: 'https://api.test.rudder.build',
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
projectKey: 'test-project',
|
projectKey: 'test-project',
|
||||||
runtime: { loginEvent: null },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(client.auth).toBeDefined();
|
||||||
|
expect(client.player).toBeDefined();
|
||||||
|
expect(client.catalog).toBeDefined();
|
||||||
|
expect(client.storage).toBeDefined();
|
||||||
|
expect(client.projectStorage).toBeDefined();
|
||||||
|
expect(client.modules).toBeDefined();
|
||||||
|
expect(typeof client.modules.call).toBe('function');
|
||||||
|
expect(typeof client.resources).toBe('function');
|
||||||
expect(client.effects).toBeDefined();
|
expect(client.effects).toBeDefined();
|
||||||
|
|
||||||
|
expect('battlePass' in client).toBe(false);
|
||||||
|
expect('quests' in client).toBe(false);
|
||||||
|
expect('leaderboards' in client).toBe(false);
|
||||||
|
expect('stores' in client).toBe(false);
|
||||||
|
expect('inventory' in client).toBe(false);
|
||||||
|
expect('remoteConfig' in client).toBe(false);
|
||||||
expect('scenarios' in client).toBe(false);
|
expect('scenarios' in client).toBe(false);
|
||||||
});
|
expect('onBattlePass' in client.effects).toBe(false);
|
||||||
|
expect('onStoreOffer' in client.effects).toBe(false);
|
||||||
it('uses the canonical glossary property names on the client', () => {
|
expect('onQuest' in client.effects).toBe(false);
|
||||||
const client = new RudderClient({
|
expect('onLeaderboard' in client.effects).toBe(false);
|
||||||
baseUrl: 'https://api.test.rudder.build',
|
expect('onConfigChanged' in client.effects).toBe(false);
|
||||||
projectKey: 'test-project',
|
|
||||||
runtime: { loginEvent: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.remoteConfig).toBeDefined();
|
|
||||||
expect('config' in client).toBe(false);
|
|
||||||
expect(client.battlePass).toBeDefined();
|
|
||||||
expect('battlepass' in client).toBe(false);
|
|
||||||
expect(typeof client.effects.onBattlePass).toBe('function');
|
|
||||||
expect(typeof client.effects.onBattlePassLevel).toBe('function');
|
|
||||||
expect('onBattlepass' in client.effects).toBe(false);
|
|
||||||
expect('onBattlepassLevel' in client.effects).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-57
@@ -135,83 +135,67 @@ describe('transport error mapping', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('lazy scenario runtime', () => {
|
describe('modules and resources', () => {
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not create the scenario runtime until first use', async () => {
|
it('modules.call posts to /sdk/v1/modules/:module/:fn', async () => {
|
||||||
const client = new RudderClient({
|
const client = new RudderClient({
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
projectKey: 'proj_123',
|
projectKey: 'proj_123',
|
||||||
runtime: { loginEvent: null },
|
tokenStore: createFakeTokenStore(),
|
||||||
});
|
});
|
||||||
const internals = client as unknown as {
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
scenarioRuntime: unknown;
|
new Response(JSON.stringify({ result: { ok: true }, log: [] }), { status: 200 }),
|
||||||
ensureScenarioRuntime(): Promise<unknown>;
|
);
|
||||||
};
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
expect(internals.scenarioRuntime).toBeNull();
|
|
||||||
await internals.ensureScenarioRuntime();
|
const result = await client.modules.call('quests', 'list', { limit: 10 });
|
||||||
expect(internals.scenarioRuntime).not.toBeNull();
|
|
||||||
|
expect(result).toEqual({ result: { ok: true }, log: [] });
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(new URL(String(url)).pathname).toBe('/sdk/v1/modules/quests/list');
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({ args: { limit: 10 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delivers effects to handlers subscribed before the runtime is ready', async () => {
|
it('resources(slug) lists and mutates rows', async () => {
|
||||||
const client = new RudderClient({
|
const client = new RudderClient({
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
projectKey: 'proj_123',
|
projectKey: 'proj_123',
|
||||||
runtime: {},
|
tokenStore: createFakeTokenStore(),
|
||||||
});
|
});
|
||||||
|
const row = { id: 'row-1', data: { name: 'Sword' }, version: 1 };
|
||||||
const notifications: string[] = [];
|
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||||
client.effects.onNotification((effect) => {
|
|
||||||
notifications.push(effect.message);
|
|
||||||
return effect.done();
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
|
||||||
const path = new URL(url).pathname;
|
const path = new URL(url).pathname;
|
||||||
if (path === '/sdk/v1/authorization/device') {
|
const method = (init?.method ?? 'GET').toUpperCase();
|
||||||
return Promise.resolve(new Response(
|
if (method === 'GET' && path === '/sdk/v1/resources/items') {
|
||||||
JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }),
|
return Promise.resolve(new Response(JSON.stringify({ items: [row], total: 1 }), { status: 200 }));
|
||||||
{ status: 200 },
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if (path === '/sdk/v1/scenarios/trigger') {
|
if (method === 'GET' && path === '/sdk/v1/resources/items/row-1') {
|
||||||
return Promise.resolve(new Response(JSON.stringify({
|
return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
|
||||||
effects: [{
|
|
||||||
runId: 'run-1',
|
|
||||||
scenarioSlug: 'scenario-1',
|
|
||||||
nodeId: 'start',
|
|
||||||
type: 'notification',
|
|
||||||
data: { message: 'Welcome!' },
|
|
||||||
}],
|
|
||||||
}), { status: 200 }));
|
|
||||||
}
|
}
|
||||||
if (path === '/sdk/v1/scenarios/pending') {
|
if (method === 'POST' && path === '/sdk/v1/resources/items') {
|
||||||
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 }));
|
return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
|
||||||
}
|
}
|
||||||
if (path === '/sdk/v1/remote-configs') {
|
if (method === 'PATCH' && path === '/sdk/v1/resources/items/row-1') {
|
||||||
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
|
return Promise.resolve(new Response(JSON.stringify({ ...row, version: 2 }), { status: 200 }));
|
||||||
}
|
}
|
||||||
if (path === '/sdk/v1/stores') {
|
if (method === 'DELETE' && path === '/sdk/v1/resources/items/row-1') {
|
||||||
return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 }));
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
}
|
}
|
||||||
if (path === '/sdk/v1/catalog') {
|
return Promise.resolve(new Response('unhandled', { status: 500 }));
|
||||||
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
});
|
||||||
}
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
if (path === '/sdk/v1/sync') {
|
|
||||||
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
|
|
||||||
}
|
|
||||||
void init;
|
|
||||||
return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 }));
|
|
||||||
}));
|
|
||||||
|
|
||||||
await client.auth.loginWithDevice();
|
const handle = client.resources('items');
|
||||||
await vi.waitFor(() => expect(notifications).toEqual(['Welcome!']));
|
expect(await handle.list()).toEqual({ items: [row], total: 1 });
|
||||||
client.dispose();
|
expect(await handle.get('row-1')).toEqual(row);
|
||||||
|
expect(await handle.create({ data: { name: 'Sword' } })).toEqual(row);
|
||||||
|
expect(await handle.update('row-1', { data: { name: 'Sword' }, version: 1 })).toEqual({
|
||||||
|
...row,
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
expect(await handle.delete('row-1')).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
// reach the admin API for wallet grants / player inspection.
|
||||||
|
|
||||||
import { RudderClient } from '../../../src/client/RudderClient.js';
|
import { RudderClient } from '../../../src/client/RudderClient.js';
|
||||||
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
|
|
||||||
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
||||||
import { createAdminClient, type AdminClient } from './adminClient.js';
|
import { createAdminClient, type AdminClient } from './adminClient.js';
|
||||||
import { readArtifact } from './artifact.js';
|
import { readArtifact } from './artifact.js';
|
||||||
import type { SeedArtifact } from './types.js';
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
type RuntimeOptions = {
|
|
||||||
loginEvent?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
|
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
|
||||||
export function loadArtifact(): SeedArtifact {
|
export function loadArtifact(): SeedArtifact {
|
||||||
const file = readArtifact();
|
const file = readArtifact();
|
||||||
@@ -44,25 +39,22 @@ export function createInMemoryTokenStore(): TokenStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
|
/** A client with no auth yet (in-memory tokens). */
|
||||||
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
export function makeProdClient(
|
||||||
artifact: SeedArtifact = loadArtifact(),
|
artifact: SeedArtifact = loadArtifact(),
|
||||||
runtime: RuntimeOptions = { loginEvent: null },
|
): RudderClient {
|
||||||
): RudderClient<TConfig> {
|
return new RudderClient({
|
||||||
return new RudderClient<TConfig>({
|
|
||||||
baseUrl: artifact.baseUrl,
|
baseUrl: artifact.baseUrl,
|
||||||
projectKey: artifact.projectKey,
|
projectKey: artifact.projectKey,
|
||||||
tokenStore: createInMemoryTokenStore(),
|
tokenStore: createInMemoryTokenStore(),
|
||||||
runtime,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
|
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
|
||||||
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
export async function freshPlayer(
|
||||||
artifact: SeedArtifact = loadArtifact(),
|
artifact: SeedArtifact = loadArtifact(),
|
||||||
runtime?: RuntimeOptions,
|
): Promise<RudderClient> {
|
||||||
): Promise<RudderClient<TConfig>> {
|
const client = makeProdClient(artifact);
|
||||||
const client = makeProdClient<TConfig>(artifact, runtime);
|
|
||||||
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +1,20 @@
|
|||||||
// Production seed: registers an operator, creates a project, configures staging
|
// Production seed: registers an operator and creates a project. Creating a
|
||||||
// content (counter, item, remote configs, leaderboard, store with free+paid offers, a
|
// project provisions staging + prod environments; the prod SDK key is what
|
||||||
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
|
// the runtime SDK sends as projectKey.
|
||||||
//
|
|
||||||
// The scenario flow uses the server engine's node types, `data` keys and handle names —
|
|
||||||
// the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
|
|
||||||
// and .../fsm), so what we store is what the server runs.
|
|
||||||
|
|
||||||
import { createAdminClient, type AdminClient } from './adminClient.js';
|
import { createAdminClient } from './adminClient.js';
|
||||||
import type { SeedArtifact } from './types.js';
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
interface ReleaseResponse {
|
|
||||||
id: string;
|
|
||||||
status: string;
|
|
||||||
version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuestResponse {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
objectives?: Array<{ id: string; metric: string; target: number }>;
|
|
||||||
rewards?: Array<{ itemId?: string; currency?: string; amount: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoreResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
offers?: Array<{
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
contents?: Array<{ itemId: string; amount: number }>;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFlow(scenarioSlug: string) {
|
|
||||||
return {
|
|
||||||
nodes: [
|
|
||||||
{ id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } },
|
|
||||||
{ id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } },
|
|
||||||
{
|
|
||||||
id: 'rc_1',
|
|
||||||
type: 'remote_config_override',
|
|
||||||
data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] },
|
|
||||||
},
|
|
||||||
{ id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } },
|
|
||||||
{ id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } },
|
|
||||||
{ id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } },
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' },
|
|
||||||
{ id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' },
|
|
||||||
{ id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' },
|
|
||||||
{ id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' },
|
|
||||||
{ id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollRelease(
|
|
||||||
api: AdminClient,
|
|
||||||
projPath: string,
|
|
||||||
env: string,
|
|
||||||
releaseId: string,
|
|
||||||
log: (m: string) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const deadline = Date.now() + 60_000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const releases = await api.get<ReleaseResponse[]>(
|
|
||||||
`${projPath}/releases?environment=${env}&limit=20&offset=0`,
|
|
||||||
);
|
|
||||||
const rel = releases.find((r) => r.id === releaseId);
|
|
||||||
if (rel) {
|
|
||||||
if (rel.status === 'completed') {
|
|
||||||
log(`release ${releaseId} completed (v${rel.version})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (rel.status === 'failed') {
|
|
||||||
throw new Error(`release ${releaseId} failed`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await sleep(1500);
|
|
||||||
}
|
|
||||||
throw new Error(`release ${releaseId} did not complete within 60s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSeed(
|
export async function runSeed(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
log: (m: string) => void = () => {},
|
log: (m: string) => void = () => {},
|
||||||
): Promise<SeedArtifact> {
|
): Promise<SeedArtifact> {
|
||||||
const api = createAdminClient(baseUrl);
|
const api = createAdminClient(baseUrl);
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const authoringEnv = 'staging';
|
|
||||||
const runtimeEnv = 'prod';
|
const runtimeEnv = 'prod';
|
||||||
const q = `?environment=${authoringEnv}`;
|
|
||||||
const prodQ = `?environment=${runtimeEnv}`;
|
|
||||||
const adminEmail = `e2e+${ts}@rudder.build`;
|
const adminEmail = `e2e+${ts}@rudder.build`;
|
||||||
const password = `Passw0rd!e2e-${ts}`;
|
const password = `Passw0rd!e2e-${ts}`;
|
||||||
|
|
||||||
// 1. Register operator (open registration; no approval gate at the API layer).
|
|
||||||
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
|
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
|
||||||
'/platform/v1/authorization/register',
|
'/platform/v1/authorization/register',
|
||||||
{ email: adminEmail, password, fullName: 'E2E Bot' },
|
{ email: adminEmail, password, fullName: 'E2E Bot' },
|
||||||
@@ -111,8 +23,6 @@ export async function runSeed(
|
|||||||
api.setToken(auth.accessToken);
|
api.setToken(auth.accessToken);
|
||||||
log(`registered ${adminEmail}`);
|
log(`registered ${adminEmail}`);
|
||||||
|
|
||||||
// 2. Project. Creating one provisions the staging + prod environments, each with
|
|
||||||
// its own SDK key — that key is what the runtime SDK sends as projectKey.
|
|
||||||
const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
|
const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
|
||||||
name: `e2e-${ts}`,
|
name: `e2e-${ts}`,
|
||||||
});
|
});
|
||||||
@@ -126,168 +36,6 @@ export async function runSeed(
|
|||||||
}
|
}
|
||||||
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
|
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
|
||||||
|
|
||||||
// 3. Soft currency counter — offer prices are validated against counter slugs, and
|
|
||||||
// the purchase debit resolves the currency through the catalog counter cache.
|
|
||||||
const currency = 'soft';
|
|
||||||
await api.post(`${projPath}/counters${q}`, {
|
|
||||||
projectId: project.id,
|
|
||||||
environment: authoringEnv,
|
|
||||||
name: 'Soft Currency',
|
|
||||||
slug: currency,
|
|
||||||
kind: 'currency',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Item (granted as a paid offer's contents).
|
|
||||||
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
|
|
||||||
projectId: project.id,
|
|
||||||
environment: authoringEnv,
|
|
||||||
name: 'Gold Coin',
|
|
||||||
slug: 'gold-coin',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Remote configs — one per value type. RCs are live per-env flags served
|
|
||||||
// straight from the cache and are not copied by a promote, so they go directly
|
|
||||||
// into the runtime env.
|
|
||||||
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
|
|
||||||
{ key: 'max_energy', value: '100', valueType: 'int' },
|
|
||||||
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
|
|
||||||
{ key: 'feature_x', value: 'true', valueType: 'bool' },
|
|
||||||
{ key: 'welcome_text', value: 'hi', valueType: 'string' },
|
|
||||||
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
|
|
||||||
];
|
|
||||||
for (const rc of remoteConfigs) {
|
|
||||||
await api.post(`${projPath}/remote-configs${prodQ}`, {
|
|
||||||
projectId: project.id,
|
|
||||||
environment: runtimeEnv,
|
|
||||||
...rc,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
log(`created ${remoteConfigs.length} remote configs`);
|
|
||||||
|
|
||||||
// 6. Leaderboard.
|
|
||||||
await api.post(`${projPath}/leaderboards${q}`, {
|
|
||||||
projectId: project.id,
|
|
||||||
environment: authoringEnv,
|
|
||||||
name: 'Weekly Score',
|
|
||||||
slug: 'weekly-score',
|
|
||||||
metric: 'score',
|
|
||||||
resetPeriod: 'weekly',
|
|
||||||
sortingOrder: 'desc',
|
|
||||||
maxEntries: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Store with a free offer and a paid (currency) offer with a purchase limit.
|
|
||||||
// Stores and offers carry an authored slug that survives promotes.
|
|
||||||
const storeSlug = 'starter-shop';
|
|
||||||
const paidOfferSlug = 'gold-pack';
|
|
||||||
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
|
|
||||||
projectId: project.id,
|
|
||||||
environment: authoringEnv,
|
|
||||||
slug: storeSlug,
|
|
||||||
name: 'Starter Shop',
|
|
||||||
description: 'E2E shop',
|
|
||||||
offers: [
|
|
||||||
{
|
|
||||||
slug: 'welcome-gift',
|
|
||||||
name: 'Welcome Gift',
|
|
||||||
contents: [{ itemId: item.id, amount: 10 }],
|
|
||||||
price: { currency, amount: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: paidOfferSlug,
|
|
||||||
name: 'Gold Pack',
|
|
||||||
contents: [{ itemId: item.id, amount: 50 }],
|
|
||||||
price: { currency, amount: 100 },
|
|
||||||
maxPurchases: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
|
|
||||||
|
|
||||||
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
|
|
||||||
const questName = 'Buy Gold Pack Quest';
|
|
||||||
const questObjectiveId = 'buy-gold-pack';
|
|
||||||
const questTarget = 1;
|
|
||||||
const questSlug = 'buy-gold-pack-quest';
|
|
||||||
const questMetric = `purchase.offer:${paidOfferSlug}`;
|
|
||||||
const questRewardAmount = 7;
|
|
||||||
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
|
|
||||||
projectId: project.id,
|
|
||||||
environment: authoringEnv,
|
|
||||||
slug: questSlug,
|
|
||||||
name: questName,
|
|
||||||
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
|
|
||||||
rewards: [{ itemId: item.id, amount: questRewardAmount }],
|
|
||||||
});
|
|
||||||
log(`quest ${quest.id} staged for metric ${questMetric}`);
|
|
||||||
|
|
||||||
// 9. Scenario (must be live now: startAt <= now <= endAt).
|
|
||||||
const startAt = new Date(ts - 3600_000).toISOString();
|
|
||||||
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
|
|
||||||
const scenarioEvent = 'session_start';
|
|
||||||
const scenarioSlug = 'onboarding';
|
|
||||||
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
|
|
||||||
projectId: project.id,
|
|
||||||
environment: authoringEnv,
|
|
||||||
slug: scenarioSlug,
|
|
||||||
name: 'Onboarding',
|
|
||||||
description: 'e2e onboarding',
|
|
||||||
startAt,
|
|
||||||
endAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
|
|
||||||
await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
|
|
||||||
projectId: project.id,
|
|
||||||
flow: buildFlow(storeSlug),
|
|
||||||
});
|
|
||||||
log(`scenario ${scenario.id} flow set`);
|
|
||||||
|
|
||||||
// 11. Promote staging content to prod (the runtime serves the prod snapshot).
|
|
||||||
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
|
|
||||||
projectId: project.id,
|
|
||||||
fromEnvironment: authoringEnv,
|
|
||||||
toEnvironment: runtimeEnv,
|
|
||||||
});
|
|
||||||
await pollRelease(api, projPath, runtimeEnv, release.id, log);
|
|
||||||
|
|
||||||
// Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
|
|
||||||
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
|
|
||||||
const prodStore = prodStores.find((s) => s.slug === storeSlug);
|
|
||||||
if (!prodStore) {
|
|
||||||
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
|
|
||||||
}
|
|
||||||
const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
|
|
||||||
if (!prodPaidOffer?.slug) {
|
|
||||||
throw new Error('promoted paid offer did not return a slug');
|
|
||||||
}
|
|
||||||
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
|
|
||||||
if (!prodRewardItemId) {
|
|
||||||
throw new Error('promoted paid offer did not return a reward item id');
|
|
||||||
}
|
|
||||||
|
|
||||||
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
|
|
||||||
const prodQuest = prodQuests.find((qq) => qq.slug === questSlug);
|
|
||||||
if (!prodQuest?.slug) {
|
|
||||||
throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`);
|
|
||||||
}
|
|
||||||
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
|
|
||||||
if (!prodQuestMetric) {
|
|
||||||
throw new Error(`promoted quest ${questSlug} did not return metric ${questObjectiveId}`);
|
|
||||||
}
|
|
||||||
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
|
|
||||||
if (!prodQuestRewardItemId) {
|
|
||||||
throw new Error(`promoted quest ${questSlug} did not return reward item id`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
|
|
||||||
`${projPath}/scenarios${prodQ}`,
|
|
||||||
);
|
|
||||||
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
|
|
||||||
if (!prodScenario?.slug) {
|
|
||||||
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
@@ -296,38 +44,6 @@ export async function runSeed(
|
|||||||
adminEmail,
|
adminEmail,
|
||||||
environment: runtimeEnv,
|
environment: runtimeEnv,
|
||||||
seededAt: new Date(ts).toISOString(),
|
seededAt: new Date(ts).toISOString(),
|
||||||
releaseId: release.id,
|
|
||||||
store: {
|
|
||||||
name: 'Starter Shop',
|
|
||||||
slug: storeSlug,
|
|
||||||
freeOfferName: 'Welcome Gift',
|
|
||||||
paidOfferName: 'Gold Pack',
|
|
||||||
paidOfferSlug: prodPaidOffer.slug,
|
|
||||||
paidPrice: { currency, amount: 100 },
|
|
||||||
paidMaxPurchases: 2,
|
|
||||||
grantedItemName: 'Gold Coin',
|
|
||||||
paidGrantAmount: 50,
|
|
||||||
},
|
|
||||||
quest: {
|
|
||||||
slug: prodQuest.slug,
|
|
||||||
name: questName,
|
|
||||||
objectiveId: questObjectiveId,
|
|
||||||
metric: prodQuestMetric,
|
|
||||||
target: questTarget,
|
|
||||||
rewardItemId: prodQuestRewardItemId,
|
|
||||||
rewardAmount: questRewardAmount,
|
|
||||||
},
|
|
||||||
leaderboard: { slug: 'weekly-score', metric: 'score' },
|
|
||||||
item: { id: prodRewardItemId, name: item.name },
|
|
||||||
remoteConfigs: {
|
|
||||||
maxEnergy: 100,
|
|
||||||
spawnRate: 1.5,
|
|
||||||
featureX: true,
|
|
||||||
welcomeText: 'hi',
|
|
||||||
shopLayout: { cols: 3 },
|
|
||||||
spawnRateOverride: 3.0,
|
|
||||||
},
|
|
||||||
scenario: { slug: prodScenario.slug, event: scenarioEvent },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,37 +10,4 @@ export interface SeedArtifact {
|
|||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
seededAt: string;
|
seededAt: string;
|
||||||
releaseId: string;
|
|
||||||
store: {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
freeOfferName: string;
|
|
||||||
paidOfferName: string;
|
|
||||||
paidOfferSlug: string;
|
|
||||||
paidPrice: { currency: string; amount: number };
|
|
||||||
paidMaxPurchases: number;
|
|
||||||
grantedItemName: string;
|
|
||||||
paidGrantAmount: number;
|
|
||||||
};
|
|
||||||
quest: {
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
objectiveId: string;
|
|
||||||
metric: string;
|
|
||||||
target: number;
|
|
||||||
rewardItemId: string;
|
|
||||||
rewardAmount: number;
|
|
||||||
};
|
|
||||||
leaderboard: { slug: string; metric: string };
|
|
||||||
item: { id: string; name: string };
|
|
||||||
remoteConfigs: {
|
|
||||||
maxEnergy: number;
|
|
||||||
spawnRate: number;
|
|
||||||
featureX: boolean;
|
|
||||||
welcomeText: string;
|
|
||||||
shopLayout: Record<string, unknown>;
|
|
||||||
/** spawn_rate value the scenario's remote_config_override applies. */
|
|
||||||
spawnRateOverride: number;
|
|
||||||
};
|
|
||||||
scenario: { slug: string; event: string };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,6 @@ describe('e2e-prod: health', () => {
|
|||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
|
const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
|
||||||
expect(body.status).toBe('ready');
|
expect(body.status).toBe('ready');
|
||||||
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
|
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
baseUrl: 'https://api.test.rudder.build',
|
||||||
projectKey: 'test-project',
|
projectKey: 'test-project',
|
||||||
tokenStore: createFakeTokenStore(),
|
tokenStore: createFakeTokenStore(),
|
||||||
runtime: { loginEvent: null },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,13 +45,11 @@ describe('E2E: player storage', () => {
|
|||||||
{ id: 'b', type: 'prefs', data: '{}' },
|
{ id: 'b', type: 'prefs', data: '{}' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// v0.2 fetches the whole storage; filtering by type is client-side.
|
|
||||||
const all = await client.storage.reload();
|
const all = await client.storage.reload();
|
||||||
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
|
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
|
||||||
expect(prefs).toHaveLength(1);
|
expect(prefs).toHaveLength(1);
|
||||||
expect(prefs[0].id).toBe('b');
|
expect(prefs[0].id).toBe('b');
|
||||||
|
|
||||||
// The read went out as a storage fetch with limit as a query param, not a path segment.
|
|
||||||
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
|
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
|
||||||
expect(getReq?.query.get('limit')).toBe('100');
|
expect(getReq?.query.get('limit')).toBe('100');
|
||||||
});
|
});
|
||||||
|
|||||||
+63
-142
@@ -1,12 +1,9 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import type { PendingEffect } from '../../src/generated/scenarios.js';
|
|
||||||
import type { RemoteConfig } from '../../src/generated/remote-config.js';
|
|
||||||
import type { Store } from '../../src/generated/stores.js';
|
|
||||||
import type { StorageItem } from '../../src/generated/storage.js';
|
import type { StorageItem } from '../../src/generated/storage.js';
|
||||||
import type {
|
import type { ResourceRow } from '../../src/generated/resources.js';
|
||||||
ClaimQuestResponse,
|
import type { CallModuleFunctionClientResponse } from '../../src/generated/modules.js';
|
||||||
ListQuestsResponse,
|
import type { CatalogCurrency } from '../../src/generated/catalog.js';
|
||||||
} from '../../src/generated/quests.js';
|
import type { ProjectStorageItem } from '../../src/generated/project-storage.js';
|
||||||
|
|
||||||
export interface RecordedRequest {
|
export interface RecordedRequest {
|
||||||
method: string;
|
method: string;
|
||||||
@@ -17,28 +14,17 @@ export interface RecordedRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeGatewayState {
|
export interface FakeGatewayState {
|
||||||
scenarios: Map<string, PendingEffect[]>;
|
|
||||||
callbacks: Map<string, PendingEffect | null>;
|
|
||||||
callbackErrors: Map<string, { status: number; code?: string }>;
|
|
||||||
pendingEffects: PendingEffect[];
|
|
||||||
counterCompleted: boolean;
|
|
||||||
counterEffect?: PendingEffect;
|
|
||||||
remoteConfigs: Record<string, RemoteConfig>;
|
|
||||||
stores: Store[];
|
|
||||||
quests: ListQuestsResponse;
|
|
||||||
questClaims: Map<string, ClaimQuestResponse>;
|
|
||||||
storage: Map<string, StorageItem>;
|
storage: Map<string, StorageItem>;
|
||||||
|
projectStorage: Map<string, ProjectStorageItem>;
|
||||||
|
currencies: CatalogCurrency[];
|
||||||
|
resources: Map<string, Map<string, ResourceRow>>;
|
||||||
|
moduleCalls: CallModuleFunctionClientResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeGateway {
|
export interface FakeGateway {
|
||||||
state: FakeGatewayState;
|
state: FakeGatewayState;
|
||||||
recorded: RecordedRequest[];
|
recorded: RecordedRequest[];
|
||||||
purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
|
|
||||||
questClaims: Array<{ questSlug: string; authToken: string | null }>;
|
|
||||||
install(): void;
|
install(): void;
|
||||||
onEvent(event: string, ...effects: PendingEffect[]): void;
|
|
||||||
onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
|
|
||||||
onCallbackError(nodeId: string, handle: string, status?: number, code?: string): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function json(body: unknown, status = 200): Response {
|
function json(body: unknown, status = 200): Response {
|
||||||
@@ -50,52 +36,16 @@ function json(body: unknown, status = 200): Response {
|
|||||||
|
|
||||||
const noContent = (): Response => new Response(null, { status: 204 });
|
const noContent = (): Response => new Response(null, { status: 204 });
|
||||||
|
|
||||||
function replaceRun(pending: PendingEffect[], next: PendingEffect[]): PendingEffect[] {
|
|
||||||
const runIds = new Set(next.map((effect) => effect.runId));
|
|
||||||
return [...pending.filter((effect) => !runIds.has(effect.runId)), ...next];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
|
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
|
||||||
const state: FakeGatewayState = {
|
const state: FakeGatewayState = {
|
||||||
scenarios: seed?.scenarios ?? new Map(),
|
|
||||||
callbacks: seed?.callbacks ?? new Map(),
|
|
||||||
callbackErrors: seed?.callbackErrors ?? new Map(),
|
|
||||||
pendingEffects: seed?.pendingEffects ?? [],
|
|
||||||
counterCompleted: seed?.counterCompleted ?? false,
|
|
||||||
counterEffect: seed?.counterEffect,
|
|
||||||
remoteConfigs: seed?.remoteConfigs ?? {},
|
|
||||||
stores: seed?.stores ?? [],
|
|
||||||
quests: seed?.quests ?? { quests: [] },
|
|
||||||
questClaims: seed?.questClaims ?? new Map(),
|
|
||||||
storage: seed?.storage ?? new Map(),
|
storage: seed?.storage ?? new Map(),
|
||||||
|
projectStorage: seed?.projectStorage ?? new Map(),
|
||||||
|
currencies: seed?.currencies ?? [],
|
||||||
|
resources: seed?.resources ?? new Map(),
|
||||||
|
moduleCalls: seed?.moduleCalls ?? { result: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
const recorded: RecordedRequest[] = [];
|
const recorded: RecordedRequest[] = [];
|
||||||
const purchases: FakeGateway['purchases'] = [];
|
|
||||||
const questClaims: FakeGateway['questClaims'] = [];
|
|
||||||
|
|
||||||
function advanceExpiredWaits(): PendingEffect[] {
|
|
||||||
const now = Date.now();
|
|
||||||
const next: PendingEffect[] = [];
|
|
||||||
for (const effect of state.pendingEffects) {
|
|
||||||
if (
|
|
||||||
effect.type === 'wait' &&
|
|
||||||
effect.waitDeadline &&
|
|
||||||
Date.parse(effect.waitDeadline) <= now
|
|
||||||
) {
|
|
||||||
const continued = state.callbacks.get(`${effect.nodeId}:onComplete`);
|
|
||||||
if (continued === undefined) {
|
|
||||||
next.push(effect);
|
|
||||||
} else if (continued !== null) {
|
|
||||||
next.push(continued);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
next.push(effect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.pendingEffects = next;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handle(req: RecordedRequest): Promise<Response> {
|
async function handle(req: RecordedRequest): Promise<Response> {
|
||||||
const { method, path, query, body } = req;
|
const { method, path, query, body } = req;
|
||||||
@@ -117,85 +67,54 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && path === '/sdk/v1/catalog') {
|
if (method === 'GET' && path === '/sdk/v1/catalog') {
|
||||||
return json({ items: [] });
|
return json({ currencies: state.currencies });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/);
|
||||||
const event = (body as { event?: string }).event ?? '';
|
if (method === 'POST' && moduleMatch) {
|
||||||
const effects = state.scenarios.get(event) ?? [];
|
return json(state.moduleCalls);
|
||||||
state.pendingEffects = replaceRun(state.pendingEffects, effects);
|
|
||||||
return json({ effects });
|
|
||||||
}
|
|
||||||
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
|
|
||||||
return json({ effects: advanceExpiredWaits() });
|
|
||||||
}
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
|
const resourceItemMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)\/([^/]+)$/);
|
||||||
return json({ configs: state.remoteConfigs });
|
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/')) {
|
if (method === 'PATCH') {
|
||||||
const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length));
|
const existing = rows.get(id);
|
||||||
const cfg = state.remoteConfigs[key];
|
if (!existing) return json({ error: 'not found' }, 404);
|
||||||
return cfg ? json(cfg) : 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 === 'DELETE') {
|
||||||
if (method === 'GET' && path === '/sdk/v1/stores') {
|
rows.delete(id);
|
||||||
return json({ stores: state.stores, total: state.stores.length });
|
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 (resourceListMatch) {
|
||||||
if (method === 'GET' && storeMatch) {
|
const slug = decodeURIComponent(resourceListMatch[1]);
|
||||||
const slug = decodeURIComponent(storeMatch[1]);
|
const rows = state.resources.get(slug) ?? new Map();
|
||||||
const store = state.stores.find((s) => s.slug === slug);
|
if (method === 'GET') {
|
||||||
return store ? json(store) : json({ error: 'not found' }, 404);
|
return json({ items: [...rows.values()], total: rows.size });
|
||||||
}
|
}
|
||||||
|
if (method === 'POST') {
|
||||||
if (method === 'POST' && path === '/sdk/v1/quests/list') {
|
const created: ResourceRow = {
|
||||||
return json(state.quests);
|
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') {
|
if (path === '/sdk/v1/storage') {
|
||||||
@@ -222,6 +141,19 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/sdk/v1/project-storage') {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return json({ items: [...state.projectStorage.values()], nextCursor: '' });
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const items = (body as { items?: ProjectStorageItem[] }).items ?? [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id) state.projectStorage.set(item.id, item);
|
||||||
|
}
|
||||||
|
return noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json({ error: `unhandled route: ${method} ${path}` }, 500);
|
return json({ error: `unhandled route: ${method} ${path}` }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,17 +180,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
|||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
recorded,
|
recorded,
|
||||||
purchases,
|
|
||||||
questClaims,
|
|
||||||
install,
|
install,
|
||||||
onEvent(event, ...effects) {
|
|
||||||
state.scenarios.set(event, effects);
|
|
||||||
},
|
|
||||||
onCallback(nodeId, handle, next) {
|
|
||||||
state.callbacks.set(`${nodeId}:${handle}`, next);
|
|
||||||
},
|
|
||||||
onCallbackError(nodeId, handle, status = 500, code) {
|
|
||||||
state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
target: 'es2022',
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
// Code-split the lazily-imported scenario client out of the initial chunk.
|
splitting: false,
|
||||||
splitting: true,
|
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
platform: 'browser',
|
platform: 'browser',
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
|||||||
Reference in New Issue
Block a user