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