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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user