diff --git a/CHANGELOG.md b/CHANGELOG.md index af55d56..56e7a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## Unreleased + +Breaking change — major bump. The local scenario engine is replaced by a +server-driven effects client; the realtime websocket client is removed. + +### Removed + +- Local scenario runtime: `PlanRun`, `ExecutionPlan` walking, node sessions + (`NotificationSession`, `StoreOfferSession`, `LeaderboardSession`, + `WaitSession`, `QuestSession`, `BattlePassSession`, + `BattlePassLevelSession`, `ConfigChangedSession`), `RespondAsync` / + `Respond`, `RestoreAsync`, `Clear`, `ActiveRuns`, `IsRunning`, + `CurrentNodeId`, `OnConfigChanged`. +- `RudderClientOptions.PlanStateStore` / `IPlanStateStore` and + `IPlanScheduler` — no local plan persistence or delayed plan work. +- `RealtimeService` (`client.Realtime`), `RudderClientOptions.RealtimeUrl` / + `RealtimeTransportFactory`, `IRealtimeTransport` / + `IRealtimeTransportFactory`. + +### Changed (breaking) + +- `client.Scenario` is a trigger only: `TriggerAsync(eventName)` returns + `Task` (not started plans). Returned pending effects are ingested into + `client.Effects`. +- Subscribe to `client.Effects.On*` instead of `client.Scenario.On*`. + Sessions are now effect objects (`NotificationEffect`, `StoreOfferEffect`, + …). Complete them with the methods on the effect (`DoneAsync`, + `PurchaseAsync`/`DeclineAsync`, `EndAsync`/`ClaimAsync`, …). +- `client.Update(deltaTime)` is still required every frame — it now pumps + the effects client (30s `GET /sdk/v1/scenarios/pending` heartbeat + + wait-deadline checks), not a local DAG or a websocket. +- After login (or when constructed with a stored access token), the next + `Update` fetches pending effects. There is no `loginEvent` option; trigger + login-gated scenarios yourself. + +### Migration + +| Before | After | +|---|---| +| `client.Scenario.OnNotification` (and other `On*`) | `client.Effects.OnNotification` (same event names) | +| `NotificationSession` / `StoreOfferSession` / … | `NotificationEffect` / `StoreOfferEffect` / … | +| `session.CompleteAsync()` / `RespondAsync("output")` | `effect.DoneAsync()` (and the matching method on each effect) | +| `RudderClientOptions.PlanStateStore` + `RestoreAsync` | gone — server owns run state; pending fetch after login + heartbeat | +| `client.Realtime` | gone | +| `client.Update(deltaTime)` | still required (effects pump) | + ## 0.4.0 - Generated DTOs live under `Models/` (`RudderSdk.Core.Models`). Wire fields diff --git a/README.md b/README.md index 01d2d66..de95f95 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ .NET client SDK for the Rudder LiveOps platform: authentication, player profile, stores, battle pass, quests, leaderboards, inventory, remote config, -scenarios, storage and realtime. +scenarios (server-driven effects), and storage. - Target framework: `netstandard2.1` (works in Unity, .NET, Xamarin). - JSON: Newtonsoft.Json. @@ -36,7 +36,7 @@ Console.WriteLine(profile.Player.Id); // Buy an offer (idempotency key is generated when omitted). var purchase = await client.Stores.PurchaseAsync("main-store", "offer-1"); -// Pump time-dependent services (scenarios, realtime) every frame. +// Pump the effects client (30s pending heartbeat + wait deadlines) every frame. client.Update(deltaTime); ``` @@ -58,15 +58,15 @@ stack. | `Inventory` | `InventoryService` | `GetAsync` | | `Leaderboards` | `LeaderboardsService` | `FindBySlug(slug)` → handle: `SubmitAsync`, `ListAsync` | | `RemoteConfig` | `RemoteConfigService` | `LoadAsync`, `Get`, `GetAsync` | -| `Scenario` | `ScenarioService` | `TriggerAsync`, `RestoreAsync`, `On*` effect events | +| `Scenario` | `ScenarioService` | `TriggerAsync` | +| `Effects` | `EffectsService` | `On*` effect events; complete via methods on the effect objects | | `Storage` | `StorageService` | `GetAsync`, `ListAllAsync`, `SaveAsync`, `DeleteAsync` | -| `Realtime` | `RealtimeService` | `ConnectAsync`, `DisconnectAsync` | ## Quests `client.Quests` covers the player's global quests — list with per-objective progress, claim, and metric reports. These are distinct from scenario quest -nodes, which advance through `QuestSession` (`Scenario.OnQuest`). +nodes, which advance through `QuestEffect` (`Effects.OnQuest`). ```csharp var quests = await client.Quests.ListAsync(); @@ -108,11 +108,18 @@ All of them carry `StatusCode` (`int`), the machine-readable `Code` ## Scenario effects -Subscribe to typed sessions on `client.Scenario`: +The server owns scenario execution. Trigger with `client.Scenario.TriggerAsync`, +subscribe to typed effects on `client.Effects`, and call `client.Update` every +frame: -`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnConfigChanged`, -`OnWait`, `OnQuest`, `OnBattlePass`, `OnBattlePassLevel`, -`OnScenarioCompleted`, `OnScenarioFailed`. +`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnWait`, `OnQuest`, +`OnBattlePass`, `OnBattlePassLevel`, `OnScenarioCompleted`, +`OnScenarioFailed`. + +Complete an effect with the methods on the object (`DoneAsync`, +`PurchaseAsync`/`DeclineAsync`, `EndAsync`/`ClaimAsync`, …) or the run +stalls. `unknown_run` / `run_expired` drop the run and fire +`OnScenarioFailed`. ## Tests diff --git a/skills/rudder-csharp-sdk/SKILL.md b/skills/rudder-csharp-sdk/SKILL.md index f690a76..8fb22f1 100644 --- a/skills/rudder-csharp-sdk/SKILL.md +++ b/skills/rudder-csharp-sdk/SKILL.md @@ -1,6 +1,6 @@ --- name: rudder-csharp-sdk -description: Use when working with the Rudder C# SDK Rudder.Core (namespace RudderSdk.Core) — client init, transports, auth/session lifecycle, and the per-domain services (player, stores, inventory, battle pass, quests, leaderboards, remote config, scenarios, storage, realtime). +description: Use when working with the Rudder C# SDK Rudder.Core (namespace RudderSdk.Core) — client init, transports, auth/session lifecycle, and the per-domain services (player, stores, inventory, battle pass, quests, leaderboards, remote config, scenario trigger, scenario effects, storage). --- # Rudder C# SDK (Rudder.Core) @@ -43,8 +43,8 @@ full options surface and the abstraction interfaces): - `DeviceIdProvider` → `GuidDeviceIdProvider` (new GUID per run — plug a persistent `IDeviceIdProvider` for production) -`RealtimeUrl` + `RealtimeTransportFactory` are required only for -`client.Realtime`; there is no default websocket transport in Rudder.Core. +`Clock` defaults to `DateTimeOffset.UtcNow`; override in tests. There is no +realtime websocket client in Rudder.Core. ## Request pipeline (applies to every service) @@ -58,7 +58,7 @@ full options surface and the abstraction interfaces): failures throw `RudderNetworkException` (`StatusCode == 0`). See reference/errors.md. - `client.Update(deltaTime)` must be called every frame — it pumps the - scenario runtime (wait-node deadlines) and the realtime transport. + effects client (30s pending heartbeat and wait-node deadline checks). ## Capability map @@ -74,19 +74,19 @@ full options surface and the abstraction interfaces): | Stores and offer purchases | `client.Stores` | reference/stores.md | | Leaderboards | `client.Leaderboards.FindBySlug(slug)` | reference/leaderboards.md | | Inventory | `client.Inventory` | reference/inventory.md | -| Battle pass | `client.BattlePass` (+ scenario session) | reference/battlepass.md | +| Battle pass | `client.BattlePass` (+ scenario effect) | reference/battlepass.md | | Quests (global) | `client.Quests` | reference/quests.md | -| Scenarios (event-driven plans, node sessions) | `client.Scenario` | reference/scenarios.md | -| Realtime websocket | `client.Realtime` | reference/realtime.md | +| Scenario trigger | `client.Scenario` | reference/scenarios.md | +| Scenario effects | `client.Effects.On*` | reference/scenarios.md | Important cross-cutting facts: - Battle pass state is tied to a scenario battle-pass node: every `BattlePassService` call carries `ScenarioId`/`NodeId` (mutations also - `RunId`). During a scenario run, `BattlePassSession` supplies them — prefer - the session API inside a run. + `RunId`). During a scenario run, `BattlePassEffect` supplies them — prefer + the effect API inside a run. - Global quests (`client.Quests`) are distinct from scenario quest nodes - (`Scenario.OnQuest` → `QuestSession`). + (`Effects.OnQuest` → `QuestEffect`). - Mutations that take an idempotency key auto-generate a random GUID when the key is omitted (`Stores.PurchaseAsync`, `ProjectStorage.SaveAsync`, `BattlePass.PurchasePremiumAsync`). Pass a stable key to make retries safe. @@ -96,10 +96,10 @@ Important cross-cutting facts: ## Relationship to the Unity package (rudder.sdk) `rudder.sdk` (UPM, liveops-unity-sdk) wraps `Rudder.Core.dll` with Unity -adapters: `UnityWebRequest` transport, `PlayerPrefs` token store, a websocket -realtime transport, and a `Rudder` bootstrap component -(`RudderSdk.Unity` namespace). The domain services and models are the same -ones documented here — for Unity games, consume them through the package's -adapters instead of wiring `RudderClientOptions` by hand. Unity requires +adapters: `UnityWebRequest` transport, `PlayerPrefs` token store, and a +`Rudder` bootstrap component (`RudderSdk.Unity` namespace). The domain +services and models are the same ones documented here — for Unity games, +consume them through the package's adapters instead of wiring +`RudderClientOptions` by hand. Unity requires `com.unity.nuget.newtonsoft-json` since Rudder.Core serializes with Newtonsoft.Json. diff --git a/skills/rudder-csharp-sdk/reference/auth.md b/skills/rudder-csharp-sdk/reference/auth.md index de064f1..d644b7a 100644 --- a/skills/rudder-csharp-sdk/reference/auth.md +++ b/skills/rudder-csharp-sdk/reference/auth.md @@ -48,7 +48,9 @@ public class LoginViaDeviceResponse / LoginViaCustomResponse ## Behavior notes - A successful login stores the token pair in the configured `ITokenStore` - and fires `SignedIn`. + and fires `SignedIn`. `EffectsService` treats `SignedIn` as a pending-effects + refresh due: the next `client.Update` GETs `/sdk/v1/scenarios/pending`. + This SDK does not auto-trigger a login scenario event. - If the server returns an incomplete session (missing tokens), login throws `InvalidOperationException`. - Token refresh is automatic: any API call that gets a 401 triggers one @@ -56,3 +58,5 @@ public class LoginViaDeviceResponse / LoginViaCustomResponse transparent retry. A failed refresh clears the tokens and fires `SignedOut`; the original call then throws `RudderAuthException`. - `RefreshAsync()` shares the same single-flight path. +- Logout / `SignedOut` clears the effects client's seen-effect set and wait + deadlines. diff --git a/skills/rudder-csharp-sdk/reference/battlepass.md b/skills/rudder-csharp-sdk/reference/battlepass.md index 0f48579..ad6aa86 100644 --- a/skills/rudder-csharp-sdk/reference/battlepass.md +++ b/skills/rudder-csharp-sdk/reference/battlepass.md @@ -1,13 +1,13 @@ # Battle pass — `client.BattlePass` (`BattlePassService`) Source: `Services/BattlePassService.cs`, -`Services/Scenarios/Sessions/BattlePassSession.cs`, -`Services/Scenarios/Sessions/BattlePassLevelSession.cs`, `BattlePass/*.cs` +`Services/Effects/BattlePassEffect.cs`, +`Services/Effects/BattlePassLevelEffect.cs`, `BattlePass/*.cs` (generated DTOs). Battle pass state is tied to a scenario battle-pass node, so every call carries `ScenarioId`/`NodeId` (mutations also `RunId`). Inside a scenario run, -`Scenario.OnBattlePass` hands you a `BattlePassSession` that supplies these +`Effects.OnBattlePass` hands you a `BattlePassEffect` that supplies these ids — prefer it over calling the service directly. ## BattlePassService @@ -88,7 +88,6 @@ public class AddBattlePassXpResponse public int? Level { get; set; } // "level" public bool? LeveledUp { get; set; } // "leveledUp" public bool? MaxLevel { get; set; } // "maxLevel" - public ExecutionPlan? Plan { get; set; } // "plan" — scenario continuation public long? Xp { get; set; } // "xp" } @@ -103,7 +102,6 @@ public class ClaimBattlePassRewardResponse public class PurchaseBattlePassPremiumResponse { public string? Error { get; set; } // "error" - public ExecutionPlan? Plan { get; set; } // "plan" public bool? Success { get; set; } // "success" } ``` @@ -112,36 +110,35 @@ public class PurchaseBattlePassPremiumResponse `ItemId` (string?). Responses carry business errors in `Error` — check `Success`. Claim failures surface codes such as `RudderErrorCodes.LevelNotReached`. -## BattlePassSession (scenario node session) +## `BattlePassEffect` (scenario battlepass node) -Raised via `Scenario.OnBattlePass`. Bound to the node's scenario/node/run ids: +Raised via `Effects.OnBattlePass`. Bound to the node's scenario/node/run ids: ```csharp -public ScenarioNodeContext Context { get; } -public string Id { get; } // node id +public string RunId { get; } +public string ScenarioId { get; } +public string NodeId { get; } public T Get(string key, T defaultValue = default!); public Task GetProgressAsync(CancellationToken ct = default); public Task AddXpAsync(string source, long amount, CancellationToken ct = default); public Task ClaimRewardAsync(int level, string track, CancellationToken ct = default); -// Purchases premium, then crosses the onPremiumPurchase handle on success. +// Purchases premium, then posts onPremiumPurchase when response.Success == true. public Task PurchasePremiumAsync(CancellationToken ct = default); -// Boundary crossings (async + fire-and-forget variants): -public Task LevelUpAsync(CancellationToken ct = default); public void LevelUp(); // onLevelUp -public Task MaxLevelAsync(CancellationToken ct = default); public void MaxLevel(); // onMaxLevel -public Task CompleteAsync(CancellationToken ct = default); public void Complete(); // onComplete +public Task LevelUpAsync(CancellationToken ct = default); public void LevelUp(); // onLevelUp +public Task EndAsync(CancellationToken ct = default); public void End(); // onComplete ``` -## BattlePassLevelSession (scenario battlepass_level node) +## `BattlePassLevelEffect` (scenario battlepass_level node) -Raised via `Scenario.OnBattlePassLevel`; a single claimable tier: +Raised via `Effects.OnBattlePassLevel`; a single claimable tier: ```csharp public int Level { get; } // reads the "levelNumber" node data key public Task ClaimAsync(CancellationToken ct = default); public void Claim(); ``` -`ClaimAsync` crosses `onComplete`; the server accepts it only once the player +`ClaimAsync` posts `onComplete`; the server accepts it only once the player has reached the node's configured level. diff --git a/skills/rudder-csharp-sdk/reference/client.md b/skills/rudder-csharp-sdk/reference/client.md index 9b75da5..90d8cdc 100644 --- a/skills/rudder-csharp-sdk/reference/client.md +++ b/skills/rudder-csharp-sdk/reference/client.md @@ -19,14 +19,13 @@ public sealed class RudderClient public BattlePassService BattlePass { get; } public QuestsService Quests { get; } public ScenarioService Scenario { get; } - public RealtimeService Realtime { get; } + public EffectsService Effects { get; } public string ProjectKey { get; } - public string? RealtimeUrl { get; } - public IClock Clock { get; } // defaults to DateTimeOffset.UtcNow + public IClock Clock { get; } // Options.Clock, else DateTimeOffset.UtcNow public RudderClient(RudderClientOptions options); - public void Update(float deltaTime); // pumps Scenario + Realtime; call every frame + public void Update(float deltaTime); // pumps Effects; call every frame } ``` @@ -40,16 +39,12 @@ single-flight refresh and one transparent retry (see errors.md). public sealed class RudderClientOptions { public string? BaseUrl { get; set; } // required - public string? RealtimeUrl { get; set; } // required only for Realtime public string? ProjectKey { get; set; } // required public IRudderTransport? Transport { get; set; } // default HttpClientTransport public ITokenStore? TokenStore { get; set; } // default InMemoryTokenStore public IDeviceIdProvider? DeviceIdProvider { get; set; }// default GuidDeviceIdProvider public IRudderLogger? Logger { get; set; } // null = silent - public IClock? Clock { get; set; } // override in tests - public IPlanStateStore? PlanStateStore { get; set; } // scenario persistence between launches - public IPlanScheduler? Scheduler { get; set; } // optional delayed plan work - public IRealtimeTransportFactory? RealtimeTransportFactory { get; set; } // required only for Realtime + public IClock? Clock { get; set; } // time source for the effects client; override in tests } ``` @@ -89,27 +84,6 @@ public interface IRudderLogger { void Log(RudderLogLevel level, string message); public enum RudderLogLevel { Debug, Info, Warning, Error } public interface IClock { DateTimeOffset UtcNow { get; } } - -public interface IPlanStateStore { string? State { get; set; } } // serialized scenario-run blob - -public interface IPlanScheduler -{ - Task ScheduleAsync(TimeSpan delay, CancellationToken cancellationToken = default); -} - -public interface IRealtimeTransport -{ - event Action Closed; - event Action> Received; - event Action Error; - bool IsConnected { get; } - Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default); - Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default); - Task CloseAsync(CancellationToken cancellationToken = default); - void Update(float deltaTime); -} - -public interface IRealtimeTransportFactory { IRealtimeTransport Create(); } ``` Production guidance from the sources: provide a durable `ITokenStore` diff --git a/skills/rudder-csharp-sdk/reference/errors.md b/skills/rudder-csharp-sdk/reference/errors.md index 0368dd3..a731b32 100644 --- a/skills/rudder-csharp-sdk/reference/errors.md +++ b/skills/rudder-csharp-sdk/reference/errors.md @@ -1,8 +1,7 @@ # Errors, exceptions, error codes Source: `Exceptions/*.cs`, `Models/RudderErrorCodes.cs`, -`Models/ErrorResponse.cs`, `HttpClientTransport.cs` (status mapping), -`Services/Scenarios/ScenarioService.Runtime.cs` (`TransientBoundaryException`). +`Models/ErrorResponse.cs`, `HttpClientTransport.cs` (status mapping). ## Exception hierarchy @@ -59,11 +58,11 @@ UnknownRun = "unknown_run" Match against `RudderApiException.Code`. -## Scenario boundary errors +## Scenario callback / counter errors -`TransientBoundaryException` (public sealed, wraps the transport error) is -used inside the scenario runtime for transient boundary-callback failures -(timeout / `RudderNetworkException`): the run is NOT advanced, the node stays -active and the handle stays pending for retry. A failed scenario counter -update never fails the run — it is logged via `IRudderLogger` and the node -stays active. +On `POST /sdk/v1/scenarios/callback` and `POST /sdk/v1/scenarios/counter`, +`unknown_run`, `run_expired`, and HTTP 404 drop that run and fire +`Effects.OnScenarioFailed`. Other callback errors propagate to the caller. +A failed counter update that is not one of those definitive rejections is +logged via `IRudderLogger` and the effect stays active. Details: +reference/scenarios.md. diff --git a/skills/rudder-csharp-sdk/reference/quests.md b/skills/rudder-csharp-sdk/reference/quests.md index 9e5d49b..0c4f767 100644 --- a/skills/rudder-csharp-sdk/reference/quests.md +++ b/skills/rudder-csharp-sdk/reference/quests.md @@ -4,7 +4,7 @@ Source: `Services/QuestsService.cs`, `Services/QuestMetrics.cs`, `Quests/*.cs` (generated DTOs). Global quests (list + claim + metric reports). Distinct from scenario quest -nodes, which advance through `QuestSession` (`Scenario.OnQuest`) — see +nodes, which advance through `QuestEffect` (`Effects.OnQuest`) — see scenarios.md. ## Methods diff --git a/skills/rudder-csharp-sdk/reference/realtime.md b/skills/rudder-csharp-sdk/reference/realtime.md deleted file mode 100644 index f8e5521..0000000 --- a/skills/rudder-csharp-sdk/reference/realtime.md +++ /dev/null @@ -1,51 +0,0 @@ -# Realtime — `client.Realtime` (`RealtimeService` + `RealtimeSession`) - -Source: `Services/RealtimeService.cs`, `Abstractions/IRealtimeTransport.cs`, -`Abstractions/IRealtimeTransportFactory.cs`. - -Websocket channel. Requires `RudderClientOptions.RealtimeUrl` and -`RudderClientOptions.RealtimeTransportFactory` — Rudder.Core ships no default -websocket transport (the Unity package `rudder.sdk` provides one). The current -access token authorizes the connection, so sign in first. - -## RealtimeService - -```csharp -public RealtimeSession? Session { get; } // null when not connected -public bool IsConnected { get; } - -// Connects to the configured RealtimeUrl. Throws InvalidOperationException -// when RealtimeUrl is not configured. -public Task ConnectAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - -// Connects to an explicit URL. Throws InvalidOperationException when -// RealtimeTransportFactory is missing or no access token is stored. -public Task ConnectAsync(Uri uri, TimeSpan? timeout = null, CancellationToken cancellationToken = default); - -public Task DisconnectAsync(CancellationToken cancellationToken = default); - -public void Update(float deltaTime); // pumps the transport; call every frame (client.Update does this) -``` - -Default connect timeout: 10 seconds. - -## RealtimeSession - -```csharp -public string AccessToken { get; } // token the connection was authorized with -public bool IsConnected { get; } - -public event Action? Closed; -public event Action? Error; -public event Action>? MessageReceived; - -public Task SendAsync(byte[] payload, CancellationToken cancellationToken = default); -public Task DisconnectAsync(CancellationToken cancellationToken = default); -``` - -There is no message envelope or channel abstraction — payloads are raw bytes; -define the message shape with the server side of your project. - -Note: the working tree contains an untracked `Services/RealtimeSession.cs` -with a stale `RealtimeSession` (no `AccessToken`). The committed definition in -`Services/RealtimeService.cs` documented above is authoritative. diff --git a/skills/rudder-csharp-sdk/reference/remote-config.md b/skills/rudder-csharp-sdk/reference/remote-config.md index 255a900..e67c7b7 100644 --- a/skills/rudder-csharp-sdk/reference/remote-config.md +++ b/skills/rudder-csharp-sdk/reference/remote-config.md @@ -48,5 +48,6 @@ public class RemoteConfig } ``` -Scenario `remote_config_override` nodes apply patches directly into this -cache before `Scenario.OnConfigChanged` fires (see scenarios.md). +Scenario `remote_config_override` nodes are applied server-side and do not +reach the client. Call `LoadAsync` (or `GetAsync`) to observe the patched +value via `Get`. diff --git a/skills/rudder-csharp-sdk/reference/scenarios.md b/skills/rudder-csharp-sdk/reference/scenarios.md index 0046cd0..06a387d 100644 --- a/skills/rudder-csharp-sdk/reference/scenarios.md +++ b/skills/rudder-csharp-sdk/reference/scenarios.md @@ -1,149 +1,273 @@ -# Scenarios — `client.Scenario` (`ScenarioService`) +# Scenarios + effects — `client.Scenario` + `client.Effects` -Source: `Services/ScenarioService.cs`, `Services/Scenarios/ScenarioService.{Dispatch,NodeTypes,Persistence,Runtime}.cs`, -`Services/Scenarios/Models/*.cs`, `Services/Scenarios/Sessions/*.cs`, -`Scenarios/*.cs` (generated DTOs), `Models/*.cs`. +Source: `Services/ScenarioService.cs`, `Services/EffectsService.cs`, +`Services/Effects/*.cs`, `Scenarios/*.cs` (generated DTOs). -Scenario runtime for server-issued execution plans. `TriggerAsync(eventName)` -starts plans; active nodes surface as typed sessions through the `On*` events -and are advanced by completing those sessions (which crosses server-validated -boundaries). Requires `Update(deltaTime)` every frame for wait-node deadlines. +Scenarios are server-authored node graphs (configured in the dashboard) that +run per player. The **server owns the graph**. This SDK is a thin effects +client: it sends trigger events, polls pending effects, raises typed +`client.Effects.On*` events, and posts callbacks when the game completes an +effect. There is no local plan, no `IPlanStateStore`, and no restore. -## ScenarioService surface +## Lifecycle + +- `client.Scenario.TriggerAsync(eventName)` POSTs + `/sdk/v1/scenarios/trigger` with `{event}`. Returned `PendingEffect`s are + ingested into `client.Effects` (they do **not** come back as a return + value). +- Drive `client.Update(deltaTime)` every frame. That pumps + `EffectsService.Update`: a 30s heartbeat (`GET /sdk/v1/scenarios/pending`) + plus an immediate pending fetch when any wait deadline is due. The pump + no-ops when there is no access token, and it will not start a second + fetch while one is in flight. +- After login (`AuthStateChanged` → `SignedIn`), or when the client is + constructed with an already-stored access token, a pending fetch is marked + due and runs on the next `Update`. Sign-out / logout clears the seen-effect + set and wait deadlines. +- This SDK does **not** fire a login scenario event (`loginEvent` / + `player_login` is a web-SDK option only). Trigger login-gated scenarios + yourself with `TriggerAsync`. +- An effect with an already-seen `(runId, nodeId)` is not re-emitted. +- There is no local plan persistence. Pending work lives on the server. + +## `ScenarioService` (`client.Scenario`) ```csharp -public sealed partial class ScenarioService +public sealed class ScenarioService { - // Node events - public event Action? OnNotification; - public event Action? OnStoreOffer; - public event Action? OnLeaderboard; - public event Action? OnConfigChanged; // patches already applied - public event Action? OnWait; - public event Action? OnQuest; - public event Action? OnBattlePass; - public event Action? OnBattlePassLevel; - - // Run lifecycle events - public event Action? OnScenarioCompleted; // run finished all nodes - public event Action? OnScenarioFailed; // run died on unrecoverable error - - public bool IsRunning { get; } // at least one run active - public string? CurrentNodeId { get; } // first active node id across runs - public IReadOnlyList ActiveRuns { get; } // snapshots - - // POST /sdk/v1/scenarios/trigger — body {event}; returns the runs this call started - public Task> TriggerAsync(string eventName, CancellationToken ct = default); - - // Restores persisted runs (needs RudderClientOptions.PlanStateStore), - // reconciles with the server, re-dispatches active nodes. Call once after login. - public Task RestoreAsync(CancellationToken ct = default); - - public void Clear(); // drops all runs and the persisted state - - public void Update(float deltaTime); // completes wait nodes past their deadline - - // Completes the FIRST active node of the FIRST run with the given handle. - public Task RespondAsync(string handle, CancellationToken ct = default); - public void Respond(string handle); // fire-and-forget - - // Adds progress to a counter of the FIRST active node of the FIRST run. - public Task UpdateProgressAsync(string counterKey, long amount, CancellationToken ct = default); + // POST /sdk/v1/scenarios/trigger — body {event}; returned effects are + // ingested into client.Effects. Returns when the HTTP call finishes. + public Task TriggerAsync(string eventName, CancellationToken ct = default); } ``` -Prefer session methods over `RespondAsync`/`UpdateProgressAsync` — the -parameterless variants only address the first active node of the first run. +## `EffectsService` (`client.Effects`) -## Node types and session handles +```csharp +public sealed class EffectsService +{ + public event Action? OnNotification; + public event Action? OnStoreOffer; + public event Action? OnLeaderboard; + public event Action? OnWait; + public event Action? OnQuest; + public event Action? OnBattlePass; + public event Action? OnBattlePassLevel; + public event Action? OnScenarioCompleted; + public event Action? OnScenarioFailed; -| Node `type` | Event | Session completion handles | + public void Update(float deltaTime); // also called by client.Update +} +``` + +Subscribe before login / trigger so you do not miss the first batch. +Handlers are `Action` (not `Func`) — do not use `async` +lambdas on these events. Exceptions thrown inside handlers are logged via +`IRudderLogger` (Error) and do not fail the run. Always subscribe to +`OnScenarioFailed` — otherwise run failures surface only as logs. + +```csharp +client.Effects.OnNotification += n => +{ + ShowToast(n.Title, n.Message); + n.Done(); // always resolve or the run stalls; use DoneAsync from a Task context +}; + +await client.Scenario.TriggerAsync("level_complete"); +``` + +Every effect exposes `RunId`, `ScenarioId`, `NodeId`, `Data` (`JObject`), +and `Get(key, defaultValue)` for reading node data. Completion methods +have an `Async` variant (awaitable) and a fire-and-forget variant. + +## Effect types + +| Node `type` | Event | How the node advances | |---|---|---| -| `notification` | `OnNotification` | `output` (`CompleteAsync`/`Complete`) | -| `store` | `OnStoreOffer` | `onPurchase`, `onDecline` (`Purchase*`/`Decline*`, one-shot via `IsResolved`) | -| `wait` | `OnWait` | auto-completes `onComplete` at `WaitSession.DeadlineUtc` | -| `remote_config_override` | `OnConfigChanged` | auto-completes `output` after applying patches to `RemoteConfig` | -| `quest` | `OnQuest` | `onComplete`, `onFail`; `AddProgressAsync(counterKey, amount)` feeds counters | -| `leaderboard` | `OnLeaderboard` | `onEnd` (`End*`), `onClaim` (`Claim*` / obsolete `RewardClaimed*`) | -| `battlepass` | `OnBattlePass` | `onLevelUp`, `onMaxLevel`, `onComplete`, `onPremiumPurchase` (see battlepass.md) | -| `battlepass_level` | `OnBattlePassLevel` | `onComplete` via `ClaimAsync` — server checks the configured level | +| `notification` | `OnNotification` | `Done*` → handle `output` | +| `store` | `OnStoreOffer` | `Purchase*` → `onPurchase`; `Decline*` → `onDecline` (one-shot via `IsResolved`) | +| `leaderboard` | `OnLeaderboard` | `End*` → `onEnd`; `Claim*` → `onClaim` (one-shot via `IsResolved`) | +| `wait` | `OnWait` | none — server advances at `DeadlineUtc`; SDK polls pending then | +| `quest` | `OnQuest` | `ReportProgress*` via counter; server auto-completes when all objectives are satisfied | +| `battlepass` | `OnBattlePass` | `LevelUp*` → `onLevelUp`; `End*` → `onComplete`; successful `PurchasePremiumAsync` → `onPremiumPurchase` | +| `battlepass_level` | `OnBattlePassLevel` | `Claim*` → `onComplete` (server checks the configured level) | + +`remote_config_override` is applied server-side and is **not** delivered to +the client. There is no `OnConfigChanged`. Reload `client.RemoteConfig` to +observe patched values. An unsupported node type fails the run (`OnScenarioFailed`) instead of -stalling. All sessions expose `Context` (`ScenarioNodeContext`), `Id` (node -id) and `Get(key, defaultValue)` for reading node data. +stalling. -## ScenarioNodeContext +### `NotificationEffect` ```csharp -public PlanRun Run { get; } -public ExecutionPlanNode Node { get; } -public string RunId / PlanId / ScenarioId / NodeId / Type { get; } -public JObject Data { get; } -public T Get(string key, T defaultValue = default!); -public T Get(); // whole payload -public Dictionary AsObjectDictionary(); +public string Title { get; } // node data "title" +public string Message { get; } // node data "message" + +public Task DoneAsync(CancellationToken ct = default); // handle "output" +public void Done(); ``` -## PlanRun and failure event +### `StoreOfferEffect` + +Posts the scenario callback only — it does **not** call `Stores.PurchaseAsync`. +Purchase through `client.Stores` first if the offer should actually be bought, +then complete the effect. ```csharp -public sealed class PlanRun +public string StoreSlug { get; } // node data "storeSlug" +public string? Message { get; } // node data "message", null if empty +public bool IsResolved { get; } // true after Purchase* or Decline* once + +public Task PurchaseAsync(CancellationToken ct = default); // "onPurchase" +public void Purchase(); +public Task DeclineAsync(CancellationToken ct = default); // "onDecline" +public void Decline(); +``` + +### `LeaderboardEffect` + +```csharp +public bool IsResolved { get; } + +public Task EndAsync(CancellationToken ct = default); // "onEnd" +public void End(); +public Task ClaimAsync(CancellationToken ct = default); // "onClaim" — server matches live rank to a place +public void Claim(); +``` + +### `WaitEffect` + +```csharp +public DateTimeOffset DeadlineUtc { get; } +``` + +No completion method. When `Clock.UtcNow >= DeadlineUtc`, the next `Update` +fetches pending so the server-advanced successor can be dispatched. + +### `QuestEffect` + +Scenario quest node — distinct from global `client.Quests`. + +```csharp +public string Name { get; } // node data "name" +public IReadOnlyList Objectives { get; } // node data "objectives" + +// POST /sdk/v1/scenarios/counter {scenarioId, nodeId, runId, counterKey, amount} +public Task ReportProgressAsync(string objectiveId, long amount = 1, CancellationToken ct = default); +public void ReportProgress(string objectiveId, long amount = 1); +``` + +The node auto-completes server-side once every objective is satisfied; a +completed counter response may carry the next `PendingEffect`. + +### `BattlePassEffect` + +Bound to this node's `ScenarioId` / `NodeId` / `RunId` — prefer these over +calling `client.BattlePass` by hand. See also reference/battlepass.md. + +```csharp +public Task GetProgressAsync(CancellationToken ct = default); +public Task AddXpAsync(string source, long amount, CancellationToken ct = default); +public Task ClaimRewardAsync(int level, string track, CancellationToken ct = default); + +// Purchases premium, then posts "onPremiumPurchase" when response.Success == true. +public Task PurchasePremiumAsync(CancellationToken ct = default); + +public Task LevelUpAsync(CancellationToken ct = default); public void LevelUp(); // "onLevelUp" +public Task EndAsync(CancellationToken ct = default); public void End(); // "onComplete" +``` + +### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier) + +```csharp +public int Level { get; } // node data "levelNumber" + +public Task ClaimAsync(CancellationToken ct = default); // "onComplete" +public void Claim(); +``` + +The server accepts `onComplete` only once the player has reached the node's +configured level. + +### Run lifecycle effects + +```csharp +public sealed class ScenarioCompletedEffect { public string RunId { get; } - public string PlanId { get; } public string ScenarioId { get; } - public string UserId { get; } - public IReadOnlyList ActiveNodeIds { get; } - public ExecutionPlan Plan { get; } } -public sealed class ScenarioFailedEvent +public sealed class ScenarioFailedEffect { - public PlanRun Run { get; } + public string RunId { get; } + public string ScenarioId { get; } public string NodeId { get; } public Exception Exception { get; } } ``` -## ExecutionPlan (generated, `RudderSdk.Core.Models`) +`OnScenarioCompleted` fires when a callback / completed-counter response has +no next effect (the run finished all its nodes). + +## Reliability + +- Completion methods POST `/sdk/v1/scenarios/callback` with + `{scenarioId, runId, nodeId, handle}` using the handles `output`, + `onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`, + `onPremiumPurchase`. A non-null `effect` in the response is ingested as + the next node; a null/missing effect completes the run. +- `unknown_run`, `run_expired` (`RudderErrorCodes`), and HTTP 404 + (`RudderNotFoundException`) on a callback or counter call **drop that + run** (forget its seen keys and wait deadlines) and fire + `OnScenarioFailed`. +- Other callback errors propagate to the `*Async` caller. Counter-update + failures that are not a definitive rejection are logged (Warning) and the + effect stays active. +- Pending-fetch failures are logged (Warning); the 30s heartbeat continues. +- Server scenario errors also include `run_not_active`, `node_not_active`, + `scenario_not_active`, `early_completion`, `objectives_incomplete`, + `level_not_reached`, `forbidden` (`RudderErrorCodes`). + +## Wire DTOs (`RudderSdk.Core.Models.Scenarios`) ```csharp -public class ExecutionPlan +public class PendingEffect { - public List? BoundaryNodes { get; set; } - public JToken? Context { get; set; } - public List? Edges { get; set; } - public List? Nodes { get; set; } - public string? PlanId { get; set; } + public JToken Data { get; set; } + public string NodeId { get; set; } + public string RunId { get; set; } + public string ScenarioId { get; set; } + public string Type { get; set; } + public DateTimeOffset? WaitDeadline { get; set; } +} + +public class TriggerScenarioRequest { public string? Event { get; set; } } +public class TriggerScenarioResponse { public List Effects { get; set; } } +public class ListPendingScenarioEffectsResponse { public List Effects { get; set; } } + +public class HandleScenarioCallbackRequest +{ + public string? Handle { get; set; } + public string? NodeId { get; set; } public string? RunId { get; set; } public string? ScenarioId { get; set; } - public string? StartNodeId { get; set; } - public string? UserId { get; set; } +} +public class HandleScenarioCallbackResponse { public JToken? Effect { get; set; } } + +public class UpdateScenarioCounterRequest +{ + public long? Amount { get; set; } + public string? CounterKey { get; set; } + public string? NodeId { get; set; } + public string? RunId { get; set; } + public string? ScenarioId { get; set; } +} +public class UpdateScenarioCounterResponse +{ + public bool? Completed { get; set; } + public JToken? Effect { get; set; } } ``` - -`ExecutionPlanNode`: `Id`, `Type`, `Data` (`JToken`). `PlanEdge`: `Id`, -`Source`, `SourceHandle`, `Target`, `TargetHandle`. `BoundaryNode`: -`CallbackUrl`, `Enforcement`, `EnteredAt`, `NodeId`, `SourceHandle`, -`SourceNodeId`, `WaitDeadline`. - -## Runtime semantics - -- Boundaries: crossing a handle that has a server boundary calls - `POST /sdk/v1/scenarios/callback` first; the returned continuation plan - replaces/starts the run before the handle is marked complete. On failure the - runtime reconciles via `POST /sdk/v1/scenarios/run`; `unknown_run` / - `expired` statuses drop the run. Transient failures - (`TransientBoundaryException`: timeouts, `RudderNetworkException`) leave the - node active for retry; terminal errors fail the run. `rank_not_eligible` on - leaderboard Claim leaves the node active (retry Claim or End). -- Counter updates (`POST /sdk/v1/scenarios/counter`) never fail the run; when - the server reports the objective completed, the node crosses `onComplete` - automatically. -- Dedup/idempotency: a plan with an already-running `RunId` is not restarted; - completed (node, handle) pairs are not re-crossed. -- Wait deadlines prefer the server-stamped `BoundaryNode.WaitDeadline` over - locally computed `duration`+`unit` node data (`days`/`hours`/`minutes`/ - `seconds` and abbreviations). -- Persistence: with `IPlanStateStore` configured, every state change is - serialized into `State`; call `RestoreAsync` once after startup/login.