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:
+12
-41
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user