Server-side scenario execution: thin effects client replaces local engine

- engine/ (DagWalker, sessions, IndexedDbPlanStore) deleted; server owns the graph
- trigger/callback/counter carry PendingEffect; GET /sdk/v1/scenarios/pending polled
  (30s jittered heartbeat paused on hidden tab + wait-deadline timers)
- client.effects public API unchanged (done/buy/dismiss/end/claim/battlepass/quest)
- planStateStore removed; loginEvent kept; reconcile() makes server the source of truth
- generated models regenerated; skills/README/CHANGELOG updated
This commit is contained in:
edmand46
2026-09-04 14:08:48 +03:00
parent ecbbf93951
commit 1491b5e357
33 changed files with 976 additions and 2050 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## Unreleased
- Scenario execution moved server-side. The SDK no longer walks a local DAG
or persists plan state to IndexedDB.
- Removed `RudderClientOptions.runtime.planStateStore` and the
`PlanStateStore` type.
- `GET /sdk/v1/scenarios/pending` is polled on login, on a ~30s heartbeat,
and at each effect `waitDeadline`. Completions POST
`/sdk/v1/scenarios/callback`; the response may carry the next
`PendingEffect`.
- Scenario `remote_config_override` nodes no longer emit `onConfigChanged`
(the server applies the override). The `ConfigChangedEffect` type and
`onConfigChanged` subscription remain.
## 0.6.0 ## 0.6.0
- `LeaderboardSession.claim()` / effect `claim()` completes the leaderboard - `LeaderboardSession.claim()` / effect `claim()` completes the leaderboard
+5 -2
View File
@@ -115,8 +115,11 @@ The scenario runtime is not exposed directly; scenario nodes surface through
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel` - `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
- `onScenarioCompleted`, `onScenarioFailed` - `onScenarioCompleted`, `onScenarioFailed`
The scenario engine (and its IndexedDB persistence) loads lazily on first The server executes the scenario graph. The SDK is a thin effects client
login — a client that only reads domains never pulls it into the page. (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` Errors thrown inside effect handlers are reported via the `onEffectError`
client option (default: `console.error`). client option (default: `console.error`).
+1 -1
View File
@@ -36,7 +36,7 @@ const client = new RudderClient({
// requestTimeoutMs?: number — default 10000 // requestTimeoutMs?: number — default 10000
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter) // syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
// onEffectError?: (error) => void — default console.error // onEffectError?: (error) => void — default console.error
// runtime?: { planStateStore?, loginEvent? } — advanced, see reference/scenarios.md // runtime?: { loginEvent? } — advanced, see reference/scenarios.md
}); });
``` ```
@@ -34,7 +34,6 @@ interface AddBattlePassXpResponse {
level?: number; level?: number;
leveledUp?: boolean; leveledUp?: boolean;
maxLevel?: boolean; maxLevel?: boolean;
plan?: ExecutionPlan; // scenario plan continuation, handled by the runtime
xp?: number; xp?: number;
} }
@@ -67,7 +66,6 @@ interface PurchaseBattlePassPremiumRequest {
} }
interface PurchaseBattlePassPremiumResponse { interface PurchaseBattlePassPremiumResponse {
error?: string; error?: string;
plan?: ExecutionPlan;
success?: boolean; success?: boolean;
} }
``` ```
@@ -82,6 +80,5 @@ interface PurchaseBattlePassPremiumResponse {
in the response and the typed error `code` (`RudderErrorCodes`). in the response and the typed error `code` (`RudderErrorCodes`).
- `purchasePremium` charges the player's wallet; idempotent; pass your own - `purchasePremium` charges the player's wallet; idempotent; pass your own
`idempotencyKey` for safe retries. `idempotencyKey` for safe retries.
- Mutations return `ExecutionPlan` continuations — when driving battle pass - Prefer the `onBattlePass` effect session, which binds `scenarioId` /
manually you are responsible for the scenario run state; prefer the `nodeId` / `runId` and posts scenario callbacks for you.
`onBattlePass` effect session which handles this.
@@ -56,8 +56,8 @@ already filters inactive configs out and may omit the flag).
## Behavior notes ## Behavior notes
- Warmed at login. - Warmed at login.
- Scenario `remote_config_override` nodes patch the local snapshot and fire - Scenario `remote_config_override` nodes are applied server-side and do not
`client.effects.onConfigChanged({ key })` — the patched value is what reach the client. Reload or wait for the config sync poll to observe the
`get()` returns afterwards. patched value via `get()`.
- Without a `TConfig` type argument, `RemoteConfigShape` defaults to - Without a `TConfig` type argument, `RemoteConfigShape` defaults to
`Record<string, unknown>` and `get()` returns `unknown`. `Record<string, unknown>` and `get()` returns `unknown`.
+36 -31
View File
@@ -1,29 +1,28 @@
# Scenarios + effects — `client.effects` # Scenarios + effects — `client.effects`
Scenarios are server-authored node graphs (configured in the dashboard) that Scenarios are server-authored node graphs (configured in the dashboard) that
run per player. The SDK's scenario runtime is deliberately **not** part of the run per player. The server executes the graph. The SDK is a thin effects
public client surface: scenario nodes surface as typed effects through client: it sends trigger events, turns `PendingEffect` payloads into typed
`client.effects` (source: `src/effects/EffectsCenter.ts`), and the engine `client.effects` handlers, posts callbacks when the game completes an
itself loads lazily on first login. 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 ## Lifecycle
- The runtime starts after login: scenario state is restored from persistence, - After login the SDK fetches `GET /sdk/v1/scenarios/pending` (replacing any
then the login event (`'player_login'` by default) is fired to trigger local restore), then fires the login event (`'player_login'` by default).
event-driven scenarios. - A heartbeat polls pending every ~30s (±20% jitter), paused while
- Runs persist to IndexedDB by default, so waits and pending nodes survive `document.hidden`. Each effect with a `waitDeadline` also schedules a
page reloads. On restore, each run is reconciled with the server local timer so waits fire on time.
(`unknown_run` / `expired` runs are dropped). - An effect with an already-active `(runId, nodeId)` is not re-emitted.
- `logout()` / `dispose()` clear all runs and persisted state. - `logout()` / `dispose()` stop the poll, cancel wait timers, and drop
active effects. There is no local plan persistence.
### Runtime options (`RudderClientOptions.runtime`) ### Runtime options (`RudderClientOptions.runtime`)
```ts ```ts
runtime?: { runtime?: {
// Scenario plan persistence. undefined = default IndexedDB store (created
// lazily); null = disable persistence entirely; or pass a PlanStateStore.
planStateStore?: PlanStateStore | null;
// Scenario event fired automatically after login. // Scenario event fired automatically after login.
// undefined = 'player_login'; null = fire nothing. // undefined = 'player_login'; null = fire nothing.
loginEvent?: string | null; loginEvent?: string | null;
@@ -60,6 +59,11 @@ interface Effects {
} }
``` ```
`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 ### `NotificationEffect` — a notification node became active
```ts ```ts
@@ -81,19 +85,15 @@ interface Effects {
### `LeaderboardEffect` — a leaderboard node became active ### `LeaderboardEffect` — a leaderboard node became active
```ts ```ts
{ end(): Promise<void>; rewardClaimed(): Promise<void> } { end(): Promise<void>; claim(): Promise<void>; rewardClaimed(): Promise<void> }
``` ```
### `ConfigChangedEffect` — a `remote_config_override` node patched config `rewardClaimed()` is a deprecated alias for `claim()`.
```ts
{ readonly key: string } // client.remoteConfig.get(key) already returns the override
```
### `WaitEffect` — a wait node became active ### `WaitEffect` — a wait node became active
```ts ```ts
{ readonly deadlineUtc: Date } // run resumes automatically at the deadline { readonly deadlineUtc: Date } // SDK polls pending at the deadline; the server advances the run
``` ```
### `QuestEffect` — a scenario quest node became active ### `QuestEffect` — a scenario quest node became active
@@ -106,8 +106,8 @@ interface Effects {
} }
``` ```
The node auto-completes server-side once every objective is satisfied; the SDK The node auto-completes server-side once every objective is satisfied; the
crosses the `onComplete` boundary itself when the server reports completion. counter response may carry the next `PendingEffect`.
### `BattlePassEffect` — a battle pass node became active ### `BattlePassEffect` — a battle pass node became active
@@ -137,7 +137,7 @@ already bound — prefer them over calling the service manually.
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioId: string } interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioId: string }
interface ScenarioFailedEffect { interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioId: string; readonly nodeId: string; readonly runId: string; readonly scenarioId: string; readonly nodeId: string;
readonly error: Error; // transport gave up, or unsupported node type readonly error: Error;
} }
``` ```
@@ -146,15 +146,20 @@ as console warnings.
## Supported node types ## Supported node types
`wait`, `remote_config_override`, `notification`, `store`, `leaderboard`, `wait`, `notification`, `store`, `leaderboard`, `quest`, `battlepass`,
`quest`, `battlepass`, `battlepass_level`. Any other node type fails the run `battlepass_level`. Any other node type fails the run (surfaced via
(surfaced via `onScenarioFailed`). `onScenarioFailed`). `remote_config_override` is applied server-side and is
not delivered to the client.
## Reliability notes ## Reliability notes
- Node completion is idempotent client-side (completed handles are tracked). - Completion methods POST `/sdk/v1/scenarios/callback` with
- Transient boundary failures (network error, 5xx) leave the node active for `{scenarioId, runId, nodeId, handle}` using the handles `output`,
retry on reconnect; terminal server errors fail the run. `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`, - Server scenario errors use the typed codes `run_expired`, `run_not_active`,
`node_not_active`, `scenario_not_active`, `unknown_run`, `node_not_active`, `scenario_not_active`, `unknown_run`,
`early_completion`, `objectives_incomplete`, `level_not_reached`, `early_completion`, `objectives_incomplete`, `level_not_reached`,
+12 -41
View File
@@ -9,8 +9,8 @@
* client (which keeps the module graph acyclic). Cross-domain wiring (the * client (which keeps the module graph acyclic). Cross-domain wiring (the
* inventory↔catalog link, purchase invalidation) is done here at construction. * inventory↔catalog link, purchase invalidation) is done here at construction.
* *
* The scenario runtime (engine + plan persistence) is loaded lazily on first * The scenario effects client is loaded lazily on first use — a client that
* use — a client that never logs in never pays for the scenario engine. * never logs in never pays for the scenario machinery.
*/ */
import type { import type {
@@ -26,7 +26,6 @@ import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
import { BattlePassService } from '../battlepass/BattlePassService.js'; import { BattlePassService } from '../battlepass/BattlePassService.js';
import { QuestsService } from '../quests/QuestsService.js'; import { QuestsService } from '../quests/QuestsService.js';
import type { ScenarioService } from '../scenario/ScenarioService.js'; import type { ScenarioService } from '../scenario/ScenarioService.js';
import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js'; import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
import { SyncEngine } from '../state/SyncEngine.js'; import { SyncEngine } from '../state/SyncEngine.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js'; import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
@@ -38,7 +37,6 @@ import { StorageDomain } from '../domains/StorageDomain.js';
import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js'; import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
import { StoresDomain } from '../domains/StoresDomain.js'; import { StoresDomain } from '../domains/StoresDomain.js';
/** A domain the client can bulk-invalidate (re-login) or reset (logout). */
type ManagedDomain = { invalidate(): void; reset(): void }; type ManagedDomain = { invalidate(): void; reset(): void };
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> { export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
@@ -50,7 +48,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
public readonly quests: QuestsService; public readonly quests: QuestsService;
public readonly effects: Effects; public readonly effects: Effects;
// Observable domains: subscribe via `onChange`, read `.data`, or `reload()`.
public readonly player: PlayerDomain; public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain; public readonly catalog: CatalogDomain;
public readonly inventory: InventoryDomain; public readonly inventory: InventoryDomain;
@@ -60,8 +57,8 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
public readonly stores: StoresDomain; public readonly stores: StoresDomain;
private readonly effectsCenter: EffectsCenter; private readonly effectsCenter: EffectsCenter;
private scenarioRuntime: ScenarioService<TConfig> | null = null; private scenarioRuntime: ScenarioService | null = null;
private scenarioRuntimePromise: Promise<ScenarioService<TConfig>> | null = null; private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
private readonly syncEngine: SyncEngine; private readonly syncEngine: SyncEngine;
private readonly loginEvent: string | null; private readonly loginEvent: string | null;
private readonly ctx: RudderContext; private readonly ctx: RudderContext;
@@ -137,11 +134,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
); );
} }
/**
* Tears down all background work: stops the sync poll (and its
* visibilitychange listener), cancels scenario wait timers, and drops cached
* state. Call when disposing the client (e.g. on unmount / HMR) to avoid leaks.
*/
dispose(): void { dispose(): void {
this.syncEngine.stop(); this.syncEngine.stop();
this.scenarioRuntime?.clear(); this.scenarioRuntime?.clear();
@@ -157,8 +149,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.invalidateAll(); this.invalidateAll();
} }
this.runtimeStarted = true; this.runtimeStarted = true;
// These four warms are independent — fetch them concurrently so login
// doesn't pay a serial round-trip per entity.
await Promise.all([ await Promise.all([
this.remoteConfig.load(), this.remoteConfig.load(),
this.player.load(), this.player.load(),
@@ -166,51 +156,34 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.catalog.load(), this.catalog.load(),
]); ]);
const runtime = await runtimePromise; const runtime = await runtimePromise;
await runtime.restore(); await runtime.start();
if (this.loginEvent) { if (this.loginEvent) {
await this.sendRuntimeEvent(runtime, this.loginEvent); await this.sendRuntimeEvent(runtime, this.loginEvent);
} }
this.syncEngine.start(); this.syncEngine.start();
} }
/** private ensureScenarioRuntime(): Promise<ScenarioService> {
* Loads the scenario engine and plan persistence on first use and caches the
* runtime. Dynamic imports keep the engine out of the initial module graph,
* so a thin client never pulls the scenario machinery.
*/
private ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> {
this.scenarioRuntimePromise ??= this.createScenarioRuntime(); this.scenarioRuntimePromise ??= this.createScenarioRuntime();
return this.scenarioRuntimePromise; return this.scenarioRuntimePromise;
} }
private async createScenarioRuntime(): Promise<ScenarioService<TConfig>> { private async createScenarioRuntime(): Promise<ScenarioService> {
const { ScenarioService } = await import('../scenario/ScenarioService.js'); const { ScenarioService } = await import('../scenario/ScenarioService.js');
const configured = this.options.runtime?.planStateStore; this.scenarioRuntime = new ScenarioService(
let planStore: PlanStateStore | null;
if (configured !== undefined) {
planStore = configured;
} else {
const { createIndexedDbPlanStateStore } = await import(
'../scenario/engine/IndexedDbPlanStore.js'
);
planStore = createIndexedDbPlanStateStore();
}
this.scenarioRuntime = new ScenarioService<TConfig>(
this.ctx, this.ctx,
{ {
player: this.player, player: this.player,
inventory: this.inventory, inventory: this.inventory,
config: this.remoteConfig,
stores: this.stores, stores: this.stores,
}, },
this.battlePass, this.battlePass,
planStore,
); );
return this.scenarioRuntime; return this.scenarioRuntime;
} }
private async sendRuntimeEvent( private async sendRuntimeEvent(
runtime: ScenarioService<TConfig>, runtime: ScenarioService,
event: string, event: string,
): Promise<void> { ): Promise<void> {
try { try {
@@ -240,12 +213,10 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
]; ];
} }
/** Re-login: refetch every domain that is in use. */
private invalidateAll(): void { private invalidateAll(): void {
for (const domain of this.managedDomains) domain.invalidate(); for (const domain of this.managedDomains) domain.invalidate();
} }
/** Logout: drop all cached data. */
private resetAll(): void { private resetAll(): void {
for (const domain of this.managedDomains) domain.reset(); for (const domain of this.managedDomains) domain.reset();
} }
@@ -254,14 +225,14 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
/** /**
* Test/internal access to the scenario runtime, which is deliberately not part * Test/internal access to the scenario runtime, which is deliberately not part
* of the public client surface (it is driven through `client.effects`). * of the public client surface (it is driven through `client.effects`).
* Lazily loads the scenario engine on first call. * Lazily loads the scenario effects client on first call.
* *
* @internal * @internal
*/ */
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>( export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
client: RudderClient<TConfig>, client: RudderClient<TConfig>,
): Promise<ScenarioService<TConfig>> { ): Promise<ScenarioService> {
return ( return (
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> } client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
).ensureScenarioRuntime(); ).ensureScenarioRuntime();
} }
+1 -17
View File
@@ -1,22 +1,6 @@
import type { TokenStore } from '../token/TokenStore.js'; import type { TokenStore } from '../token/TokenStore.js';
import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
/**
* Advanced runtime knobs. Mainly for tests and non-browser hosts; browser
* consumers can ignore these and take the defaults.
*/
export interface RudderRuntimeOptions { export interface RudderRuntimeOptions {
/**
* Scenario plan persistence backend. `undefined` uses the default
* IndexedDB store (created lazily with the scenario runtime); pass `null`
* to disable persistence entirely.
*/
planStateStore?: PlanStateStore | null;
/**
* Scenario event fired automatically after login. `undefined` uses
* `'player_login'`; pass `null` to fire nothing.
*/
loginEvent?: string | null; loginEvent?: string | null;
} }
@@ -46,7 +30,7 @@ export interface RudderClientOptions {
*/ */
onEffectError?: (error: unknown) => void; onEffectError?: (error: unknown) => void;
/** Advanced runtime knobs (plan persistence, login event). */ /** Advanced runtime knobs (login event). */
runtime?: RudderRuntimeOptions; runtime?: RudderRuntimeOptions;
} }
+19 -80
View File
@@ -1,15 +1,5 @@
import type { PurchaseOfferResponse } from '../generated/stores.js'; import type { PurchaseOfferResponse } from '../generated/stores.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js'; import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js';
import type {
BattlePassLevelSession,
BattlePassSession,
LeaderboardSession,
NotificationSession,
QuestSession,
StoreSession,
WaitSession,
} from '../scenario/engine/sessions.js';
import type { PlanRun, ScenarioRunFailedEvent } from '../scenario/engine/types.js';
import type { import type {
AddBattlePassXpResponse, AddBattlePassXpResponse,
ClaimBattlePassRewardResponse, ClaimBattlePassRewardResponse,
@@ -44,22 +34,15 @@ export interface ConfigChangedEffect {
readonly key: string; readonly key: string;
} }
/** A scenario wait node became active; the run resumes at `deadlineUtc`. */
export interface WaitEffect { export interface WaitEffect {
readonly deadlineUtc: Date; readonly deadlineUtc: Date;
} }
/** A scenario run reached a terminal end successfully. */
export interface ScenarioCompletedEffect { export interface ScenarioCompletedEffect {
readonly runId: string; readonly runId: string;
readonly scenarioId: string; readonly scenarioId: string;
} }
/**
* A scenario run failed at a node (transport gave up, or the node type is not
* supported by this SDK). Surfaced so the game can react instead of the failure
* being swallowed into a console warning.
*/
export interface ScenarioFailedEffect { export interface ScenarioFailedEffect {
readonly runId: string; readonly runId: string;
readonly scenarioId: string; readonly scenarioId: string;
@@ -67,17 +50,12 @@ export interface ScenarioFailedEffect {
readonly error: Error; readonly error: Error;
} }
/**
* A scenario quest node became active. Report objective progress; the node
* auto-completes server-side once every objective is satisfied.
*/
export interface QuestEffect { export interface QuestEffect {
readonly name: string; readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>; readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>; reportProgress(objectiveId: string, amount?: number): Promise<void>;
} }
/** A scenario battle pass node became active. */
export interface BattlePassEffect { export interface BattlePassEffect {
getProgress(): Promise<GetBattlePassProgressResponse>; getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>; addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
@@ -87,7 +65,6 @@ export interface BattlePassEffect {
end(): Promise<void>; end(): Promise<void>;
} }
/** A scenario battlepass_level node became active (a single claimable tier). */
export interface BattlePassLevelEffect { export interface BattlePassLevelEffect {
readonly level: number; readonly level: number;
claim(): Promise<void>; claim(): Promise<void>;
@@ -166,36 +143,20 @@ export class EffectsCenter implements Effects {
} }
/** @internal */ /** @internal */
emitNotification(session: NotificationSession): void { emitNotification(effect: NotificationEffect): void {
this.emit(this.notificationHandlers, { this.emit(this.notificationHandlers, effect);
title: session.title,
message: session.message,
done: () => session.complete(),
});
} }
/** @internal */ /** @internal */
emitStoreOffer(session: StoreSession): void { emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
session.getStore() Promise.resolve(effect)
.then((store) => { .then((value) => this.emit(this.storeOfferHandlers, value))
this.emit(this.storeOfferHandlers, {
store,
offers: store.offers,
message: session.get('message', undefined as string | undefined),
buy: (offer, options) => session.buy(offer, options),
dismiss: () => session.decline(),
});
})
.catch((error) => this.onError(error)); .catch((error) => this.onError(error));
} }
/** @internal */ /** @internal */
emitLeaderboard(session: LeaderboardSession): void { emitLeaderboard(effect: LeaderboardEffect): void {
this.emit(this.leaderboardHandlers, { this.emit(this.leaderboardHandlers, effect);
end: () => session.end(),
claim: () => session.claim(),
rewardClaimed: () => session.claim(),
});
} }
/** @internal */ /** @internal */
@@ -204,55 +165,33 @@ export class EffectsCenter implements Effects {
} }
/** @internal */ /** @internal */
emitWait(session: WaitSession): void { emitWait(effect: WaitEffect): void {
this.emit(this.waitHandlers, { deadlineUtc: session.deadlineUtc }); this.emit(this.waitHandlers, effect);
} }
/** @internal */ /** @internal */
emitScenarioCompleted(run: PlanRun): void { emitScenarioCompleted(effect: ScenarioCompletedEffect): void {
this.emit(this.scenarioCompletedHandlers, { this.emit(this.scenarioCompletedHandlers, effect);
runId: run.runId,
scenarioId: run.scenarioId,
});
} }
/** @internal */ /** @internal */
emitScenarioFailed(event: ScenarioRunFailedEvent): void { emitScenarioFailed(effect: ScenarioFailedEffect): void {
this.emit(this.scenarioFailedHandlers, { this.emit(this.scenarioFailedHandlers, effect);
runId: event.run.runId,
scenarioId: event.run.scenarioId,
nodeId: event.nodeId,
error: event.error,
});
} }
/** @internal */ /** @internal */
emitQuest(session: QuestSession): void { emitQuest(effect: QuestEffect): void {
this.emit(this.questHandlers, { this.emit(this.questHandlers, effect);
name: session.name,
objectives: session.objectives,
reportProgress: (objectiveId, amount) => session.reportProgress(objectiveId, amount),
});
} }
/** @internal */ /** @internal */
emitBattlePass(session: BattlePassSession): void { emitBattlePass(effect: BattlePassEffect): void {
this.emit(this.battlePassHandlers, { this.emit(this.battlePassHandlers, effect);
getProgress: () => session.getProgress(),
addXp: (source, amount) => session.addXp(source, amount),
claimReward: (level, track) => session.claimReward(level, track),
purchasePremium: () => session.purchasePremium(),
levelUp: () => session.levelUp(),
end: () => session.end(),
});
} }
/** @internal */ /** @internal */
emitBattlePassLevel(session: BattlePassLevelSession): void { emitBattlePassLevel(effect: BattlePassLevelEffect): void {
this.emit(this.battlePassLevelHandlers, { this.emit(this.battlePassLevelHandlers, effect);
level: session.level,
claim: () => session.claim(),
});
} }
private addHandler<TEffect>( private addHandler<TEffect>(
+6 -7
View File
@@ -9,7 +9,7 @@ import type { PlayerProfile } from './player.js';
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js'; import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js'; import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js';
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js'; import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
import type { GetScenarioRunRequest, GetScenarioRunResponse, HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js'; import type { HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, ListPendingScenarioEffectsResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js'; import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js'; import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
@@ -100,12 +100,6 @@ export const api = {
): Promise<{ [key: string]: number }> => ): Promise<{ [key: string]: number }> =>
t.request<{ [key: string]: number }>('GET', '/sdk/v1/sync'), t.request<{ [key: string]: number }>('GET', '/sdk/v1/sync'),
getScenarioRun: (
t: Transport,
body: GetScenarioRunRequest,
): Promise<GetScenarioRunResponse> =>
t.request<GetScenarioRunResponse>('POST', '/sdk/v1/scenarios/run', body),
getStorage: ( getStorage: (
t: Transport, t: Transport,
query?: { types?: string; limit?: number; cursor?: string }, query?: { types?: string; limit?: number; cursor?: string },
@@ -129,6 +123,11 @@ export const api = {
): Promise<ListCatalogItemsResponse> => ): Promise<ListCatalogItemsResponse> =>
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'), t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
listPendingScenarioEffects: (
t: Transport,
): Promise<ListPendingScenarioEffectsResponse> =>
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
listQuests: ( listQuests: (
t: Transport, t: Transport,
): Promise<ListQuestsResponse> => ): Promise<ListQuestsResponse> =>
+1 -3
View File
@@ -1,6 +1,6 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan, Reward } from './common.js'; import type { Reward } from './common.js';
export interface AddBattlePassXpRequest { export interface AddBattlePassXpRequest {
"amount"?: number; "amount"?: number;
@@ -14,7 +14,6 @@ export interface AddBattlePassXpResponse {
"level"?: number; "level"?: number;
"leveledUp"?: boolean; "leveledUp"?: boolean;
"maxLevel"?: boolean; "maxLevel"?: boolean;
"plan"?: ExecutionPlan;
"xp"?: number; "xp"?: number;
} }
@@ -59,7 +58,6 @@ export interface PurchaseBattlePassPremiumRequest {
export interface PurchaseBattlePassPremiumResponse { export interface PurchaseBattlePassPremiumResponse {
"error"?: string; "error"?: string;
"plan"?: ExecutionPlan;
"success"?: boolean; "success"?: boolean;
} }
-36
View File
@@ -1,15 +1,5 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
export interface BoundaryNode {
"callbackUrl"?: string;
"enforcement"?: "client" | "server";
"enteredAt"?: string;
"nodeId"?: string;
"sourceHandle"?: string;
"sourceNodeId"?: string;
"waitDeadline"?: string;
}
export interface ErrorResponse { 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"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
"error"?: string; "error"?: string;
@@ -18,32 +8,6 @@ export interface ErrorResponse {
"requestId"?: string; "requestId"?: string;
} }
export interface ExecutionPlan {
"boundaryNodes"?: BoundaryNode[];
"context"?: { [key: string]: unknown };
"edges"?: PlanEdge[];
"nodes"?: ExecutionPlanNode[];
"planId"?: string;
"runId"?: string;
"scenarioId"?: string;
"startNodeId"?: string;
"userId"?: string;
}
export interface ExecutionPlanNode {
"data"?: { [key: string]: unknown };
"id"?: string;
"type"?: string;
}
export interface PlanEdge {
"id"?: string;
"source"?: string;
"sourceHandle"?: string;
"target"?: string;
"targetHandle"?: string;
}
export interface Reward { export interface Reward {
"amount"?: number; "amount"?: number;
"currency"?: string; "currency"?: string;
+16 -15
View File
@@ -1,17 +1,5 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan } from './common.js';
export interface GetScenarioRunRequest {
"runId"?: string;
}
export interface GetScenarioRunResponse {
"plan"?: ExecutionPlan;
"runId"?: string;
"status"?: "active" | "completed" | "expired";
}
export interface HandleScenarioCallbackRequest { export interface HandleScenarioCallbackRequest {
"handle"?: string; "handle"?: string;
"nodeId"?: string; "nodeId"?: string;
@@ -20,7 +8,20 @@ export interface HandleScenarioCallbackRequest {
} }
export interface HandleScenarioCallbackResponse { export interface HandleScenarioCallbackResponse {
"plan"?: ExecutionPlan; "effect"?: PendingEffect;
}
export interface ListPendingScenarioEffectsResponse {
"effects": PendingEffect[];
}
export interface PendingEffect {
"data": { [key: string]: unknown };
"nodeId": string;
"runId": string;
"scenarioId": string;
"type": string;
"waitDeadline"?: string;
} }
export interface TriggerScenarioRequest { export interface TriggerScenarioRequest {
@@ -28,7 +29,7 @@ export interface TriggerScenarioRequest {
} }
export interface TriggerScenarioResponse { export interface TriggerScenarioResponse {
"plans"?: ExecutionPlan[]; "effects": PendingEffect[];
} }
export interface UpdateScenarioCounterRequest { export interface UpdateScenarioCounterRequest {
@@ -41,6 +42,6 @@ export interface UpdateScenarioCounterRequest {
export interface UpdateScenarioCounterResponse { export interface UpdateScenarioCounterResponse {
"completed"?: boolean; "completed"?: boolean;
"plan"?: ExecutionPlan; "effect"?: PendingEffect;
} }
+351 -502
View File
@@ -1,603 +1,452 @@
import type { RudderContext } from '../core/context.js'; import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape, RemoteConfigState } from '../state/RemoteConfigState.js'; import type { ShopHandle, OfferHandle, BuyOptions } from '../state/shops.js';
import type { ShopHandle } from '../state/shops.js'; import type { PurchaseOfferResponse } from '../generated/stores.js';
import { api } from '../generated/api.js'; import { api } from '../generated/api.js';
import type { TriggerScenarioResponse } from '../generated/scenarios.js'; import type { PendingEffect, TriggerScenarioResponse } from '../generated/scenarios.js';
import type { ExecutionPlan } from '../generated/common.js'; import { RudderErrorCodes } from '../generated/errors.js';
import type { PlanStateStore } from './engine/IndexedDbPlanStore.js';
/**
* The subset of domains the scenario runtime drives: it invalidates the player
* and inventory after server mutations, patches remote config for override
* nodes, and resolves stores for store nodes.
*/
export interface ScenarioDomains {
readonly player: { invalidate(): void };
readonly inventory: { invalidate(): void };
readonly config: RemoteConfigState;
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
}
import { import {
RudderNetworkError, RudderNetworkError,
RudderHttpError, RudderHttpError,
} from '../client/RudderError.js'; } from '../client/RudderError.js';
import {
findNode,
matchingEdges,
matchingBoundaryNodes,
completedHandleKey,
durationToMs,
} from './engine/DagWalker.js';
import { RuntimeRun, PlanRun, type ActiveNodeState, type ScenarioRunFailedEvent } from './engine/types.js';
import {
ScenarioNodeContext,
NotificationSession,
WaitSession,
StoreSession,
LeaderboardSession,
QuestSession,
BattlePassSession,
BattlePassLevelSession,
} from './engine/sessions.js';
import type { BattlePassService } from '../battlepass/BattlePassService.js'; import type { BattlePassService } from '../battlepass/BattlePassService.js';
/** export interface ScenarioDomains {
* Error thrown when a boundary HTTP call fails with a transient error readonly player: { invalidate(): void };
* (network failure or server 5xx). The caller should NOT advance the run; readonly inventory: { invalidate(): void };
* the node stays active and the handle stays pending for retry on reconnect. readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
*/ }
class TransientBoundaryError extends Error {
constructor(public readonly inner: unknown) { const DEFAULT_PENDING_INTERVAL_MS = 30_000;
super('Transient boundary error'); const JITTER_RATIO = 0.2;
this.name = 'TransientBoundaryError';
} type ActiveRecord = {
runId: string;
nodeId: string;
scenarioId: string;
storePurchased?: boolean;
lastPurchase?: PurchaseOfferResponse;
};
function effectKey(runId: string, nodeId: string): string {
return `${runId}:${nodeId}`;
} }
/** Returns true for network / 5xx errors that may succeed on retry. */
function isTransientHttpError(err: unknown): boolean { function isTransientHttpError(err: unknown): boolean {
if (err instanceof RudderNetworkError) return true; if (err instanceof RudderNetworkError) return true;
if (err instanceof RudderHttpError && err.status >= 500) return true; if (err instanceof RudderHttpError && err.status >= 500) return true;
return false; return false;
} }
function isRankNotEligible(err: unknown): boolean { function isDroppedRunError(err: unknown): boolean {
return err instanceof RudderHttpError && err.code === 'rank_not_eligible'; if (!(err instanceof RudderHttpError)) return false;
return (
err.code === RudderErrorCodes.unknownRun || err.code === RudderErrorCodes.runExpired
);
} }
function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigShape> { function asObjectives(value: unknown): Array<Record<string, unknown>> {
private readonly runs = new Map<string, RuntimeRun>(); if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === 'object' && !Array.isArray(item),
);
}
function asLevel(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
export class ScenarioService {
private readonly active = new Map<string, ActiveRecord>();
private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>(); private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly droppedRuns = new Set<string>();
private readonly completedRuns = new Set<string>();
private heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
private running = false;
private polling = false;
private pollQueued = false;
constructor( constructor(
private readonly ctx: RudderContext, private readonly ctx: RudderContext,
private readonly domains: ScenarioDomains, private readonly domains: ScenarioDomains,
private readonly battlePass: BattlePassService, private readonly battlePass: BattlePassService,
private readonly planStore: PlanStateStore | null, private readonly intervalMs: number = DEFAULT_PENDING_INTERVAL_MS,
) {} ) {}
// ---- Public Events (subscribe to receive node dispatches) ----
onNotification?: (session: NotificationSession) => void;
onWait?: (session: WaitSession) => void;
onStore?: (session: StoreSession) => void;
onLeaderboard?: (session: LeaderboardSession) => void;
onRunCompleted?: (run: PlanRun) => void;
onRunFailed?: (event: ScenarioRunFailedEvent) => void;
onCompleted?: () => void;
get isRunning(): boolean { get isRunning(): boolean {
return this.runs.size > 0; return this.active.size > 0;
} }
get activeRuns(): readonly PlanRun[] {
return [...this.runs.values()].map((r) => this.toPlanRun(r));
}
// ---- Public API ----
/** Triggers scenarios by event name. Returns all started plans. */
async send(eventName: string): Promise<TriggerScenarioResponse> { async send(eventName: string): Promise<TriggerScenarioResponse> {
const response = await api.triggerScenario(this.ctx, { event: eventName }); const response = await api.triggerScenario(this.ctx, { event: eventName });
this.domains.player.invalidate(); this.domains.player.invalidate();
this.domains.inventory.invalidate(); this.domains.inventory.invalidate();
this.startPlans(response.plans ?? []); this.ingest(response.effects ?? []);
return response; return response;
} }
/** Completes the current active node with the given handle. */ async start(): Promise<void> {
async respond(handle: string): Promise<void> { if (this.running) return;
for (const run of this.runs.values()) { this.running = true;
for (const nodeId of run.activeNodes.keys()) { if (typeof document !== 'undefined') {
await this.completeNodeAsync(run.runId, nodeId, handle); document.addEventListener('visibilitychange', this.onVisibilityChange);
return;
}
} }
await this.pollPending();
this.scheduleHeartbeat();
} }
/**
* Restores persisted scenario state from IndexedDB.
* Call once after constructing the client.
*/
async restore(): Promise<void> {
const store = this.planStore;
if (!store) return;
try {
await store.load();
const json = store.state;
if (!json) return;
const persisted = JSON.parse(json) as { runs: Array<{
runId: string;
plan: ExecutionPlan;
activeNodes: ActiveNodeState[];
completedHandles: string[];
}> };
for (const saved of persisted.runs ?? []) {
let plan = saved.plan;
let activeNodes = saved.activeNodes;
let completedHandles = saved.completedHandles;
try {
const response = await api.getScenarioRun(this.ctx, { runId: saved.runId });
const status: string | undefined = response?.status;
if (status === 'unknown_run' || status === 'expired') {
continue;
}
if (response?.plan) {
plan = response.plan;
activeNodes = [];
completedHandles = [];
}
} catch {
// Network/server error — fall back to persisted local state.
}
const run = new RuntimeRun(saved.runId, plan);
for (const handle of completedHandles ?? []) {
run.completedHandles.add(handle);
}
this.runs.set(run.runId, run);
if (activeNodes.length > 0) {
for (const nodeState of activeNodes) {
run.activeNodes.set(nodeState.nodeId, nodeState);
this.dispatchActiveNode(run, nodeState, true);
}
} else if (plan?.nodes?.length) {
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0];
if (startNode?.id) {
this.activateNode(run, startNode.id);
}
}
}
await this.persist();
} catch {
// Corrupted state — clear and continue.
this.clear();
}
}
/** Clears all active runs and persisted state. */
clear(): void { clear(): void {
this.running = false;
this.pollQueued = false;
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
for (const timer of this.waitTimers.values()) { for (const timer of this.waitTimers.values()) {
clearTimeout(timer); clearTimeout(timer);
} }
this.waitTimers.clear(); this.waitTimers.clear();
this.runs.clear(); this.active.clear();
this.persist().catch(() => {}); this.droppedRuns.clear();
} this.completedRuns.clear();
if (typeof document !== 'undefined') {
// ---- Internal: Plan lifecycle ---- document.removeEventListener('visibilitychange', this.onVisibilityChange);
private startPlans(plans: ExecutionPlan[]): void {
for (const plan of plans ?? []) {
this.startPlan(plan);
} }
} }
private startPlan(plan: ExecutionPlan): void { private readonly onVisibilityChange = (): void => {
if (!plan.nodes?.length) return; if (this.running && typeof document !== 'undefined' && !document.hidden) {
if (plan.boundaryNodes?.length && !plan.runId) { void this.heartbeatTick();
throw new Error('ExecutionPlan has boundaryNodes but missing runId');
} }
// Dedup: server returned a plan with a runId already active — skip without };
// restarting wait timers or re-dispatching node sessions.
if (plan.runId && this.runs.has(plan.runId)) return; private scheduleHeartbeat(): void {
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0]; if (!this.running) return;
if (!startNode?.id) return; if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
const runId = plan.runId ?? crypto.randomUUID().replace(/-/g, ''); const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
const run = new RuntimeRun(runId, plan); this.heartbeatTimer = setTimeout(() => {
this.runs.set(run.runId, run); void this.heartbeatTick();
this.activateNode(run, startNode.id); }, this.intervalMs * jitter);
this.persist().catch(() => {});
} }
private activateNode( private async heartbeatTick(): Promise<void> {
run: RuntimeRun, if (!this.running) return;
nodeId: string, if (typeof document !== 'undefined' && document.hidden) {
restoredState?: ActiveNodeState, this.scheduleHeartbeat();
): void { return;
const node = findNode(run.plan, nodeId); }
if (!node?.id) return; await this.pollPending();
const state = restoredState ?? { nodeId }; this.scheduleHeartbeat();
run.activeNodes.set(nodeId, state);
this.dispatchActiveNode(run, state, restoredState !== undefined);
} }
// ---- Internal: Node Dispatch ---- private async pollPending(): Promise<void> {
if (this.polling) {
this.pollQueued = true;
return;
}
this.polling = true;
try {
do {
this.pollQueued = false;
try {
const response = await api.listPendingScenarioEffects(this.ctx);
this.reconcile(response.effects ?? []);
} catch {
}
} while (this.pollQueued);
} finally {
this.polling = false;
}
}
private dispatchActiveNode( private reconcile(effects: PendingEffect[]): void {
run: RuntimeRun, const incoming = effects.filter((effect) => !this.droppedRuns.has(effect.runId));
state: ActiveNodeState, const incomingKeys = new Set(
restored: boolean, incoming.map((effect) => effectKey(effect.runId, effect.nodeId)),
): void { );
const node = findNode(run.plan, state.nodeId); const before = new Set(this.active.keys());
if (!node) {
run.activeNodes.delete(state.nodeId); this.ingest(incoming);
this.checkRunCompleted(run);
for (const [key, record] of [...this.active.entries()]) {
if (incomingKeys.has(key)) continue;
this.deactivate(key);
this.emitCompletedIfIdle(record.runId, record.scenarioId);
}
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; return;
} }
const ctx = new ScenarioNodeContext( const record: ActiveRecord = {
this.toPlanRun(run), runId: pending.runId,
node, nodeId: pending.nodeId,
(rid, nid, h) => this.completeNodeInternal(rid, nid, h, true), scenarioId: pending.scenarioId,
(rid, nid, ck, amt) => this.updateProgressInternal(rid, nid, ck, amt), };
); this.active.set(key, record);
this.completedRuns.delete(pending.runId);
switch (node.type) { if (pending.waitDeadline) {
case 'wait': this.scheduleWait(key, pending.waitDeadline);
this.dispatchWait(run, state, ctx);
break;
case 'remote_config_override':
this.dispatchRemoteConfigOverride(run, state, ctx);
break;
case 'notification':
{
const session = new NotificationSession(ctx);
this.ctx.effects.emitNotification(session);
this.onNotification?.(session);
} }
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; break;
case 'store': case 'store':
{ this.dispatchStore(pending, record);
const session = new StoreSession(ctx, this.domains.stores);
this.ctx.effects.emitStoreOffer(session);
this.onStore?.(session);
}
break; break;
case 'leaderboard': case 'wait':
{ this.ctx.effects.emitWait({
const session = new LeaderboardSession(ctx); deadlineUtc: pending.waitDeadline
this.ctx.effects.emitLeaderboard(session); ? new Date(pending.waitDeadline)
this.onLeaderboard?.(session); : new Date(),
} });
break; 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': case 'quest':
this.ctx.effects.emitQuest(new QuestSession(ctx)); this.ctx.effects.emitQuest({
name: asString(pending.data.name),
objectives: asObjectives(pending.data.objectives),
reportProgress: (objectiveId, amount) =>
this.reportQuestProgress(record, objectiveId, amount),
});
break; break;
case 'battlepass': case 'battlepass':
this.ctx.effects.emitBattlePass(new BattlePassSession(ctx, this.battlePass)); this.dispatchBattlePass(record);
break; break;
case 'battlepass_level': case 'battlepass_level':
this.ctx.effects.emitBattlePassLevel(new BattlePassLevelSession(ctx)); this.ctx.effects.emitBattlePassLevel({
level: asLevel(pending.data.levelNumber),
claim: () => this.complete(record, 'onComplete'),
});
break; break;
default: default:
// Unsupported node type — fail the run (surfaced via onScenarioFailed)
// instead of leaving it stalled on a node no handler will complete.
console.warn( console.warn(
`[Rudder] Unsupported scenario node type '${node.type}' (${node.id})`, `[Rudder] Unsupported scenario node type '${pending.type}' (${pending.nodeId})`,
); );
this.failRun( this.dropRun(
run, record.runId,
state.nodeId, record.nodeId,
new Error(`Unsupported scenario node type '${node.type}'`), new Error(`Unsupported scenario node type '${pending.type}'`),
); );
break; break;
} }
} }
private dispatchWait( private dispatchStore(pending: PendingEffect, record: ActiveRecord): void {
run: RuntimeRun, const slug = asString(pending.data.storeSlug);
state: ActiveNodeState, this.ctx.effects.emitStoreOffer(
ctx: ScenarioNodeContext, this.domains.stores.getBySlug(slug).then((store) => ({
): void { store,
// Prefer server-provided waitDeadline from the plan boundary over local calculation. offers: store.offers,
// The server stamps waitDeadline on server-enforced wait boundaries (see StampBoundaries). message: typeof pending.data.message === 'string' ? pending.data.message : undefined,
if (!state.waitDeadlineUtc) { buy: (offer, options) => this.buyStore(record, offer, options),
const boundary = (run.plan.boundaryNodes ?? []).find( dismiss: () => this.dismissStore(record),
b => b.sourceNodeId === state.nodeId && b.waitDeadline, })),
);
if (boundary?.waitDeadline) {
state.waitDeadlineUtc = boundary.waitDeadline;
} else {
const data = ctx.data;
const duration = (data.duration as number) ?? 0;
const unit = (data.unit as string) ?? 'seconds';
const ms = durationToMs(duration, unit);
state.waitDeadlineUtc = new Date(Date.now() + ms).toISOString();
}
this.persist().catch(() => {});
}
const deadline = new Date(state.waitDeadlineUtc).getTime();
const session = new WaitSession(ctx, new Date(deadline));
this.onWait?.(session);
this.ctx.effects.emitWait(session);
const remaining = deadline - Date.now();
if (remaining <= 0) {
this.completeNode(run.runId, state.nodeId, 'onComplete');
} else {
const timerKey = `${run.runId}:${state.nodeId}`;
clearTimeout(this.waitTimers.get(timerKey));
this.waitTimers.set(
timerKey,
setTimeout(() => {
this.completeNode(run.runId, state.nodeId, 'onComplete');
this.waitTimers.delete(timerKey);
}, remaining),
); );
} }
}
private dispatchRemoteConfigOverride( private dispatchBattlePass(record: ActiveRecord): void {
run: RuntimeRun, const { scenarioId, nodeId, runId } = record;
state: ActiveNodeState, this.ctx.effects.emitBattlePass({
ctx: ScenarioNodeContext, getProgress: () => this.battlePass.getProgress(scenarioId, nodeId),
): void { addXp: (source, amount) =>
const patches = ctx.data.patches as Array<{ this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }),
path?: string; claimReward: (level, track) =>
valueType?: string; this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }),
value?: string; purchasePremium: async () => {
}> | undefined; const response = await this.battlePass.purchasePremium({
scenarioId,
if (patches) {
for (const patch of patches) {
if (patch.path) {
this.domains.config.applyOverride(
patch.path,
patch.value ?? '',
patch.valueType ?? 'json',
);
this.ctx.effects.emitConfigChanged(patch.path);
}
}
}
this.completeNode(run.runId, state.nodeId, 'output');
}
// ---- Internal: Node completion ----
/** Synchronous fire-and-forget completion. */
private completeNode(runId: string, nodeId: string, handle: string): void {
this.completeNodeInternal(runId, nodeId, handle, true).catch(() => {});
}
/** Async completion — called by sessions. */
async completeNodeAsync(
runId: string,
nodeId: string,
handle: string,
): Promise<void> {
return this.completeNodeInternal(runId, nodeId, handle, true);
}
private async completeNodeInternal(
runId: string,
nodeId: string,
handle: string,
continueOnBoundary: boolean,
): Promise<void> {
const run = this.runs.get(runId);
if (!run) return;
const key = completedHandleKey(nodeId, handle);
if (run.completedHandles.has(key)) return; // idempotent
// Mark this transition as in flight so that a transiently-empty
// activeNodes (e.g. between this node and an auto-completing successor)
// does not cause the run to be reported complete prematurely.
run.pendingTransitions++;
try {
let boundaryContinued = false;
if (continueOnBoundary) {
try {
boundaryContinued = await this.continueBoundary(run, nodeId, handle);
} catch (err: unknown) {
if (err instanceof TransientBoundaryError) {
// Transient error — don't advance the run.
// Node stays active, handle stays pending for retry on reconnect.
run.pendingTransitions--;
await this.persist();
return;
}
throw err; // terminal — handled by outer catch
}
}
// Only now — after the server has confirmed — mark the handle and node.
run.completedHandles.add(key);
run.activeNodes.delete(nodeId);
await this.persist();
if (!boundaryContinued) {
for (const edge of matchingEdges(run.plan, nodeId, handle)) {
if (edge.target) {
this.activateNode(run, edge.target);
}
}
}
run.pendingTransitions--;
this.checkRunCompleted(run);
await this.persist();
} catch (err) {
run.pendingTransitions--;
if (isRankNotEligible(err)) {
await this.persist();
throw err;
}
this.failRun(
run,
nodeId, nodeId,
err instanceof Error ? err : new Error(String(err)), 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 continueBoundary(
run: RuntimeRun,
nodeId: string,
handle: string,
): Promise<boolean> {
const boundaries = [...matchingBoundaryNodes(run.plan, nodeId, handle)];
if (boundaries.length === 0) return false;
for (const boundary of boundaries) { private async buyStore(
record: ActiveRecord,
offer: OfferHandle,
options?: BuyOptions,
): Promise<PurchaseOfferResponse> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) {
return { success: false, error: 'store session already resolved' };
}
if (!record.storePurchased) {
const purchase = await offer.buy(options);
if (!purchase.success) return purchase;
record.storePurchased = true;
record.lastPurchase = purchase;
}
await this.complete(record, 'onPurchase');
return record.lastPurchase ?? { success: true };
}
private async dismissStore(record: ActiveRecord): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (record.storePurchased) return;
await this.complete(record, 'onDecline');
}
private async reportQuestProgress(
record: ActiveRecord,
objectiveId: string,
amount = 1,
): Promise<void> {
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioId: record.scenarioId,
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.scenarioId);
}
} catch {
}
}
private async complete(record: ActiveRecord, handle: string): Promise<void> {
const key = effectKey(record.runId, record.nodeId);
if (!this.active.has(key)) return;
if (this.droppedRuns.has(record.runId)) return;
try { try {
const response = await api.handleScenarioCallback(this.ctx, { const response = await api.handleScenarioCallback(this.ctx, {
scenarioId: run.plan.scenarioId, scenarioId: record.scenarioId,
nodeId: boundary.sourceNodeId, nodeId: record.nodeId,
handle: boundary.sourceHandle, handle,
runId: run.runId, runId: record.runId,
}); });
this.domains.player.invalidate(); this.domains.player.invalidate();
this.domains.inventory.invalidate(); this.domains.inventory.invalidate();
if (response?.plan) { if (!this.active.has(key)) return;
this.startPlan(response.plan); this.deactivate(key);
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioId);
} }
} catch (err: unknown) { } catch (err) {
// Boundary call failed — try to reconcile with server. if (isTransientHttpError(err)) return;
let reconciled = false; if (isDroppedRunError(err)) {
try { const error = err instanceof Error ? err : new Error(String(err));
const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId }); this.dropRun(record.runId, record.nodeId, error);
const reconcileStatus: string | undefined = reconcile?.status;
if (reconcileStatus === 'unknown_run' || reconcileStatus === 'expired') {
this.runs.delete(run.runId);
await this.persist();
reconciled = true;
} else if (reconcile?.plan) {
const newRun = new RuntimeRun(run.runId, reconcile.plan);
this.runs.set(run.runId, newRun);
const startNode = findNode(reconcile.plan, reconcile.plan.startNodeId) ?? reconcile.plan.nodes?.[0];
if (startNode?.id) {
this.activateNode(newRun, startNode.id);
}
reconciled = true;
}
} catch {
// Reconciliation also failed.
}
if (!reconciled) {
// Reconcile did not resolve — distinguish transient from terminal.
if (isTransientHttpError(err)) {
throw new TransientBoundaryError(err);
}
// Terminal error (typed error code from the server) — let the caller failRun.
throw err; throw err;
} }
// If reconciled, the boundary was handled (run corrected or removed). throw err;
// Fall through to continue to the next boundary.
}
}
return true;
}
// ---- Internal: Counter progress ----
async updateProgressInternal(
runId: string,
nodeId: string,
counterKey: string,
amount: number,
): Promise<void> {
const run = this.runs.get(runId);
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioId: run?.plan.scenarioId ?? '',
nodeId,
counterKey,
amount,
runId,
});
// The server reports objective completion; it no longer returns a plan from the
// counter endpoint. On completion, cross the quest's onComplete boundary (which
// grants rewards + advances the run) — idempotent if the consumer also calls complete().
if (response?.completed) {
await this.completeNodeInternal(runId, nodeId, 'onComplete', true);
}
} catch {
// Counter update failure does not fail the run.
} }
} }
// ---- Internal: Run lifecycle ---- private scheduleWait(key: string, waitDeadline: string): void {
const deadline = Date.parse(waitDeadline);
private checkRunCompleted(run: RuntimeRun): void { if (Number.isNaN(deadline)) return;
if (run.activeNodes.size > 0) return; const remaining = Math.max(0, deadline - Date.now());
// A node transition is still settling (e.g. an auto-completing const existing = this.waitTimers.get(key);
// remote_config_override node is about to activate its successor). if (existing) clearTimeout(existing);
// Wait for it to finish before declaring the run complete. this.waitTimers.set(
if (run.pendingTransitions > 0) return; key,
if (!this.runs.has(run.runId)) return; // already completed/removed setTimeout(() => {
this.runs.delete(run.runId); this.waitTimers.delete(key);
const planRun = this.toPlanRun(run); void this.pollPending();
this.onRunCompleted?.(planRun); }, remaining),
this.onCompleted?.(); );
this.ctx.effects.emitScenarioCompleted(planRun);
this.persist().catch(() => {});
} }
private failRun( private deactivate(key: string): void {
run: RuntimeRun, const timer = this.waitTimers.get(key);
nodeId: string, if (timer) {
error: Error, clearTimeout(timer);
): void { this.waitTimers.delete(key);
}
this.active.delete(key);
}
private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId);
let scenarioId = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioId) scenarioId = record.scenarioId;
this.deactivate(key);
}
console.warn( console.warn(
`[Rudder] Scenario run ${run.runId} failed at node ${nodeId}: ${error.message}`, `[Rudder] Scenario run ${runId} failed at node ${nodeId}: ${error.message}`,
); );
this.runs.delete(run.runId); this.ctx.effects.emitScenarioFailed({
const event: ScenarioRunFailedEvent = { run: this.toPlanRun(run), nodeId, error }; runId,
this.onRunFailed?.(event); scenarioId,
this.ctx.effects.emitScenarioFailed(event); nodeId,
this.persist().catch(() => {}); error,
}
// ---- Internal: Helpers ----
private toPlanRun(run: RuntimeRun): PlanRun {
return new PlanRun(
run.runId,
run.plan.planId ?? '',
run.plan.scenarioId ?? '',
run.plan.userId ?? '',
[...run.activeNodes.keys()],
run.plan,
);
}
private async persist(): Promise<void> {
const store = this.planStore;
if (!store) return;
const state = JSON.stringify({
runs: [...this.runs.values()].map((run) => ({
runId: run.runId,
plan: run.plan,
activeNodes: [...run.activeNodes.values()],
completedHandles: [...run.completedHandles],
})),
}); });
store.state = state; }
try {
await store.save(state); private emitCompletedIfIdle(runId: string, scenarioId: string): void {
} catch { if (this.droppedRuns.has(runId)) return;
// Best-effort persistence. 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, scenarioId });
} }
} }
-74
View File
@@ -1,74 +0,0 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../generated/common.js';
/** Finds a node by ID within a plan (linear scan of the nodes array). */
export function findNode(
plan: ExecutionPlan,
nodeId: string | undefined,
): ExecutionPlanNode | undefined {
if (!nodeId || !plan.nodes) return undefined;
return plan.nodes.find((n) => n.id === nodeId);
}
/** Yields all edges whose source node and sourceHandle match. */
export function* matchingEdges(
plan: ExecutionPlan,
sourceNodeId: string,
sourceHandle: string,
): Generator<PlanEdge> {
for (const edge of plan.edges ?? []) {
if (
edge.source === sourceNodeId &&
(edge.sourceHandle ?? '') === (sourceHandle ?? '')
) {
yield edge;
}
}
}
/** Yields all boundary nodes matching the completed node + handle. */
export function* matchingBoundaryNodes(
plan: ExecutionPlan,
sourceNodeId: string,
sourceHandle: string,
): Generator<BoundaryNode> {
for (const boundary of plan.boundaryNodes ?? []) {
if (
boundary.sourceNodeId === sourceNodeId &&
(boundary.sourceHandle ?? '') === (sourceHandle ?? '')
) {
yield boundary;
}
}
}
/** Returns a deduplication key for completed node+handle pairs. */
export function completedHandleKey(nodeId: string, handle: string): string {
return `${nodeId}:${handle ?? ''}`;
}
/** Converts wait duration+unit to milliseconds. Unit required; defaults to seconds when unit is unrecognized. */
export function durationToMs(duration: number, unit: string): number {
switch ((unit ?? 'seconds').toLowerCase()) {
case 'days':
case 'day':
case 'd':
return duration * 86_400_000;
case 'hours':
case 'hour':
case 'hr':
case 'h':
return duration * 3_600_000;
case 'minutes':
case 'minute':
case 'min':
case 'm':
return duration * 60_000;
case 'seconds':
case 'second':
case 'sec':
case 's':
return duration * 1000;
default:
return duration * 1000; // default: seconds
}
}
-94
View File
@@ -1,94 +0,0 @@
/**
* IndexedDB-backed plan state store for persisting scenario execution state.
*
* The C# SDK uses a synchronous `IPlanStateStore { string State { get; set; } }`.
* IndexedDB is inherently async, so we provide an async `load()` / `save()` API
* alongside a synchronous `state` property for immediate reads after load.
*/
export interface PlanStateStore {
/** Current serialized state (available after `load()` or `save()`). */
state: string | null;
/** Loads persisted state from IndexedDB. Call once on SDK initialization. */
load(): Promise<void>;
/** Persists the current state to IndexedDB. */
save(state: string | null): Promise<void>;
}
const DB_NAME = 'RudderPlanState';
const STORE_NAME = 'state';
const KEY = 'active_runs';
const DB_VERSION = 1;
export function createIndexedDbPlanStateStore(): PlanStateStore {
let dbPromise: Promise<IDBDatabase> | null = null;
let cachedState: string | null = null;
function getDb(): Promise<IDBDatabase> {
if (!dbPromise) {
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return dbPromise;
}
return {
get state(): string | null {
return cachedState;
},
set state(value: string | null) {
cachedState = value;
// Best-effort async write — does not block the setter.
getDb()
.then((db) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
if (value === null) {
tx.objectStore(STORE_NAME).delete(KEY);
} else {
tx.objectStore(STORE_NAME).put(value, KEY);
}
})
.catch(() => {
// IndexedDB write failed — state is still in memory.
});
},
async load(): Promise<void> {
const db = await getDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(KEY);
req.onsuccess = () => {
cachedState = (req.result as string) ?? null;
resolve();
};
req.onerror = () => reject(req.error);
});
},
async save(state: string | null): Promise<void> {
cachedState = state;
const db = await getDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const req =
state === null
? tx.objectStore(STORE_NAME).delete(KEY)
: tx.objectStore(STORE_NAME).put(state, KEY);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
};
}
-347
View File
@@ -1,347 +0,0 @@
import type { ExecutionPlanNode } from '../../generated/common.js';
import type { PlanRun } from './types.js';
import type { BuyOptions, OfferHandle, ShopHandle } from '../../state/shops.js';
import type { PurchaseOfferResponse } from '../../generated/stores.js';
import type { BattlePassService } from '../../battlepass/BattlePassService.js';
import type {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
GetBattlePassProgressResponse,
PurchaseBattlePassPremiumResponse,
} from '../../generated/battlepass.js';
/** Resolves a store handle by slug — satisfied by the stores domain. */
interface StoreResolver {
getBySlug(slug: string): Promise<ShopHandle>;
}
// ---- ScenarioNodeContext ----
/**
* Context object passed to each session.
* Wraps the current run, plan node, and raw node data.
* The session calls `complete(handle)` to advance the DAG.
*/
export class ScenarioNodeContext {
constructor(
public readonly run: PlanRun,
public readonly node: ExecutionPlanNode,
private readonly onComplete: (runId: string, nodeId: string, handle: string) => Promise<void>,
private readonly onProgress: (runId: string, nodeId: string, counterKey: string, amount: number) => Promise<void>,
) {}
/** Extracts a typed value from node data. */
get<T>(key: string, defaultValue: T): T {
const data = this.node.data as Record<string, unknown> | undefined;
const value = data?.[key];
if (value === undefined || value === null) return defaultValue;
return value as unknown as T;
}
/** Full node data as a typed record. */
get data(): Record<string, unknown> {
return (this.node.data as Record<string, unknown>) ?? {};
}
/** Completes the current node with the given output handle. */
async complete(handle: string): Promise<void> {
await this.onComplete(this.run.runId, this.node.id!, handle);
}
/** Updates counter progress for server-managed scenario nodes. */
async updateProgress(counterKey: string, amount: number): Promise<void> {
await this.onProgress(this.run.runId, this.node.id!, counterKey, amount);
}
}
// ---- NotificationSession ----
export class NotificationSession {
public readonly title: string;
public readonly message: string;
constructor(private readonly context: ScenarioNodeContext) {
this.title = context.get('title', '');
this.message = context.get('message', '');
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async complete(): Promise<void> {
await this.context.complete('output');
}
}
// ---- WaitSession ----
export class WaitSession {
constructor(
private readonly context: ScenarioNodeContext,
public readonly deadlineUtc: Date,
) {}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
}
// ---- StoreSession ----
export class StoreSession {
private resolved = false;
constructor(
private readonly context: ScenarioNodeContext,
private readonly stores: StoreResolver,
) {}
get isResolved(): boolean {
return this.resolved;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async getStore(): Promise<ShopHandle> {
return this.stores.getBySlug(this.get('storeSlug', ''));
}
async buy(
offer: OfferHandle,
options?: BuyOptions,
): Promise<PurchaseOfferResponse> {
if (this.resolved) {
return { success: false, error: 'store session already resolved' };
}
const purchase = await offer.buy(options);
if (purchase.success) {
this.resolved = true;
await this.context.complete('onPurchase');
}
return purchase;
}
async decline(): Promise<void> {
if (this.resolved) return;
this.resolved = true;
await this.context.complete('onDecline');
}
}
// ---- LeaderboardSession ----
export class LeaderboardSession {
private resolved = false;
constructor(private readonly context: ScenarioNodeContext) {}
get isResolved(): boolean {
return this.resolved;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
async end(): Promise<void> {
if (this.resolved) return;
await this.context.complete('onEnd');
this.resolved = true;
}
async claim(): Promise<void> {
if (this.resolved) return;
await this.context.complete('onClaim');
this.resolved = true;
}
/** @deprecated Use {@link LeaderboardSession.claim}. Removed in the next SDK version. */
async rewardClaimed(): Promise<void> {
return this.claim();
}
}
// ---- QuestSession ----
/**
* A scenario quest node. The game reports objective progress; the server
* auto-completes the node (crossing `onComplete`, which grants rewards and
* advances the run) once every objective is satisfied.
*/
export class QuestSession {
constructor(private readonly context: ScenarioNodeContext) {}
get name(): string {
return this.context.get('name', '');
}
get objectives(): Array<Record<string, unknown>> {
return this.context.get('objectives', [] as Array<Record<string, unknown>>);
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
/**
* Reports progress toward an objective. When the reported counter completes
* every objective, the server signals completion and the node crosses
* `onComplete` automatically.
*/
async reportProgress(objectiveId: string, amount = 1): Promise<void> {
await this.context.updateProgress(objectiveId, amount);
}
}
// ---- BattlePassSession ----
/**
* A scenario battlepass node. Exposes the battlepass operations (xp, premium,
* progress) bound to this node's scenario/node/run ids, plus explicit boundary
* crossings (`onLevelUp`, `onPremiumPurchase`) the game drives from its UI. The
* server validates each crossing (e.g. onLevelUp requires the level be reached).
*/
export class BattlePassSession {
constructor(
private readonly context: ScenarioNodeContext,
private readonly battlePass: BattlePassService,
) {}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
private ids(): { scenarioId: string; nodeId: string; runId: string } {
return {
scenarioId: this.context.run.scenarioId,
nodeId: this.context.node.id!,
runId: this.context.run.runId,
};
}
/** Current xp/level/premium/claimed-tiers for this node. */
getProgress(): Promise<GetBattlePassProgressResponse> {
const { scenarioId, nodeId } = this.ids();
return this.battlePass.getProgress(scenarioId, nodeId);
}
/** Credits XP from a configured source. */
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse> {
const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId });
}
/** Claims a tier reward at a reached level. */
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse> {
const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId });
}
/** Purchases premium, then crosses `onPremiumPurchase` on success. */
async purchasePremium(): Promise<PurchaseBattlePassPremiumResponse> {
const { scenarioId, nodeId, runId } = this.ids();
const response = await this.battlePass.purchasePremium({
scenarioId,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
if (response.success) {
await this.context.complete('onPremiumPurchase');
}
return response;
}
/** Crosses `onLevelUp` (server validates the node's level was reached). */
async levelUp(): Promise<void> {
await this.context.complete('onLevelUp');
}
/** Ends the battlepass node via `onComplete`. */
async end(): Promise<void> {
await this.context.complete('onComplete');
}
}
// ---- BattlePassLevelSession ----
/**
* A scenario battlepass_level node — a single claimable tier. `claim()` crosses
* `onComplete`, which the server accepts only once the player has reached the
* node's configured level.
*/
export class BattlePassLevelSession {
constructor(private readonly context: ScenarioNodeContext) {}
get level(): number {
return this.context.get('levelNumber', 0);
}
get data(): Record<string, unknown> {
return this.context.data;
}
get node(): ExecutionPlanNode {
return this.context.node;
}
get<T>(key: string, defaultValue: T): T {
return this.context.get(key, defaultValue);
}
/** Claims this tier; crosses `onComplete` (server checks level reached). */
async claim(): Promise<void> {
await this.context.complete('onComplete');
}
}
-63
View File
@@ -1,63 +0,0 @@
import type { ExecutionPlan } from '../../generated/common.js';
/**
* Active node state within a running scenario plan.
* For wait nodes, this includes the deadline.
*/
export interface ActiveNodeState {
nodeId: string;
waitDeadlineUtc?: string; // ISO 8601 string, set for wait nodes
}
/**
* A running scenario plan instance — tracks active nodes and completed handles.
*/
export class RuntimeRun {
public readonly activeNodes = new Map<string, ActiveNodeState>();
public readonly completedHandles = new Set<string>();
/**
* Number of node transitions currently in flight for this run.
* A transition begins when a node starts completing and ends once its
* successors have been activated. While > 0 the run must not be considered
* complete, even if `activeNodes` is transiently empty (e.g. an
* auto-completing remote_config_override node between two client nodes).
*/
public pendingTransitions = 0;
constructor(
public readonly runId: string,
public readonly plan: ExecutionPlan,
) {}
}
/** Serialization format for a persisted run. */
export interface PersistedRun {
runId: string;
plan: ExecutionPlan;
activeNodes: ActiveNodeState[];
completedHandles: string[];
}
/** Top-level serialization format for all active runs. */
export interface PersistedState {
runs: PersistedRun[];
}
/** Read-only snapshot of a running plan, surfaced in events. */
export class PlanRun {
constructor(
public readonly runId: string,
public readonly planId: string,
public readonly scenarioId: string,
public readonly userId: string,
public readonly activeNodeIds: readonly string[],
public readonly plan: ExecutionPlan,
) {}
}
/** Failure event payload. */
export interface ScenarioRunFailedEvent {
run: PlanRun;
nodeId: string;
error: Error;
}
+2 -3
View File
@@ -30,7 +30,7 @@ describe('AuthService', () => {
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' }); const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
expect(fetchMock).toHaveBeenCalledTimes(8); expect(fetchMock).toHaveBeenCalledTimes(9);
const [url, init] = fetchMock.mock.calls[0]; const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/sdk/v1/authorization/device'); expect(url).toContain('/sdk/v1/authorization/device');
const body = JSON.parse(init.body); const body = JSON.parse(init.body);
@@ -46,10 +46,9 @@ describe('AuthService', () => {
'/sdk/v1/player/information', '/sdk/v1/player/information',
'/sdk/v1/stores', '/sdk/v1/stores',
'/sdk/v1/catalog', '/sdk/v1/catalog',
'/sdk/v1/scenarios/pending',
'/sdk/v1/scenarios/trigger', '/sdk/v1/scenarios/trigger',
// The login event invalidates the warmed profile → refetch.
'/sdk/v1/player/information', '/sdk/v1/player/information',
// Sync engine baseline poll, fired right after login.
'/sdk/v1/sync', '/sdk/v1/sync',
]); ]);
}); });
+2 -2
View File
@@ -64,7 +64,7 @@ describe('public API surface', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
expect(client.effects).toBeDefined(); expect(client.effects).toBeDefined();
@@ -75,7 +75,7 @@ describe('public API surface', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
expect(client.remoteConfig).toBeDefined(); expect(client.remoteConfig).toBeDefined();
+10 -10
View File
@@ -148,7 +148,7 @@ describe('lazy scenario runtime', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
projectKey: 'proj_123', projectKey: 'proj_123',
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
const internals = client as unknown as { const internals = client as unknown as {
scenarioRuntime: unknown; scenarioRuntime: unknown;
@@ -163,7 +163,7 @@ describe('lazy scenario runtime', () => {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
projectKey: 'proj_123', projectKey: 'proj_123',
runtime: { planStateStore: null }, // default loginEvent: player_login runtime: {},
}); });
const notifications: string[] = []; const notifications: string[] = [];
@@ -182,18 +182,18 @@ describe('lazy scenario runtime', () => {
} }
if (path === '/sdk/v1/scenarios/trigger') { if (path === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({ return Promise.resolve(new Response(JSON.stringify({
plans: [{ effects: [{
planId: 'plan-1',
scenarioId: 'scenario-1',
userId: 'user-1',
startNodeId: 'start',
runId: 'run-1', runId: 'run-1',
nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }], scenarioId: 'scenario-1',
edges: [], nodeId: 'start',
boundaryNodes: [], type: 'notification',
data: { message: 'Welcome!' },
}], }],
}), { status: 200 })); }), { status: 200 }));
} }
if (path === '/sdk/v1/scenarios/pending') {
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 }));
}
if (path === '/sdk/v1/remote-configs') { if (path === '/sdk/v1/remote-configs') {
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 })); return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
} }
+255 -367
View File
@@ -2,43 +2,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ScenarioService } from '../src/scenario/ScenarioService.js'; import { ScenarioService } from '../src/scenario/ScenarioService.js';
import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js'; import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js';
import { createFakeTokenStore } from './helpers/FakeTokenStore.js'; import { createFakeTokenStore } from './helpers/FakeTokenStore.js';
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge } from '../src/generated/common.js'; import { effect } from './helpers/plan.js';
import { NotificationSession, StoreSession, WaitSession, LeaderboardSession } from '../src/scenario/engine/sessions.js'; import { RudderHttpError } from '../src/client/RudderError.js';
import type { NotificationEffect, StoreOfferEffect, WaitEffect } from '../src/index.js';
/** Builds a simple execution plan with the given nodes and edges. */ import type { PendingEffect } from '../src/generated/scenarios.js';
function makePlan(overrides?: Partial<ExecutionPlan>): ExecutionPlan {
return {
planId: 'plan-1',
scenarioId: 'scenario-1',
userId: 'user-1',
startNodeId: 'start',
runId: 'run-1',
nodes: [],
edges: [],
boundaryNodes: [],
context: undefined,
...overrides,
};
}
function makeNode(id: string, type: string, data?: Record<string, unknown>): ExecutionPlanNode {
return { id, type, data: data as ExecutionPlanNode['data'] };
}
function makeEdge(source: string, sourceHandle: string, target: string): PlanEdge {
return { id: `edge-${source}-${target}`, source, sourceHandle, target };
}
async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> { async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> {
const client = new RudderClient({ const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-key', projectKey: 'test-key',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null }, // Disable IndexedDB for tests runtime: { loginEvent: null },
}); });
return { client, scenarios: await getScenarioRuntime(client) }; return { client, scenarios: await getScenarioRuntime(client) };
} }
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status });
}
function stubFetch(handler: (path: string, method: string, body: unknown) => Response | Promise<Response>): void {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const parsed = new URL(url);
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : undefined;
return Promise.resolve(handler(parsed.pathname, method, body));
}));
}
describe('ScenarioService', () => { describe('ScenarioService', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('crypto', { vi.stubGlobal('crypto', {
@@ -46,119 +37,100 @@ describe('ScenarioService', () => {
}); });
}); });
describe('send (trigger)', () => { afterEach(() => {
it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => { vi.useRealTimers();
const { client, scenarios } = await createClientWithScenario(); vi.unstubAllGlobals();
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue( describe('send (trigger)', () => {
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => {
)); const { client, scenarios } = await createClientWithScenario();
const onNotification = vi.fn(); const onNotification = vi.fn();
scenarios.onNotification = onNotification; 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'); const response = await scenarios.send('level_complete');
expect(response.plans).toHaveLength(1); expect(response.effects).toHaveLength(1);
expect(scenarios.isRunning).toBe(true); expect(scenarios.isRunning).toBe(true);
expect(onNotification).toHaveBeenCalledOnce(); expect(onNotification).toHaveBeenCalledOnce();
expect(onNotification.mock.calls[0][0]).toBeInstanceOf(NotificationSession); expect(onNotification.mock.calls[0][0].title).toBe('Hi');
expect(onNotification.mock.calls[0][0].message).toBe('Welcome');
}); });
}); });
describe('node dispatch', () => { describe('effect emission', () => {
it('notification node fires onNotification', async () => { it('notification node fires onNotification', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn(); const onNotif = vi.fn();
scenarios.onNotification = onNotif; client.effects.onNotification(onNotif);
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce(); expect(onNotif).toHaveBeenCalledOnce();
}); });
it('store node fires onStore', async () => { it('store node fires onStoreOffer', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn(); const onStore = vi.fn();
scenarios.onStore = onStore; client.effects.onStoreOffer(onStore);
stubFetch((path) => {
const plan = makePlan({ if (path === '/sdk/v1/scenarios/trigger') {
nodes: [makeNode('start', 'store')], 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', name: 'Pack' }] });
}
return jsonResponse({});
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onStore).toHaveBeenCalledOnce(); await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
expect(onStore.mock.calls[0][0]).toBeInstanceOf(StoreSession); 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 () => { it('wait node fires onWait', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn(); const onWait = vi.fn();
scenarios.onWait = onWait; client.effects.onWait(onWait);
const deadline = new Date(Date.now() + 60_000).toISOString();
const plan = makePlan({ stubFetch(() => jsonResponse({
nodes: [makeNode('start', 'wait', { duration: 5, unit: 'minutes' })], effects: [effect('wait', {}, { waitDeadline: deadline })],
}); }));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce(); expect(onWait).toHaveBeenCalledOnce();
expect(onWait.mock.calls[0][0]).toBeInstanceOf(WaitSession); expect(onWait.mock.calls[0][0].deadlineUtc).toBeInstanceOf(Date);
}); });
it('leaderboard node fires onLeaderboard', async () => { it('leaderboard node fires onLeaderboard', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onLb = vi.fn(); const onLb = vi.fn();
scenarios.onLeaderboard = onLb; client.effects.onLeaderboard(onLb);
stubFetch(() => jsonResponse({ effects: [effect('leaderboard')] }));
const plan = makePlan({
nodes: [makeNode('start', 'leaderboard')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onLb).toHaveBeenCalledOnce(); expect(onLb).toHaveBeenCalledOnce();
expect(onLb.mock.calls[0][0]).toBeInstanceOf(LeaderboardSession);
}); });
it('quest node dispatches onQuest instead of stalling', async () => { it('quest node dispatches onQuest', async () => {
const { client, scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onQuest = vi.fn(); const onQuest = vi.fn();
client.effects.onQuest(onQuest); client.effects.onQuest(onQuest);
stubFetch(() => jsonResponse({
const plan = makePlan({ effects: [effect('quest', { name: 'Daily', objectives: [{ objectiveId: 'kills', target: 10 }] })],
nodes: [ }));
makeNode('start', 'quest', {
name: 'Daily',
objectives: [{ objectiveId: 'kills', target: 10 }],
}),
],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onQuest).toHaveBeenCalledOnce(); expect(onQuest).toHaveBeenCalledOnce();
expect(onQuest.mock.calls[0][0].name).toBe('Daily'); expect(onQuest.mock.calls[0][0].name).toBe('Daily');
// Active and waiting for progress — not stalled, not failed.
expect(scenarios.isRunning).toBe(true); expect(scenarios.isRunning).toBe(true);
}); });
@@ -166,14 +138,7 @@ describe('ScenarioService', () => {
const { client, scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onBp = vi.fn(); const onBp = vi.fn();
client.effects.onBattlePass(onBp); client.effects.onBattlePass(onBp);
stubFetch(() => jsonResponse({ effects: [effect('battlepass', { premiumPrice: 100 })] }));
const plan = makePlan({
nodes: [makeNode('start', 'battlepass', { premiumPrice: 100 })],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onBp).toHaveBeenCalledOnce(); expect(onBp).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(true); expect(scenarios.isRunning).toBe(true);
@@ -183,346 +148,269 @@ describe('ScenarioService', () => {
const { client, scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onLevel = vi.fn(); const onLevel = vi.fn();
client.effects.onBattlePassLevel(onLevel); client.effects.onBattlePassLevel(onLevel);
stubFetch(() => jsonResponse({
const plan = makePlan({ effects: [effect('battlepass_level', { levelNumber: 3 })],
nodes: [makeNode('start', 'battlepass_level', { levelNumber: 3 })], }));
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onLevel).toHaveBeenCalledOnce(); expect(onLevel).toHaveBeenCalledOnce();
expect(onLevel.mock.calls[0][0].level).toBe(3); expect(onLevel.mock.calls[0][0].level).toBe(3);
}); });
it('remote_config_override node applies patches internally and auto-completes', async () => {
const { client, scenarios } = await createClientWithScenario();
const plan = makePlan({
nodes: [
makeNode('start', 'remote_config_override', {
patches: [
{ path: 'difficulty', valueType: 'string', value: 'hard' },
],
}),
],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
expect(client.remoteConfig.get('difficulty', '')).toBe('hard');
// RemoteConfigOverride auto-completes asynchronously — wait for the promise.
await new Promise((r) => setTimeout(r, 50));
expect(scenarios.isRunning).toBe(false);
});
it('unsupported node type fails the run and surfaces onScenarioFailed', async () => { it('unsupported node type fails the run and surfaces onScenarioFailed', async () => {
const { client, scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const onFailed = vi.fn(); const onFailed = vi.fn();
client.effects.onScenarioFailed(onFailed); client.effects.onScenarioFailed(onFailed);
stubFetch(() => jsonResponse({ effects: [effect('unknown_type')] }));
const plan = makePlan({
nodes: [makeNode('start', 'unknown_type')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(warn).toHaveBeenCalledWith( expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Unsupported scenario node type'), expect.stringContaining('Unsupported scenario node type'),
); );
// The run must NOT stall: it fails and the game is notified.
expect(onFailed).toHaveBeenCalledOnce(); expect(onFailed).toHaveBeenCalledOnce();
expect(scenarios.isRunning).toBe(false); expect(scenarios.isRunning).toBe(false);
warn.mockRestore(); warn.mockRestore();
}); });
}); });
describe('DAG traversal', () => { describe('callback continuation', () => {
it('completing a node follows matching edges', async () => { it('completing an effect posts callback and emits the next effect', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onNotif = vi.fn(); const onNotif = vi.fn();
scenarios.onNotification = onNotif; client.effects.onNotification(onNotif);
const first = effect('notification', { message: 'one' }, { nodeId: 'n1' });
const second = effect('notification', { message: 'two' }, { nodeId: 'n2' });
// start(notif) → complete("output") → node2(notif) stubFetch((path, method) => {
const plan = makePlan({ if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
nodes: [ return jsonResponse({ effects: [first] });
makeNode('start', 'notification'),
makeNode('node2', 'notification'),
],
edges: [makeEdge('start', 'output', 'node2')],
});
let callCount = 0;
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
} }
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
})); return jsonResponse({ effect: second });
}
return jsonResponse({});
});
await scenarios.send('test'); await scenarios.send('test');
expect(onNotif).toHaveBeenCalledOnce(); // start node dispatched expect(onNotif).toHaveBeenCalledOnce();
const session = onNotif.mock.calls[0][0] as NotificationEffect;
// Complete the notification node with handle "output" await session.done();
const session = onNotif.mock.calls[0][0] as NotificationSession;
await session.complete();
expect(onNotif).toHaveBeenCalledTimes(2); 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({
scenarioId: 'scenario-1',
nodeId: 'n1',
handle: 'output',
runId: 'run-1',
});
}); });
it('completing a node with already-completed handle is idempotent', async () => { it('completing the last effect emits onScenarioCompleted', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onNotif = vi.fn(); const onNotif = vi.fn();
scenarios.onNotification = onNotif; client.effects.onScenarioCompleted(onCompleted);
client.effects.onNotification(onNotif);
const plan = makePlan({ stubFetch((path) => {
nodes: [ if (path === '/sdk/v1/scenarios/trigger') {
makeNode('start', 'notification'), return jsonResponse({ effects: [effect('notification')] });
makeNode('node2', 'notification'), }
], return jsonResponse({});
edges: [makeEdge('start', 'output', 'node2')],
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(scenarios.activeRuns).toHaveLength(1); await (onNotif.mock.calls[0][0] as NotificationEffect).done();
expect(onCompleted).toHaveBeenCalledOnce();
const session = onNotif.mock.calls[0][0] as NotificationSession; expect(onCompleted.mock.calls[0][0]).toEqual({
await session.complete(); runId: 'run-1',
scenarioId: 'scenario-1',
// node2 should now be active });
expect(scenarios.activeRuns).toHaveLength(1);
// Complete node2
const session2 = onNotif.mock.calls[1][0] as NotificationSession;
await session2.complete();
// Run should be completed
expect(scenarios.isRunning).toBe(false); expect(scenarios.isRunning).toBe(false);
}); });
it('run completion fires onRunCompleted and onCompleted', async () => {
const { scenarios } = await createClientWithScenario();
const onCompleted = vi.fn();
const onRunCompleted = vi.fn();
scenarios.onCompleted = onCompleted;
scenarios.onRunCompleted = onRunCompleted;
const plan = makePlan({
nodes: [makeNode('start', 'notification')],
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); describe('pending dedup', () => {
const session = (scenarios as unknown as { onNotification?: (s: NotificationSession) => void }).onNotification?.( it('does not re-emit an effect with the same runId and nodeId', async () => {
// Use the mock calls to get the session const { client, scenarios } = await createClientWithScenario();
vi.mocked(vi.fn()).mock.calls[0]?.[0] as NotificationSession, const onNotification = vi.fn();
); client.effects.onNotification(onNotification);
// We need to get the session from the mock spy const pending = effect('notification', { message: 'once' });
// Actually let's trigger via respond() stubFetch(() => jsonResponse({ effects: [pending] }));
scenarios.respond('output'); await scenarios.send('first-event');
// Fire-and-forget — wait a tick await scenarios.send('second-event');
await new Promise((r) => setTimeout(r, 10)); expect(onNotification).toHaveBeenCalledTimes(1);
expect(scenarios.isRunning).toBe(true);
expect(onRunCompleted).toHaveBeenCalled();
expect(onCompleted).toHaveBeenCalled();
}); });
}); });
describe('wait nodes', () => { describe('waitDeadline timer', () => {
it('sets a deadline and fires onWait', async () => { it('polls pending at waitDeadline and emits the next effect', async () => {
const { scenarios } = await createClientWithScenario(); vi.useFakeTimers();
const { client, scenarios } = await createClientWithScenario();
const onWait = vi.fn(); const onWait = vi.fn();
scenarios.onWait = onWait; const onNotif = vi.fn();
client.effects.onWait(onWait);
client.effects.onNotification(onNotif);
const plan = makePlan({ const deadline = new Date(Date.now() + 5_000).toISOString();
nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })], 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({});
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce(); expect(onWait).toHaveBeenCalledOnce();
const session = onWait.mock.calls[0][0] as WaitSession; expect(onNotif).not.toHaveBeenCalled();
expect(session.deadlineUtc).toBeInstanceOf(Date);
// Deadline should be ~10 minutes from now. pending = [next];
const diff = session.deadlineUtc.getTime() - Date.now(); await vi.advanceTimersByTimeAsync(5_000);
expect(diff).toBeGreaterThan(9 * 60 * 1000); expect(onNotif).toHaveBeenCalledOnce();
expect(diff).toBeLessThan(11 * 60 * 1000); 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);
});
}); });
it('completes immediately if deadline has already passed', async () => { describe('expired-run drop', () => {
const { scenarios } = await createClientWithScenario(); it('drops the run on unknown_run and does not retry it', async () => {
const onWait = vi.fn(); const { client, scenarios } = await createClientWithScenario();
const onCompleted = vi.fn(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
scenarios.onWait = onWait; const onFailed = vi.fn();
scenarios.onCompleted = onCompleted; const onNotif = vi.fn();
client.effects.onScenarioFailed(onFailed);
client.effects.onNotification(onNotif);
const pending = effect('notification', { message: 'hello' });
// Duration of 0 should result in an immediate completion. stubFetch((path) => {
const plan = makePlan({ if (path === '/sdk/v1/scenarios/trigger') {
nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })], 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({});
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(onWait).toHaveBeenCalledOnce(); expect(onNotif).toHaveBeenCalledOnce();
// Wait for the setTimeout(0) to fire. await expect((onNotif.mock.calls[0][0] as NotificationEffect).done())
await new Promise((r) => setTimeout(r, 50)); .rejects.toBeInstanceOf(RudderHttpError);
expect(onCompleted).toHaveBeenCalled(); 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', () => { describe('store session', () => {
it('buy() purchases the selected offer and completes with onPurchase', async () => { it('buy() purchases the selected offer and completes with onPurchase', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn(); const onStore = vi.fn();
scenarios.onStore = onStore; client.effects.onStoreOffer(onStore);
const plan = makePlan({ stubFetch((path, method) => {
nodes: [makeNode('start', 'store', { storeSlug: 'starter' })], if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
}); return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const parsed = new URL(url);
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 }));
} }
if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') { if (method === 'GET' && path === '/sdk/v1/stores/starter') {
return Promise.resolve(new Response(JSON.stringify({ return jsonResponse({
name: 'Starter', name: 'Starter',
slug: 'starter', slug: 'starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }], offers: [{ id: 'pack_1', name: 'Starter Pack' }],
}), { status: 200 })); });
} }
if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/stores/starter/offers/pack_1/purchase') { if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack_1/purchase') {
return Promise.resolve(new Response(JSON.stringify({ success: true, purchaseId: 'purchase-1' }), { status: 200 })); return jsonResponse({ success: true, purchaseId: 'purchase-1' });
} }
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); return jsonResponse({});
})); });
await scenarios.send('test'); await scenarios.send('test');
const session = onStore.mock.calls[0][0] as StoreSession; await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
const session = onStore.mock.calls[0][0] as StoreOfferEffect;
const onCompleted = vi.fn(); const purchase = await session.buy(session.offers[0]);
scenarios.onCompleted = onCompleted;
const store = await session.getStore();
const purchase = await session.buy(store.offers[0]);
expect(purchase.success).toBe(true); expect(purchase.success).toBe(true);
expect(session.isResolved).toBe(true);
// Second call should be a no-op. const duplicate = await session.buy(session.offers[0]);
const duplicate = await session.buy(store.offers[0]);
expect(duplicate.success).toBe(false); expect(duplicate.success).toBe(false);
}); });
it('decline() completes with onDecline', async () => { it('dismiss() completes with onDecline', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
const onStore = vi.fn(); const onStore = vi.fn();
scenarios.onStore = onStore; client.effects.onStoreOffer(onStore);
stubFetch((path) => {
const plan = makePlan({ if (path === '/sdk/v1/scenarios/trigger') {
nodes: [makeNode('start', 'store')], return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
}
if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1' }] });
}
return jsonResponse({});
}); });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
const session = onStore.mock.calls[0][0] as StoreSession; await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
await session.decline(); await (onStore.mock.calls[0][0] as StoreOfferEffect).dismiss();
expect(session.isResolved).toBe(true); 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('respond / clear', () => { describe('clear', () => {
it('respond completes the first active node', async () => {
const { scenarios } = await createClientWithScenario();
const onNotif = vi.fn();
scenarios.onNotification = onNotif;
const plan = makePlan({
nodes: [
makeNode('start', 'notification'),
makeNode('node2', 'notification'),
],
edges: [makeEdge('start', 'output', 'node2')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test');
// respond() completes the first active node with the given handle.
scenarios.respond('output');
// Wait for async completion.
await new Promise((r) => setTimeout(r, 50));
// node2 should now be active (second notification dispatched).
expect(scenarios.isRunning).toBe(true);
});
it('clear removes all runs', async () => { it('clear removes all runs', async () => {
const { scenarios } = await createClientWithScenario(); const { client, scenarios } = await createClientWithScenario();
client.effects.onNotification(vi.fn());
const plan = makePlan({ stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plans: [plan] }), { status: 200 }),
));
await scenarios.send('test'); await scenarios.send('test');
expect(scenarios.isRunning).toBe(true); expect(scenarios.isRunning).toBe(true);
scenarios.clear(); scenarios.clear();
expect(scenarios.isRunning).toBe(false); expect(scenarios.isRunning).toBe(false);
}); });
}); });
describe('runId filtering', () => {
it('rejects plans with duplicate runId', async () => {
const { scenarios } = await createClientWithScenario();
const onNotification = vi.fn();
scenarios.onNotification = onNotification;
const plan = makePlan({
runId: 'run-1',
nodes: [makeNode('start', 'notification')],
});
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })),
));
// First send creates a run
await scenarios.send('first-event');
expect(scenarios.activeRuns).toHaveLength(1);
expect(onNotification).toHaveBeenCalledTimes(1);
// Second send with same runId is rejected by startPlan dedup
await scenarios.send('second-event');
expect(scenarios.activeRuns).toHaveLength(1);
expect(onNotification).toHaveBeenCalledTimes(1);
});
});
}); });
+1 -6
View File
@@ -9,11 +9,6 @@ import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js'; import type { SeedArtifact } from './types.js';
type RuntimeOptions = { type RuntimeOptions = {
planStateStore?: {
state: string | null;
load(): Promise<void>;
save(state: string | null): Promise<void>;
} | null;
loginEvent?: string | null; loginEvent?: string | null;
}; };
@@ -52,7 +47,7 @@ export function createInMemoryTokenStore(): TokenStore {
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */ /** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>( export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
artifact: SeedArtifact = loadArtifact(), artifact: SeedArtifact = loadArtifact(),
runtime: RuntimeOptions = { planStateStore: null, loginEvent: null }, runtime: RuntimeOptions = { loginEvent: null },
): RudderClient<TConfig> { ): RudderClient<TConfig> {
return new RudderClient<TConfig>({ return new RudderClient<TConfig>({
baseUrl: artifact.baseUrl, baseUrl: artifact.baseUrl,
+3 -4
View File
@@ -16,7 +16,6 @@ describe('e2e-prod: scenarios', () => {
let runCompleted = false; let runCompleted = false;
const client = makeProdClient(artifact, { const client = makeProdClient(artifact, {
planStateStore: null,
loginEvent: artifact.scenario.event, loginEvent: artifact.scenario.event,
}); });
@@ -25,9 +24,9 @@ describe('e2e-prod: scenarios', () => {
storeOffer = effect; storeOffer = effect;
}); });
const runtime = await getScenarioRuntime(client); const runtime = await getScenarioRuntime(client);
runtime.onRunCompleted = () => { client.effects.onScenarioCompleted(() => {
runCompleted = true; runCompleted = true;
}; });
await client.auth.loginWithDevice({ region: 'en', language: 'en' }); await client.auth.loginWithDevice({ region: 'en', language: 'en' });
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds. // The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
@@ -45,8 +44,8 @@ describe('e2e-prod: scenarios', () => {
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 }); await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done(); await notifications[0].done();
// remote_config_override applies, then the store offer surfaces.
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 }); await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
await client.remoteConfig.reload();
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo( expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
artifact.remoteConfigs.spawnRateOverride, artifact.remoteConfigs.spawnRateOverride,
); );
+50 -83
View File
@@ -3,44 +3,17 @@ import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js'; import { effect } from '../helpers/plan.js';
import type { StoreOfferEffect } from '../../src/index.js'; import type { StoreOfferEffect } from '../../src/index.js';
import type { PlanRun, ScenarioRunFailedEvent } from '../../src/scenario/engine/types.js';
function makeClient(): RudderClient { function makeClient(): RudderClient {
return new RudderClient({ return new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null },
}); });
} }
/**
* An offer whose `onPurchase` continuation lives server-side (boundary node):
* the client doesn't hold the post-purchase DAG — it must call back so the
* server can validate the transaction and decide what happens next.
*
* `onDecline`, by contrast, is a plain local edge handled entirely on-device.
*/
function offerWithBoundary() {
return plan('offer_flow')
.runId('offer_flow-run')
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy the starter pack!' })
.node('consolation', 'notification', { message: 'Maybe next time!' })
.edge('offer', 'onDecline', 'consolation')
.boundary('offer', 'onPurchase') // server owns what comes after a purchase
.build();
}
/** What the server returns from the callback — a fresh plan = a new run. */
function rewardContinuation() {
return plan('reward_flow')
.runId('reward_flow-run')
.node('reward', 'notification', { message: 'Reward granted: 500 gems!' })
.build();
}
describe('E2E: boundary nodes (server-side continuation)', () => { describe('E2E: boundary nodes (server-side continuation)', () => {
let gateway: FakeGateway; let gateway: FakeGateway;
let client: RudderClient; let client: RudderClient;
@@ -61,99 +34,93 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
it('purchase crosses the boundary → callback fires and continues the scenario', async () => { it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
gateway.onEvent('player_login', offerWithBoundary()); const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
gateway.onCallback('offer', 'onPurchase', rewardContinuation()); scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reward',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onPurchase', reward);
const messages: string[] = []; const messages: string[] = [];
const completed: PlanRun[] = []; const completed: string[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onNotification((e) => { messages.push(e.message); });
client.effects.onNotification((effect) => { messages.push(effect.message); }); client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioId); });
runtime.onRunCompleted = (r) => completed.push(r);
const token = (await client.auth.loginWithDevice()).accessToken; const token = (await client.auth.loginWithDevice()).accessToken;
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]); await offer!.buy(offer!.offers[0]);
// The callback hit the gateway with the boundary's source node/handle + token.
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback'); const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run' }); expect(callback?.body).toEqual({
scenarioId: 'offer_flow',
nodeId: 'offer',
handle: 'onPurchase',
runId: 'offer_flow-run',
});
expect(callback?.authToken).toBe(token); expect(callback?.authToken).toBe(token);
// The server-supplied continuation ran (as a new run), not the local edge.
expect(messages).toEqual(['Reward granted: 500 gems!']); expect(messages).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('Maybe next time!'); expect(messages).not.toContain('Maybe next time!');
expect(completed).toEqual([]);
// The original run finished; the continuation ('reward_flow') is now active.
expect(completed.map((r) => r.scenarioId)).toContain('offer_flow');
expect(runtime.activeRuns.map((r) => r.scenarioId)).toEqual(['reward_flow']);
}); });
it('decline stays local → no callback, local edge is followed', async () => { it('decline posts callback and continues with the server-supplied next effect', async () => {
gateway.onEvent('player_login', offerWithBoundary()); const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'consolation',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallback('offer', 'onDecline', consolation);
const messages: string[] = []; const messages: string[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((effect) => { messages.push(effect.message); }); client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss(); await offer!.dismiss();
expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(false); expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(true);
expect(messages).toEqual(['Maybe next time!']); expect(messages).toEqual(['Maybe next time!']);
}); });
it('a boundary takes precedence over a local edge on the SAME handle', async () => {
// `offer` has BOTH a boundary AND a local edge on onPurchase.
const conflicting = plan('offer_flow')
.runId('offer_flow-conflict')
.node('offer', 'store', { storeSlug: 'starter', message: 'Buy!' })
.node('local_next', 'notification', { message: 'LOCAL branch' })
.edge('offer', 'onPurchase', 'local_next')
.boundary('offer', 'onPurchase')
.build();
gateway.onEvent('player_login', conflicting);
gateway.onCallback('offer', 'onPurchase', rewardContinuation());
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.buy(offer!.offers[0]);
// Only the server continuation ran; the local edge was suppressed.
expect(messages).toEqual(['Reward granted: 500 gems!']);
expect(messages).not.toContain('LOCAL branch');
});
it('a failing callback does not fail or crash the run', async () => { it('a failing callback does not fail or crash the run', async () => {
gateway.onEvent('player_login', offerWithBoundary()); const offerEffect = effect('store', { storeSlug: 'starter' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('player_login', offerEffect);
gateway.onCallbackError('offer', 'onPurchase', 500); gateway.onCallbackError('offer', 'onPurchase', 500);
const failures: ScenarioRunFailedEvent[] = []; const failures: unknown[] = [];
const completed: PlanRun[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
const runtime = await getScenarioRuntime(client); const runtime = await getScenarioRuntime(client);
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
runtime.onRunFailed = (e) => failures.push(e); client.effects.onScenarioFailed((e) => { failures.push(e); });
runtime.onRunCompleted = (r) => completed.push(r);
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
// Must not throw despite the 500 from the callback endpoint.
await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true }); await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true });
expect(failures).toHaveLength(0); // 500 is transient — no terminal failure expect(failures).toHaveLength(0);
// The run stays pending because the boundary call failed transiently.
// On reconnection the consumer can retry the purchase completion.
expect(runtime.isRunning).toBe(true); expect(runtime.isRunning).toBe(true);
}); });
}); });
+55 -39
View File
@@ -3,7 +3,7 @@ import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js'; import { effect } from '../helpers/plan.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js'; import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
const MINUTE = 60_000; const MINUTE = 60_000;
@@ -13,28 +13,38 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null }, // exercised separately in the persistence test
}); });
} }
/** function offerScenario(now: number) {
* The offer scenario both branches share: const waitIntro = effect('wait', {}, {
* scenarioId: 'offer_flow',
* login → wait 1m → offer ──onDecline──→ wait 1m → "still available" runId: 'offer_flow-run',
* └─onPurchase─→ "thanks for your purchase" nodeId: 'wait_intro',
*/ waitDeadline: new Date(now + MINUTE).toISOString(),
function offerScenario() { });
return plan('offer_flow') const offer = effect('store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }, {
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' }) scenarioId: 'offer_flow',
.node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }) runId: 'offer_flow-run',
.node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' }) nodeId: 'offer',
.node('reminder', 'notification', { message: 'Your offer is still available!' }) });
.node('thanks', 'notification', { message: 'Thanks for your purchase!' }) const waitReminder = effect('wait', {}, {
.edge('wait_intro', 'onComplete', 'offer') scenarioId: 'offer_flow',
.edge('offer', 'onDecline', 'wait_reminder') runId: 'offer_flow-run',
.edge('wait_reminder', 'onComplete', 'reminder') nodeId: 'wait_reminder',
.edge('offer', 'onPurchase', 'thanks') waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
.build(); });
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
return { waitIntro, offer, waitReminder, reminder, thanks };
} }
describe('E2E: player offer journey', () => { describe('E2E: player offer journey', () => {
@@ -66,43 +76,41 @@ describe('E2E: player offer journey', () => {
expect(res.accessToken).toBeTruthy(); expect(res.accessToken).toBeTruthy();
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken); expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
// The login request carried the project key + a device id.
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device'); 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).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy(); expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
// The post-login runtime trigger carries the bearer token.
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger'); const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
expect(trigger?.authToken).toBe(res.accessToken); expect(trigger?.authToken).toBe(res.accessToken);
}); });
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => { it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
gateway.onEvent('player_login', offerScenario()); 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[] = []; const notifications: NotificationEffect[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((effect) => { notifications.push(effect); }); client.effects.onNotification((e) => { notifications.push(e); });
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
// Immediately after login the player is just waiting — no offer yet.
expect(offer).toBeUndefined(); expect(offer).toBeUndefined();
// The offer must NOT appear before the full minute has elapsed...
await vi.advanceTimersByTimeAsync(MINUTE - 1_000); await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(offer).toBeUndefined(); expect(offer).toBeUndefined();
// ...and surfaces exactly when the minute is up.
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!'); expect(offer!.message).toBe('Limited starter pack!');
// Player declines → the DAG moves to the reminder wait, no notification yet.
await offer!.dismiss(); await offer!.dismiss();
expect(notifications).toHaveLength(0); expect(notifications).toHaveLength(0);
// The reminder also honours the full minute, not a moment sooner.
await vi.advanceTimersByTimeAsync(MINUTE - 1_000); await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(notifications).toHaveLength(0); expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
@@ -111,38 +119,46 @@ describe('E2E: player offer journey', () => {
}); });
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => { it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
gateway.onEvent('player_login', offerScenario()); 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; let offer: StoreOfferEffect | undefined;
const notifications: NotificationEffect[] = []; const notifications: NotificationEffect[] = [];
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((effect) => { notifications.push(effect); }); client.effects.onNotification((e) => { notifications.push(e); });
const accessToken = (await client.auth.loginWithDevice()).accessToken; const accessToken = (await client.auth.loginWithDevice()).accessToken;
await vi.advanceTimersByTimeAsync(MINUTE); await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
// The game buys a selected offer; the session advances only after success.
const selectedOffer = offer!.offers[0]; const selectedOffer = offer!.offers[0];
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' }); const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
expect(purchase.success).toBe(true); expect(purchase.success).toBe(true);
// The purchase hit the gateway with idempotency key + bearer token.
expect(gateway.purchases).toEqual([ expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken }, { storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]); ]);
// The scenario took the onPurchase branch → "thanks", and the run finished.
expect(notifications).toHaveLength(1); expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Thanks for your purchase!'); expect(notifications[0].message).toBe('Thanks for your purchase!');
}); });
it('declining does NOT take the purchase branch (handles are isolated)', async () => { it('declining does NOT take the purchase branch (handles are isolated)', async () => {
gateway.onEvent('player_login', offerScenario()); 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[] = []; const messages: string[] = [];
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((effect) => { messages.push(effect.message); }); client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice(); await client.auth.loginWithDevice();
await vi.advanceTimersByTimeAsync(MINUTE); await vi.advanceTimersByTimeAsync(MINUTE);
+32 -34
View File
@@ -2,39 +2,44 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js'; import { getScenarioRuntime, RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { createMemoryPlanStore, stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js'; import { effect } from '../helpers/plan.js';
import type { StoreSession, WaitSession } from '../../src/scenario/engine/sessions.js'; import type { StoreOfferEffect, WaitEffect } from '../../src/index.js';
const MINUTE = 60_000; const MINUTE = 60_000;
describe('E2E: scenario state survives a reload', () => { describe('E2E: pending effects resume after a new login', () => {
let gateway: FakeGateway; let gateway: FakeGateway;
// Shared backing store mimics IndexedDB persisting across page loads.
const backing = { value: null as string | null };
function clientWithSharedStore(): RudderClient { function makeClient(): RudderClient {
return new RudderClient({ return new RudderClient({
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: createMemoryPlanStore(backing), loginEvent: null }, runtime: { loginEvent: null },
}); });
} }
beforeEach(() => { beforeEach(() => {
stubDeterministicUuid(); stubDeterministicUuid();
vi.useFakeTimers(); vi.useFakeTimers();
backing.value = null; gateway = createFakeGateway({
gateway = createFakeGateway(); stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] }],
gateway.onEvent( });
'session_start', const now = Date.now();
plan('offer_flow') const wait = effect('wait', {}, {
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' }) scenarioId: 'offer_flow',
.node('offer', 'store', { message: 'Limited starter pack!' }) runId: 'offer_flow-run',
.edge('wait_intro', 'onComplete', 'offer') nodeId: 'wait_intro',
.build(), waitDeadline: new Date(now + MINUTE).toISOString(),
); });
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('session_start', wait);
gateway.onCallback('wait_intro', 'onComplete', offer);
gateway.install(); gateway.install();
}); });
@@ -43,33 +48,26 @@ describe('E2E: scenario state survives a reload', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('a wait started before reload resumes and fires the offer after reload', async () => { it('a wait started before reload resumes from GET pending after login', async () => {
// --- Session 1: trigger the scenario, then "close the tab" mid-wait --- const client1 = makeClient();
const client1 = clientWithSharedStore();
await client1.auth.loginWithDevice(); await client1.auth.loginWithDevice();
const runtime1 = await getScenarioRuntime(client1); const runtime1 = await getScenarioRuntime(client1);
await runtime1.send('session_start'); await runtime1.send('session_start');
expect(runtime1.isRunning).toBe(true); expect(runtime1.isRunning).toBe(true);
expect(backing.value).toBeTruthy(); // run was persisted
// --- Session 2: fresh client, same persisted state (page reload) --- const client2 = makeClient();
const client2 = clientWithSharedStore(); const resumedWaits: WaitEffect[] = [];
const runtime2 = await getScenarioRuntime(client2); let offer: StoreOfferEffect | undefined;
const resumedWaits: WaitSession[] = []; client2.effects.onWait((s) => { resumedWaits.push(s); });
let offer: StoreSession | undefined; client2.effects.onStoreOffer((s) => { offer = s; });
runtime2.onWait = (s) => resumedWaits.push(s);
runtime2.onStore = (s) => { offer = s; };
await client2.auth.loginWithDevice(); await client2.auth.loginWithDevice();
// The wait node was rehydrated and re-dispatched.
expect(runtime2.isRunning).toBe(true);
expect(resumedWaits).toHaveLength(1); expect(resumedWaits).toHaveLength(1);
expect(offer).toBeUndefined(); expect(offer).toBeUndefined();
// The remaining wait time still elapses → the offer surfaces on the new client.
await vi.advanceTimersByTimeAsync(MINUTE); await vi.advanceTimersByTimeAsync(MINUTE);
expect(offer).toBeDefined(); await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.get('message', '')).toBe('Limited starter pack!'); expect(offer!.message).toBe('Limited starter pack!');
}); });
}); });
+1 -24
View File
@@ -3,7 +3,6 @@ import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js'; import type { RemoteConfig } from '../../src/generated/remote-config.js';
interface GameRemoteConfig extends Record<string, unknown> { interface GameRemoteConfig extends Record<string, unknown> {
@@ -25,7 +24,7 @@ function makeClient(): RudderClient<GameRemoteConfig> {
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
} }
@@ -76,26 +75,4 @@ describe('E2E: remote config', () => {
expect(client.remoteConfig.status).toBe('idle'); expect(client.remoteConfig.status).toBe('idle');
expect(client.remoteConfig.get('max_energy', 99)).toBe(99); expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
}); });
it('a remote_config_override scenario node patches the live cache', async () => {
client = new RudderClient<GameRemoteConfig>({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null },
});
gateway.onEvent(
'player_login',
plan('boost')
.node('override', 'remote_config_override', {
patches: [{ path: 'drop_rate', valueType: 'float', value: '0.9' }],
})
.build(),
);
await client.auth.loginWithDevice();
// The override is reflected immediately, without a reload.
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.9);
});
}); });
+1 -1
View File
@@ -8,7 +8,7 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build', baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project', projectKey: 'test-project',
tokenStore: createFakeTokenStore(), tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null, loginEvent: null }, runtime: { loginEvent: null },
}); });
} }
+76 -53
View File
@@ -1,5 +1,5 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { ExecutionPlan } from '../../src/generated/common.js'; import type { PendingEffect } from '../../src/generated/scenarios.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js'; import type { RemoteConfig } from '../../src/generated/remote-config.js';
import type { Store } from '../../src/generated/stores.js'; import type { Store } from '../../src/generated/stores.js';
import type { StorageItem } from '../../src/generated/storage.js'; import type { StorageItem } from '../../src/generated/storage.js';
@@ -8,18 +8,6 @@ import type {
ListQuestsResponse, ListQuestsResponse,
} from '../../src/generated/quests.js'; } from '../../src/generated/quests.js';
/**
* In-process fake of the LiveOps API gateway.
*
* Stubs the global `fetch` and routes requests the same way the real gateway
* does — so tests exercise the *real* SDK transport, auth-header injection,
* scenario engine and DAG traversal, just against deterministic in-memory state
* instead of a live backend.
*
* Seed state up front (scenarios per event, remote configs, stores), then drive
* the SDK through a journey and assert on `recorded` requests / server state.
*/
export interface RecordedRequest { export interface RecordedRequest {
method: string; method: string;
path: string; path: string;
@@ -29,36 +17,28 @@ export interface RecordedRequest {
} }
export interface FakeGatewayState { export interface FakeGatewayState {
/** Plans returned by POST /scenarios/trigger, keyed by event name. */ scenarios: Map<string, PendingEffect[]>;
scenarios: Map<string, ExecutionPlan[]>; callbacks: Map<string, PendingEffect | null>;
/** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */ callbackErrors: Map<string, { status: number; code?: string }>;
callbacks: Map<string, ExecutionPlan>; pendingEffects: PendingEffect[];
/** HTTP status to fail a callback with, keyed by `nodeId:handle`. */ counterCompleted: boolean;
callbackErrors: Map<string, number>; counterEffect?: PendingEffect;
/** Optional response for POST /scenarios/counter. */
counterPlan?: ExecutionPlan;
remoteConfigs: Record<string, RemoteConfig>; remoteConfigs: Record<string, RemoteConfig>;
stores: Store[]; stores: Store[];
quests: ListQuestsResponse; quests: ListQuestsResponse;
questClaims: Map<string, ClaimQuestResponse>; questClaims: Map<string, ClaimQuestResponse>;
/** Player KV storage, keyed by item id. */
storage: Map<string, StorageItem>; storage: Map<string, StorageItem>;
} }
export interface FakeGateway { export interface FakeGateway {
state: FakeGatewayState; state: FakeGatewayState;
recorded: RecordedRequest[]; recorded: RecordedRequest[];
/** Purchases received, in order. */
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>; purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>;
/** Quest claim requests received, in order. */
questClaims: Array<{ questId: string; authToken: string | null }>; questClaims: Array<{ questId: string; authToken: string | null }>;
install(): void; install(): void;
/** Convenience: register the plans returned for a trigger event. */ onEvent(event: string, ...effects: PendingEffect[]): void;
onEvent(event: string, ...plans: ExecutionPlan[]): void; onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
/** Convenience: register the plan returned when a boundary handle calls back. */ onCallbackError(nodeId: string, handle: string, status?: number, code?: string): void;
onCallback(nodeId: string, handle: string, plan: ExecutionPlan): void;
/** Convenience: make a boundary callback fail with an HTTP status (default 500). */
onCallbackError(nodeId: string, handle: string, status?: number): void;
} }
function json(body: unknown, status = 200): Response { function json(body: unknown, status = 200): Response {
@@ -70,12 +50,19 @@ function json(body: unknown, status = 200): Response {
const noContent = (): Response => new Response(null, { status: 204 }); const noContent = (): Response => new Response(null, { status: 204 });
function replaceRun(pending: PendingEffect[], next: PendingEffect[]): PendingEffect[] {
const runIds = new Set(next.map((effect) => effect.runId));
return [...pending.filter((effect) => !runIds.has(effect.runId)), ...next];
}
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway { export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = { const state: FakeGatewayState = {
scenarios: seed?.scenarios ?? new Map(), scenarios: seed?.scenarios ?? new Map(),
callbacks: seed?.callbacks ?? new Map(), callbacks: seed?.callbacks ?? new Map(),
callbackErrors: seed?.callbackErrors ?? new Map(), callbackErrors: seed?.callbackErrors ?? new Map(),
counterPlan: seed?.counterPlan, pendingEffects: seed?.pendingEffects ?? [],
counterCompleted: seed?.counterCompleted ?? false,
counterEffect: seed?.counterEffect,
remoteConfigs: seed?.remoteConfigs ?? {}, remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [], stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] }, quests: seed?.quests ?? { quests: [] },
@@ -87,10 +74,32 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
const purchases: FakeGateway['purchases'] = []; const purchases: FakeGateway['purchases'] = [];
const questClaims: FakeGateway['questClaims'] = []; const questClaims: FakeGateway['questClaims'] = [];
function advanceExpiredWaits(): PendingEffect[] {
const now = Date.now();
const next: PendingEffect[] = [];
for (const effect of state.pendingEffects) {
if (
effect.type === 'wait' &&
effect.waitDeadline &&
Date.parse(effect.waitDeadline) <= now
) {
const continued = state.callbacks.get(`${effect.nodeId}:onComplete`);
if (continued === undefined) {
next.push(effect);
} else if (continued !== null) {
next.push(continued);
}
} else {
next.push(effect);
}
}
state.pendingEffects = next;
return next;
}
async function handle(req: RecordedRequest): Promise<Response> { async function handle(req: RecordedRequest): Promise<Response> {
const { method, path, query, body } = req; const { method, path, query, body } = req;
// --- Auth ---
if (method === 'POST' && path === '/sdk/v1/authorization/device') { if (method === 'POST' && path === '/sdk/v1/authorization/device') {
const b = body as { deviceId?: string }; const b = body as { deviceId?: string };
return json({ return json({
@@ -99,39 +108,56 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
}); });
} }
// --- Player ---
if (method === 'GET' && path === '/sdk/v1/player/information') { if (method === 'GET' && path === '/sdk/v1/player/information') {
return json({ player: null, wallets: [] }); return json({ player: null, wallets: [] });
} }
// --- Sync (baseline revision poll after login) ---
if (method === 'GET' && path === '/sdk/v1/sync') { if (method === 'GET' && path === '/sdk/v1/sync') {
return json({}); return json({});
} }
// --- Catalog ---
if (method === 'GET' && path === '/sdk/v1/catalog') { if (method === 'GET' && path === '/sdk/v1/catalog') {
return json({ items: [] }); return json({ items: [] });
} }
// --- Scenarios ---
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') { if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
const event = (body as { event?: string }).event ?? ''; const event = (body as { event?: string }).event ?? '';
return json({ plans: state.scenarios.get(event) ?? [] }); const effects = state.scenarios.get(event) ?? [];
state.pendingEffects = replaceRun(state.pendingEffects, effects);
return json({ effects });
}
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
return json({ effects: advanceExpiredWaits() });
} }
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') { if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
const b = body as { nodeId?: string; handle?: string }; const b = body as { nodeId?: string; handle?: string; runId?: string };
const key = `${b.nodeId}:${b.handle}`; const key = `${b.nodeId}:${b.handle}`;
const errStatus = state.callbackErrors.get(key); const err = state.callbackErrors.get(key);
if (errStatus) return json({ error: 'callback failed' }, errStatus); if (err) {
const plan = state.callbacks.get(key); return json({ code: err.code ?? 'callback failed', error: 'callback failed' }, err.status);
return json(plan ? { plan } : {}); }
const next = state.callbacks.get(key);
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
if (next) {
state.pendingEffects = replaceRun(state.pendingEffects, [next]);
return json({ effect: next });
}
return json({});
} }
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') { if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false }); if (state.counterCompleted && state.counterEffect) {
state.pendingEffects = replaceRun(state.pendingEffects, [state.counterEffect]);
} else if (state.counterCompleted) {
const b = body as { nodeId?: string; runId?: string };
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
}
return json({ completed: state.counterCompleted, effect: state.counterEffect });
} }
// --- Remote config ---
if (method === 'GET' && path === '/sdk/v1/remote-configs') { if (method === 'GET' && path === '/sdk/v1/remote-configs') {
return json({ configs: state.remoteConfigs }); return json({ configs: state.remoteConfigs });
} }
@@ -141,7 +167,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return cfg ? json(cfg) : json({ error: 'not found' }, 404); return cfg ? json(cfg) : json({ error: 'not found' }, 404);
} }
// --- Stores ---
if (method === 'GET' && path === '/sdk/v1/stores') { if (method === 'GET' && path === '/sdk/v1/stores') {
return json({ stores: state.stores, total: state.stores.length }); return json({ stores: state.stores, total: state.stores.length });
} }
@@ -163,7 +188,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return store ? json(store) : json({ error: 'not found' }, 404); return store ? json(store) : json({ error: 'not found' }, 404);
} }
// --- Quests ---
if (method === 'POST' && path === '/sdk/v1/quests/list') { if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests); return json(state.quests);
} }
@@ -174,7 +198,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' }); return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
} }
// --- Storage ---
if (path === '/sdk/v1/storage') { if (path === '/sdk/v1/storage') {
if (method === 'GET') { if (method === 'GET') {
const typeFilter = query.get('types'); const typeFilter = query.get('types');
@@ -228,14 +251,14 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
purchases, purchases,
questClaims, questClaims,
install, install,
onEvent(event, ...plans) { onEvent(event, ...effects) {
state.scenarios.set(event, plans); state.scenarios.set(event, effects);
}, },
onCallback(nodeId, handle, plan) { onCallback(nodeId, handle, next) {
state.callbacks.set(`${nodeId}:${handle}`, plan); state.callbacks.set(`${nodeId}:${handle}`, next);
}, },
onCallbackError(nodeId, handle, status = 500) { onCallbackError(nodeId, handle, status = 500, code) {
state.callbackErrors.set(`${nodeId}:${handle}`, status); state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
}, },
}; };
} }
-26
View File
@@ -1,29 +1,3 @@
import type { PlanStateStore } from '../../src/scenario/engine/IndexedDbPlanStore.js';
/**
* In-memory PlanStateStore that survives across RudderClient instances — lets a
* test simulate a page reload: drive scenario A on one client, construct a fresh
* client sharing the same `backing`, call `scenarios.restore()`, and assert the
* run resumed mid-DAG.
*/
export function createMemoryPlanStore(backing: { value: string | null } = { value: null }): PlanStateStore {
return {
get state() {
return backing.value;
},
set state(v: string | null) {
backing.value = v;
},
async load() {
/* state already in `backing` */
},
async save(s: string | null) {
backing.value = s;
},
};
}
/** Deterministic, collision-free crypto.randomUUID() stub for scenario run IDs. */
export function stubDeterministicUuid(): void { export function stubDeterministicUuid(): void {
let n = 0; let n = 0;
const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto); const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto);
+12 -68
View File
@@ -1,72 +1,16 @@
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.js'; import type { PendingEffect } from '../../src/generated/scenarios.js';
/** export function effect(
* Small fluent builder for ExecutionPlans, so scenario journeys read like the type: string,
* DAGs they model rather than walls of object literals. data: Record<string, unknown> = {},
* overrides: Partial<PendingEffect> = {},
* plan('offer_flow') ): PendingEffect {
* .node('wait1', 'wait', { duration: 1, unit: 'minutes' })
* .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1' })
* .edge('wait1', 'onComplete', 'offer')
* .build();
*
* The first node added becomes the start node unless `.start(id)` is called.
*/
export class PlanBuilder {
private readonly nodes: ExecutionPlanNode[] = [];
private readonly edges: PlanEdge[] = [];
private readonly boundaryNodes: BoundaryNode[] = [];
private startNodeId?: string;
private runIdValue?: string;
constructor(
private readonly scenarioId: string,
private readonly opts: { planId?: string; userId?: string } = {},
) {}
/** Sets a specific run ID (default: auto-derived from scenarioId). */
runId(id: string): this {
this.runIdValue = id;
return this;
}
node(id: string, type: string, data?: Record<string, unknown>): this {
this.nodes.push({ id, type, data: data as ExecutionPlanNode['data'] });
if (!this.startNodeId) this.startNodeId = id;
return this;
}
edge(source: string, sourceHandle: string, target: string): this {
this.edges.push({ id: `e-${source}-${sourceHandle}-${target}`, source, sourceHandle, target });
return this;
}
/** Registers a server-side boundary node (handle that calls back to the gateway). */
boundary(sourceNodeId: string, sourceHandle: string, nodeId = `b-${sourceNodeId}-${sourceHandle}`): this {
this.boundaryNodes.push({ sourceNodeId, sourceHandle, nodeId });
return this;
}
start(id: string): this {
this.startNodeId = id;
return this;
}
build(): ExecutionPlan {
return { return {
planId: this.opts.planId ?? `${this.scenarioId}-plan`, runId: 'run-1',
scenarioId: this.scenarioId, scenarioId: 'scenario-1',
userId: this.opts.userId ?? 'player-1', nodeId: `${type}-1`,
startNodeId: this.startNodeId, type,
runId: this.runIdValue ?? `${this.scenarioId}-run`, data,
nodes: this.nodes, ...overrides,
edges: this.edges,
boundaryNodes: this.boundaryNodes,
context: undefined,
}; };
} }
}
export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder {
return new PlanBuilder(scenarioId, opts);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
target: 'es2022', target: 'es2022',
dts: true, dts: true,
clean: true, clean: true,
// Code-split the lazily-imported scenario engine out of the initial chunk. // Code-split the lazily-imported scenario client out of the initial chunk.
splitting: true, splitting: true,
treeshake: true, treeshake: true,
platform: 'browser', platform: 'browser',