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
+12 -41
View File
@@ -9,8 +9,8 @@
* client (which keeps the module graph acyclic). Cross-domain wiring (the
* inventory↔catalog link, purchase invalidation) is done here at construction.
*
* The scenario runtime (engine + plan persistence) is loaded lazily on first
* use — a client that never logs in never pays for the scenario engine.
* The scenario effects client is loaded lazily on first use — a client that
* never logs in never pays for the scenario machinery.
*/
import type {
@@ -26,7 +26,6 @@ import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
import { BattlePassService } from '../battlepass/BattlePassService.js';
import { QuestsService } from '../quests/QuestsService.js';
import type { ScenarioService } from '../scenario/ScenarioService.js';
import type { PlanStateStore } from '../scenario/engine/IndexedDbPlanStore.js';
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
import { SyncEngine } from '../state/SyncEngine.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 { StoresDomain } from '../domains/StoresDomain.js';
/** A domain the client can bulk-invalidate (re-login) or reset (logout). */
type ManagedDomain = { invalidate(): void; reset(): void };
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
@@ -50,7 +48,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
public readonly quests: QuestsService;
public readonly effects: Effects;
// Observable domains: subscribe via `onChange`, read `.data`, or `reload()`.
public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain;
public readonly inventory: InventoryDomain;
@@ -60,8 +57,8 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
public readonly stores: StoresDomain;
private readonly effectsCenter: EffectsCenter;
private scenarioRuntime: ScenarioService<TConfig> | null = null;
private scenarioRuntimePromise: Promise<ScenarioService<TConfig>> | null = null;
private scenarioRuntime: ScenarioService | null = null;
private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
private readonly syncEngine: SyncEngine;
private readonly loginEvent: string | null;
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 {
this.syncEngine.stop();
this.scenarioRuntime?.clear();
@@ -157,8 +149,6 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.invalidateAll();
}
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([
this.remoteConfig.load(),
this.player.load(),
@@ -166,51 +156,34 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
this.catalog.load(),
]);
const runtime = await runtimePromise;
await runtime.restore();
await runtime.start();
if (this.loginEvent) {
await this.sendRuntimeEvent(runtime, this.loginEvent);
}
this.syncEngine.start();
}
/**
* 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>> {
private ensureScenarioRuntime(): Promise<ScenarioService> {
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
return this.scenarioRuntimePromise;
}
private async createScenarioRuntime(): Promise<ScenarioService<TConfig>> {
private async createScenarioRuntime(): Promise<ScenarioService> {
const { ScenarioService } = await import('../scenario/ScenarioService.js');
const configured = this.options.runtime?.planStateStore;
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.scenarioRuntime = new ScenarioService(
this.ctx,
{
player: this.player,
inventory: this.inventory,
config: this.remoteConfig,
stores: this.stores,
},
this.battlePass,
planStore,
);
return this.scenarioRuntime;
}
private async sendRuntimeEvent(
runtime: ScenarioService<TConfig>,
runtime: ScenarioService,
event: string,
): Promise<void> {
try {
@@ -240,12 +213,10 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
];
}
/** Re-login: refetch every domain that is in use. */
private invalidateAll(): void {
for (const domain of this.managedDomains) domain.invalidate();
}
/** Logout: drop all cached data. */
private resetAll(): void {
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
* 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
*/
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
client: RudderClient<TConfig>,
): Promise<ScenarioService<TConfig>> {
): Promise<ScenarioService> {
return (
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService<TConfig>> }
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
).ensureScenarioRuntime();
}
+1 -17
View File
@@ -1,22 +1,6 @@
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 {
/**
* 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;
}
@@ -46,7 +30,7 @@ export interface RudderClientOptions {
*/
onEffectError?: (error: unknown) => void;
/** Advanced runtime knobs (plan persistence, login event). */
/** Advanced runtime knobs (login event). */
runtime?: RudderRuntimeOptions;
}
+19 -80
View File
@@ -1,15 +1,5 @@
import type { PurchaseOfferResponse } from '../generated/stores.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 {
AddBattlePassXpResponse,
ClaimBattlePassRewardResponse,
@@ -44,22 +34,15 @@ export interface ConfigChangedEffect {
readonly key: string;
}
/** A scenario wait node became active; the run resumes at `deadlineUtc`. */
export interface WaitEffect {
readonly deadlineUtc: Date;
}
/** A scenario run reached a terminal end successfully. */
export interface ScenarioCompletedEffect {
readonly runId: 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 {
readonly runId: string;
readonly scenarioId: string;
@@ -67,17 +50,12 @@ export interface ScenarioFailedEffect {
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 {
readonly name: string;
readonly objectives: ReadonlyArray<Record<string, unknown>>;
reportProgress(objectiveId: string, amount?: number): Promise<void>;
}
/** A scenario battle pass node became active. */
export interface BattlePassEffect {
getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
@@ -87,7 +65,6 @@ export interface BattlePassEffect {
end(): Promise<void>;
}
/** A scenario battlepass_level node became active (a single claimable tier). */
export interface BattlePassLevelEffect {
readonly level: number;
claim(): Promise<void>;
@@ -166,36 +143,20 @@ export class EffectsCenter implements Effects {
}
/** @internal */
emitNotification(session: NotificationSession): void {
this.emit(this.notificationHandlers, {
title: session.title,
message: session.message,
done: () => session.complete(),
});
emitNotification(effect: NotificationEffect): void {
this.emit(this.notificationHandlers, effect);
}
/** @internal */
emitStoreOffer(session: StoreSession): void {
session.getStore()
.then((store) => {
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(),
});
})
emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
Promise.resolve(effect)
.then((value) => this.emit(this.storeOfferHandlers, value))
.catch((error) => this.onError(error));
}
/** @internal */
emitLeaderboard(session: LeaderboardSession): void {
this.emit(this.leaderboardHandlers, {
end: () => session.end(),
claim: () => session.claim(),
rewardClaimed: () => session.claim(),
});
emitLeaderboard(effect: LeaderboardEffect): void {
this.emit(this.leaderboardHandlers, effect);
}
/** @internal */
@@ -204,55 +165,33 @@ export class EffectsCenter implements Effects {
}
/** @internal */
emitWait(session: WaitSession): void {
this.emit(this.waitHandlers, { deadlineUtc: session.deadlineUtc });
emitWait(effect: WaitEffect): void {
this.emit(this.waitHandlers, effect);
}
/** @internal */
emitScenarioCompleted(run: PlanRun): void {
this.emit(this.scenarioCompletedHandlers, {
runId: run.runId,
scenarioId: run.scenarioId,
});
emitScenarioCompleted(effect: ScenarioCompletedEffect): void {
this.emit(this.scenarioCompletedHandlers, effect);
}
/** @internal */
emitScenarioFailed(event: ScenarioRunFailedEvent): void {
this.emit(this.scenarioFailedHandlers, {
runId: event.run.runId,
scenarioId: event.run.scenarioId,
nodeId: event.nodeId,
error: event.error,
});
emitScenarioFailed(effect: ScenarioFailedEffect): void {
this.emit(this.scenarioFailedHandlers, effect);
}
/** @internal */
emitQuest(session: QuestSession): void {
this.emit(this.questHandlers, {
name: session.name,
objectives: session.objectives,
reportProgress: (objectiveId, amount) => session.reportProgress(objectiveId, amount),
});
emitQuest(effect: QuestEffect): void {
this.emit(this.questHandlers, effect);
}
/** @internal */
emitBattlePass(session: BattlePassSession): void {
this.emit(this.battlePassHandlers, {
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(),
});
emitBattlePass(effect: BattlePassEffect): void {
this.emit(this.battlePassHandlers, effect);
}
/** @internal */
emitBattlePassLevel(session: BattlePassLevelSession): void {
this.emit(this.battlePassLevelHandlers, {
level: session.level,
claim: () => session.claim(),
});
emitBattlePassLevel(effect: BattlePassLevelEffect): void {
this.emit(this.battlePassLevelHandlers, effect);
}
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 { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.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 { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
@@ -100,12 +100,6 @@ export const api = {
): Promise<{ [key: string]: number }> =>
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: (
t: Transport,
query?: { types?: string; limit?: number; cursor?: string },
@@ -129,6 +123,11 @@ export const api = {
): Promise<ListCatalogItemsResponse> =>
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
listPendingScenarioEffects: (
t: Transport,
): Promise<ListPendingScenarioEffectsResponse> =>
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
listQuests: (
t: Transport,
): Promise<ListQuestsResponse> =>
+1 -3
View File
@@ -1,6 +1,6 @@
// Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan, Reward } from './common.js';
import type { Reward } from './common.js';
export interface AddBattlePassXpRequest {
"amount"?: number;
@@ -14,7 +14,6 @@ export interface AddBattlePassXpResponse {
"level"?: number;
"leveledUp"?: boolean;
"maxLevel"?: boolean;
"plan"?: ExecutionPlan;
"xp"?: number;
}
@@ -59,7 +58,6 @@ export interface PurchaseBattlePassPremiumRequest {
export interface PurchaseBattlePassPremiumResponse {
"error"?: string;
"plan"?: ExecutionPlan;
"success"?: boolean;
}
-36
View File
@@ -1,15 +1,5 @@
// 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 {
"code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
"error"?: string;
@@ -18,32 +8,6 @@ export interface ErrorResponse {
"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 {
"amount"?: number;
"currency"?: string;
+16 -15
View File
@@ -1,17 +1,5 @@
// 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 {
"handle"?: string;
"nodeId"?: string;
@@ -20,7 +8,20 @@ export interface HandleScenarioCallbackRequest {
}
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 {
@@ -28,7 +29,7 @@ export interface TriggerScenarioRequest {
}
export interface TriggerScenarioResponse {
"plans"?: ExecutionPlan[];
"effects": PendingEffect[];
}
export interface UpdateScenarioCounterRequest {
@@ -41,6 +42,6 @@ export interface UpdateScenarioCounterRequest {
export interface UpdateScenarioCounterResponse {
"completed"?: boolean;
"plan"?: ExecutionPlan;
"effect"?: PendingEffect;
}
+354 -505
View File
@@ -1,603 +1,452 @@
import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape, RemoteConfigState } from '../state/RemoteConfigState.js';
import type { ShopHandle } from '../state/shops.js';
import type { ShopHandle, OfferHandle, BuyOptions } from '../state/shops.js';
import type { PurchaseOfferResponse } from '../generated/stores.js';
import { api } from '../generated/api.js';
import type { TriggerScenarioResponse } from '../generated/scenarios.js';
import type { ExecutionPlan } from '../generated/common.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 type { PendingEffect, TriggerScenarioResponse } from '../generated/scenarios.js';
import { RudderErrorCodes } from '../generated/errors.js';
import {
RudderNetworkError,
RudderHttpError,
} 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';
/**
* Error thrown when a boundary HTTP call fails with a transient error
* (network failure or server 5xx). The caller should NOT advance the run;
* the node stays active and the handle stays pending for retry on reconnect.
*/
class TransientBoundaryError extends Error {
constructor(public readonly inner: unknown) {
super('Transient boundary error');
this.name = 'TransientBoundaryError';
}
export interface ScenarioDomains {
readonly player: { invalidate(): void };
readonly inventory: { invalidate(): void };
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
}
const DEFAULT_PENDING_INTERVAL_MS = 30_000;
const JITTER_RATIO = 0.2;
type ActiveRecord = {
runId: string;
nodeId: string;
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 {
if (err instanceof RudderNetworkError) return true;
if (err instanceof RudderHttpError && err.status >= 500) return true;
return false;
}
function isRankNotEligible(err: unknown): boolean {
return err instanceof RudderHttpError && err.code === 'rank_not_eligible';
function isDroppedRunError(err: unknown): boolean {
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> {
private readonly runs = new Map<string, RuntimeRun>();
function asObjectives(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === 'object' && !Array.isArray(item),
);
}
function asLevel(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
export class ScenarioService {
private readonly active = new Map<string, ActiveRecord>();
private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly droppedRuns = new Set<string>();
private readonly completedRuns = new Set<string>();
private heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
private running = false;
private polling = false;
private pollQueued = false;
constructor(
private readonly ctx: RudderContext,
private readonly domains: ScenarioDomains,
private readonly battlePass: BattlePassService,
private readonly 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 {
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> {
const response = await api.triggerScenario(this.ctx, { event: eventName });
this.domains.player.invalidate();
this.domains.inventory.invalidate();
this.startPlans(response.plans ?? []);
this.ingest(response.effects ?? []);
return response;
}
/** Completes the current active node with the given handle. */
async respond(handle: string): Promise<void> {
for (const run of this.runs.values()) {
for (const nodeId of run.activeNodes.keys()) {
await this.completeNodeAsync(run.runId, nodeId, handle);
return;
}
async start(): Promise<void> {
if (this.running) return;
this.running = true;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
await this.pollPending();
this.scheduleHeartbeat();
}
/**
* 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 {
this.running = false;
this.pollQueued = false;
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
for (const timer of this.waitTimers.values()) {
clearTimeout(timer);
}
this.waitTimers.clear();
this.runs.clear();
this.persist().catch(() => {});
}
// ---- Internal: Plan lifecycle ----
private startPlans(plans: ExecutionPlan[]): void {
for (const plan of plans ?? []) {
this.startPlan(plan);
this.active.clear();
this.droppedRuns.clear();
this.completedRuns.clear();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
}
private startPlan(plan: ExecutionPlan): void {
if (!plan.nodes?.length) return;
if (plan.boundaryNodes?.length && !plan.runId) {
throw new Error('ExecutionPlan has boundaryNodes but missing runId');
private readonly onVisibilityChange = (): void => {
if (this.running && typeof document !== 'undefined' && !document.hidden) {
void this.heartbeatTick();
}
// 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;
const startNode = findNode(plan, plan.startNodeId) ?? plan.nodes[0];
if (!startNode?.id) return;
const runId = plan.runId ?? crypto.randomUUID().replace(/-/g, '');
const run = new RuntimeRun(runId, plan);
this.runs.set(run.runId, run);
this.activateNode(run, startNode.id);
this.persist().catch(() => {});
};
private scheduleHeartbeat(): void {
if (!this.running) return;
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
this.heartbeatTimer = setTimeout(() => {
void this.heartbeatTick();
}, this.intervalMs * jitter);
}
private activateNode(
run: RuntimeRun,
nodeId: string,
restoredState?: ActiveNodeState,
): void {
const node = findNode(run.plan, nodeId);
if (!node?.id) return;
const state = restoredState ?? { nodeId };
run.activeNodes.set(nodeId, state);
this.dispatchActiveNode(run, state, restoredState !== undefined);
private async heartbeatTick(): Promise<void> {
if (!this.running) return;
if (typeof document !== 'undefined' && document.hidden) {
this.scheduleHeartbeat();
return;
}
await this.pollPending();
this.scheduleHeartbeat();
}
// ---- 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(
run: RuntimeRun,
state: ActiveNodeState,
restored: boolean,
): void {
const node = findNode(run.plan, state.nodeId);
if (!node) {
run.activeNodes.delete(state.nodeId);
this.checkRunCompleted(run);
private reconcile(effects: PendingEffect[]): void {
const incoming = effects.filter((effect) => !this.droppedRuns.has(effect.runId));
const incomingKeys = new Set(
incoming.map((effect) => effectKey(effect.runId, effect.nodeId)),
);
const before = new Set(this.active.keys());
this.ingest(incoming);
for (const [key, record] of [...this.active.entries()]) {
if (incomingKeys.has(key)) continue;
this.deactivate(key);
this.emitCompletedIfIdle(record.runId, record.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;
}
const ctx = new ScenarioNodeContext(
this.toPlanRun(run),
node,
(rid, nid, h) => this.completeNodeInternal(rid, nid, h, true),
(rid, nid, ck, amt) => this.updateProgressInternal(rid, nid, ck, amt),
);
const record: ActiveRecord = {
runId: pending.runId,
nodeId: pending.nodeId,
scenarioId: pending.scenarioId,
};
this.active.set(key, record);
this.completedRuns.delete(pending.runId);
if (pending.waitDeadline) {
this.scheduleWait(key, pending.waitDeadline);
}
this.dispatch(pending, record);
}
switch (node.type) {
case 'wait':
this.dispatchWait(run, state, ctx);
break;
case 'remote_config_override':
this.dispatchRemoteConfigOverride(run, state, ctx);
break;
private dispatch(pending: PendingEffect, record: ActiveRecord): void {
switch (pending.type) {
case 'notification':
{
const session = new NotificationSession(ctx);
this.ctx.effects.emitNotification(session);
this.onNotification?.(session);
}
this.ctx.effects.emitNotification({
title: asString(pending.data.title),
message: asString(pending.data.message),
done: () => this.complete(record, 'output'),
});
break;
case 'store':
{
const session = new StoreSession(ctx, this.domains.stores);
this.ctx.effects.emitStoreOffer(session);
this.onStore?.(session);
}
this.dispatchStore(pending, record);
break;
case 'leaderboard':
{
const session = new LeaderboardSession(ctx);
this.ctx.effects.emitLeaderboard(session);
this.onLeaderboard?.(session);
}
case 'wait':
this.ctx.effects.emitWait({
deadlineUtc: pending.waitDeadline
? new Date(pending.waitDeadline)
: new Date(),
});
break;
case 'leaderboard': {
const end = () => this.complete(record, 'onEnd');
const claim = () => this.complete(record, 'onClaim');
this.ctx.effects.emitLeaderboard({
end,
claim,
rewardClaimed: claim,
});
break;
}
case 'quest':
this.ctx.effects.emitQuest(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;
case 'battlepass':
this.ctx.effects.emitBattlePass(new BattlePassSession(ctx, this.battlePass));
this.dispatchBattlePass(record);
break;
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;
default:
// Unsupported node type — fail the run (surfaced via onScenarioFailed)
// instead of leaving it stalled on a node no handler will complete.
console.warn(
`[Rudder] Unsupported scenario node type '${node.type}' (${node.id})`,
`[Rudder] Unsupported scenario node type '${pending.type}' (${pending.nodeId})`,
);
this.failRun(
run,
state.nodeId,
new Error(`Unsupported scenario node type '${node.type}'`),
this.dropRun(
record.runId,
record.nodeId,
new Error(`Unsupported scenario node type '${pending.type}'`),
);
break;
}
}
private dispatchWait(
run: RuntimeRun,
state: ActiveNodeState,
ctx: ScenarioNodeContext,
): void {
// Prefer server-provided waitDeadline from the plan boundary over local calculation.
// The server stamps waitDeadline on server-enforced wait boundaries (see StampBoundaries).
if (!state.waitDeadlineUtc) {
const boundary = (run.plan.boundaryNodes ?? []).find(
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 dispatchStore(pending: PendingEffect, record: ActiveRecord): void {
const slug = asString(pending.data.storeSlug);
this.ctx.effects.emitStoreOffer(
this.domains.stores.getBySlug(slug).then((store) => ({
store,
offers: store.offers,
message: typeof pending.data.message === 'string' ? pending.data.message : undefined,
buy: (offer, options) => this.buyStore(record, offer, options),
dismiss: () => this.dismissStore(record),
})),
);
}
private dispatchRemoteConfigOverride(
run: RuntimeRun,
state: ActiveNodeState,
ctx: ScenarioNodeContext,
): void {
const patches = ctx.data.patches as Array<{
path?: string;
valueType?: string;
value?: string;
}> | undefined;
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,
err instanceof Error ? err : new Error(String(err)),
);
}
}
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) {
try {
const response = await api.handleScenarioCallback(this.ctx, {
scenarioId: run.plan.scenarioId,
nodeId: boundary.sourceNodeId,
handle: boundary.sourceHandle,
runId: run.runId,
private dispatchBattlePass(record: ActiveRecord): void {
const { scenarioId, nodeId, runId } = record;
this.ctx.effects.emitBattlePass({
getProgress: () => this.battlePass.getProgress(scenarioId, nodeId),
addXp: (source, amount) =>
this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }),
claimReward: (level, track) =>
this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }),
purchasePremium: async () => {
const response = await this.battlePass.purchasePremium({
scenarioId,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (response?.plan) {
this.startPlan(response.plan);
if (response.success) {
await this.complete(record, 'onPremiumPurchase');
}
} catch (err: unknown) {
// Boundary call failed — try to reconcile with server.
let reconciled = false;
try {
const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId });
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;
}
// If reconciled, the boundary was handled (run corrected or removed).
// Fall through to continue to the next boundary.
}
}
return true;
return response;
},
levelUp: () => this.complete(record, 'onLevelUp'),
end: () => this.complete(record, 'onComplete'),
});
}
// ---- Internal: Counter progress ----
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 };
}
async updateProgressInternal(
runId: string,
nodeId: string,
counterKey: string,
amount: number,
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> {
const run = this.runs.get(runId);
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioId: run?.plan.scenarioId ?? '',
nodeId,
counterKey,
scenarioId: record.scenarioId,
nodeId: record.nodeId,
counterKey: objectiveId,
amount,
runId,
runId: record.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);
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 {
// Counter update failure does not fail the run.
}
}
// ---- Internal: Run lifecycle ----
private checkRunCompleted(run: RuntimeRun): void {
if (run.activeNodes.size > 0) return;
// A node transition is still settling (e.g. an auto-completing
// remote_config_override node is about to activate its successor).
// Wait for it to finish before declaring the run complete.
if (run.pendingTransitions > 0) return;
if (!this.runs.has(run.runId)) return; // already completed/removed
this.runs.delete(run.runId);
const planRun = this.toPlanRun(run);
this.onRunCompleted?.(planRun);
this.onCompleted?.();
this.ctx.effects.emitScenarioCompleted(planRun);
this.persist().catch(() => {});
}
private failRun(
run: RuntimeRun,
nodeId: string,
error: Error,
): void {
console.warn(
`[Rudder] Scenario run ${run.runId} failed at node ${nodeId}: ${error.message}`,
);
this.runs.delete(run.runId);
const event: ScenarioRunFailedEvent = { run: this.toPlanRun(run), nodeId, error };
this.onRunFailed?.(event);
this.ctx.effects.emitScenarioFailed(event);
this.persist().catch(() => {});
}
// ---- 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;
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 {
await store.save(state);
} catch {
// Best-effort persistence.
const response = await api.handleScenarioCallback(this.ctx, {
scenarioId: record.scenarioId,
nodeId: record.nodeId,
handle,
runId: record.runId,
});
this.domains.player.invalidate();
this.domains.inventory.invalidate();
if (!this.active.has(key)) return;
this.deactivate(key);
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioId);
}
} catch (err) {
if (isTransientHttpError(err)) return;
if (isDroppedRunError(err)) {
const error = err instanceof Error ? err : new Error(String(err));
this.dropRun(record.runId, record.nodeId, error);
throw err;
}
throw err;
}
}
private scheduleWait(key: string, waitDeadline: string): void {
const deadline = Date.parse(waitDeadline);
if (Number.isNaN(deadline)) return;
const remaining = Math.max(0, deadline - Date.now());
const existing = this.waitTimers.get(key);
if (existing) clearTimeout(existing);
this.waitTimers.set(
key,
setTimeout(() => {
this.waitTimers.delete(key);
void this.pollPending();
}, remaining),
);
}
private deactivate(key: string): void {
const timer = this.waitTimers.get(key);
if (timer) {
clearTimeout(timer);
this.waitTimers.delete(key);
}
this.active.delete(key);
}
private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId);
let scenarioId = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioId) scenarioId = record.scenarioId;
this.deactivate(key);
}
console.warn(
`[Rudder] Scenario run ${runId} failed at node ${nodeId}: ${error.message}`,
);
this.ctx.effects.emitScenarioFailed({
runId,
scenarioId,
nodeId,
error,
});
}
private emitCompletedIfIdle(runId: string, scenarioId: string): void {
if (this.droppedRuns.has(runId)) return;
if (this.completedRuns.has(runId)) return;
for (const record of this.active.values()) {
if (record.runId === runId) return;
}
this.completedRuns.add(runId);
this.ctx.effects.emitScenarioCompleted({ runId, 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;
}