Docs: skills/README/CHANGELOG for effects client rewrite

- scenarios.md rewritten for server-side execution + effects API
- realtime.md deleted; realtime/planstore references purged from docs
- CHANGELOG Unreleased: major bump + migration table
This commit is contained in:
edmand46
2026-09-04 14:18:05 +03:00
parent 6c590ca520
commit 1029f08fba
11 changed files with 346 additions and 245 deletions
+46
View File
@@ -1,5 +1,51 @@
# Changelog # 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 ## 0.4.0
- Generated DTOs live under `Models/` (`RudderSdk.Core.Models`). Wire fields - Generated DTOs live under `Models/` (`RudderSdk.Core.Models`). Wire fields
+16 -9
View File
@@ -2,7 +2,7 @@
.NET client SDK for the Rudder LiveOps platform: authentication, player .NET client SDK for the Rudder LiveOps platform: authentication, player
profile, stores, battle pass, quests, leaderboards, inventory, remote config, 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). - Target framework: `netstandard2.1` (works in Unity, .NET, Xamarin).
- JSON: Newtonsoft.Json. - JSON: Newtonsoft.Json.
@@ -36,7 +36,7 @@ Console.WriteLine(profile.Player.Id);
// Buy an offer (idempotency key is generated when omitted). // Buy an offer (idempotency key is generated when omitted).
var purchase = await client.Stores.PurchaseAsync("main-store", "offer-1"); 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); client.Update(deltaTime);
``` ```
@@ -58,15 +58,15 @@ stack.
| `Inventory` | `InventoryService` | `GetAsync` | | `Inventory` | `InventoryService` | `GetAsync` |
| `Leaderboards` | `LeaderboardsService` | `FindBySlug(slug)` → handle: `SubmitAsync`, `ListAsync` | | `Leaderboards` | `LeaderboardsService` | `FindBySlug(slug)` → handle: `SubmitAsync`, `ListAsync` |
| `RemoteConfig` | `RemoteConfigService` | `LoadAsync`, `Get<T>`, `GetAsync<T>` | | `RemoteConfig` | `RemoteConfigService` | `LoadAsync`, `Get<T>`, `GetAsync<T>` |
| `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` | | `Storage` | `StorageService` | `GetAsync`, `ListAllAsync`, `SaveAsync`, `DeleteAsync` |
| `Realtime` | `RealtimeService` | `ConnectAsync`, `DisconnectAsync` |
## Quests ## Quests
`client.Quests` covers the player's global quests — list with per-objective `client.Quests` covers the player's global quests — list with per-objective
progress, claim, and metric reports. These are distinct from scenario quest 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 ```csharp
var quests = await client.Quests.ListAsync(); var quests = await client.Quests.ListAsync();
@@ -108,11 +108,18 @@ All of them carry `StatusCode` (`int`), the machine-readable `Code`
## Scenario effects ## 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`, `OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnWait`, `OnQuest`,
`OnWait`, `OnQuest`, `OnBattlePass`, `OnBattlePassLevel`, `OnBattlePass`, `OnBattlePassLevel`, `OnScenarioCompleted`,
`OnScenarioCompleted`, `OnScenarioFailed`. `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 ## Tests
+15 -15
View File
@@ -1,6 +1,6 @@
--- ---
name: rudder-csharp-sdk 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) # Rudder C# SDK (Rudder.Core)
@@ -43,8 +43,8 @@ full options surface and the abstraction interfaces):
- `DeviceIdProvider``GuidDeviceIdProvider` (new GUID per run — plug a - `DeviceIdProvider``GuidDeviceIdProvider` (new GUID per run — plug a
persistent `IDeviceIdProvider` for production) persistent `IDeviceIdProvider` for production)
`RealtimeUrl` + `RealtimeTransportFactory` are required only for `Clock` defaults to `DateTimeOffset.UtcNow`; override in tests. There is no
`client.Realtime`; there is no default websocket transport in Rudder.Core. realtime websocket client in Rudder.Core.
## Request pipeline (applies to every service) ## Request pipeline (applies to every service)
@@ -58,7 +58,7 @@ full options surface and the abstraction interfaces):
failures throw `RudderNetworkException` (`StatusCode == 0`). See failures throw `RudderNetworkException` (`StatusCode == 0`). See
reference/errors.md. reference/errors.md.
- `client.Update(deltaTime)` must be called every frame — it pumps the - `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 ## Capability map
@@ -74,19 +74,19 @@ full options surface and the abstraction interfaces):
| Stores and offer purchases | `client.Stores` | reference/stores.md | | Stores and offer purchases | `client.Stores` | reference/stores.md |
| Leaderboards | `client.Leaderboards.FindBySlug(slug)` | reference/leaderboards.md | | Leaderboards | `client.Leaderboards.FindBySlug(slug)` | reference/leaderboards.md |
| Inventory | `client.Inventory` | reference/inventory.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 | | Quests (global) | `client.Quests` | reference/quests.md |
| Scenarios (event-driven plans, node sessions) | `client.Scenario` | reference/scenarios.md | | Scenario trigger | `client.Scenario` | reference/scenarios.md |
| Realtime websocket | `client.Realtime` | reference/realtime.md | | Scenario effects | `client.Effects.On*` | reference/scenarios.md |
Important cross-cutting facts: Important cross-cutting facts:
- Battle pass state is tied to a scenario battle-pass node: every - Battle pass state is tied to a scenario battle-pass node: every
`BattlePassService` call carries `ScenarioId`/`NodeId` (mutations also `BattlePassService` call carries `ScenarioId`/`NodeId` (mutations also
`RunId`). During a scenario run, `BattlePassSession` supplies them — prefer `RunId`). During a scenario run, `BattlePassEffect` supplies them — prefer
the session API inside a run. the effect API inside a run.
- Global quests (`client.Quests`) are distinct from scenario quest nodes - 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 - Mutations that take an idempotency key auto-generate a random GUID when the
key is omitted (`Stores.PurchaseAsync`, `ProjectStorage.SaveAsync`, key is omitted (`Stores.PurchaseAsync`, `ProjectStorage.SaveAsync`,
`BattlePass.PurchasePremiumAsync`). Pass a stable key to make retries safe. `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) ## Relationship to the Unity package (rudder.sdk)
`rudder.sdk` (UPM, liveops-unity-sdk) wraps `Rudder.Core.dll` with Unity `rudder.sdk` (UPM, liveops-unity-sdk) wraps `Rudder.Core.dll` with Unity
adapters: `UnityWebRequest` transport, `PlayerPrefs` token store, a websocket adapters: `UnityWebRequest` transport, `PlayerPrefs` token store, and a
realtime transport, and a `Rudder` bootstrap component `Rudder` bootstrap component (`RudderSdk.Unity` namespace). The domain
(`RudderSdk.Unity` namespace). The domain services and models are the same services and models are the same ones documented here — for Unity games,
ones documented here — for Unity games, consume them through the package's consume them through the package's adapters instead of wiring
adapters instead of wiring `RudderClientOptions` by hand. Unity requires `RudderClientOptions` by hand. Unity requires
`com.unity.nuget.newtonsoft-json` since Rudder.Core serializes with `com.unity.nuget.newtonsoft-json` since Rudder.Core serializes with
Newtonsoft.Json. Newtonsoft.Json.
+5 -1
View File
@@ -48,7 +48,9 @@ public class LoginViaDeviceResponse / LoginViaCustomResponse
## Behavior notes ## Behavior notes
- A successful login stores the token pair in the configured `ITokenStore` - 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 - If the server returns an incomplete session (missing tokens), login throws
`InvalidOperationException`. `InvalidOperationException`.
- Token refresh is automatic: any API call that gets a 401 triggers one - 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 transparent retry. A failed refresh clears the tokens and fires
`SignedOut`; the original call then throws `RudderAuthException`. `SignedOut`; the original call then throws `RudderAuthException`.
- `RefreshAsync()` shares the same single-flight path. - `RefreshAsync()` shares the same single-flight path.
- Logout / `SignedOut` clears the effects client's seen-effect set and wait
deadlines.
@@ -1,13 +1,13 @@
# Battle pass — `client.BattlePass` (`BattlePassService`) # Battle pass — `client.BattlePass` (`BattlePassService`)
Source: `Services/BattlePassService.cs`, Source: `Services/BattlePassService.cs`,
`Services/Scenarios/Sessions/BattlePassSession.cs`, `Services/Effects/BattlePassEffect.cs`,
`Services/Scenarios/Sessions/BattlePassLevelSession.cs`, `BattlePass/*.cs` `Services/Effects/BattlePassLevelEffect.cs`, `BattlePass/*.cs`
(generated DTOs). (generated DTOs).
Battle pass state is tied to a scenario battle-pass node, so every call Battle pass state is tied to a scenario battle-pass node, so every call
carries `ScenarioId`/`NodeId` (mutations also `RunId`). Inside a scenario run, 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. ids — prefer it over calling the service directly.
## BattlePassService ## BattlePassService
@@ -88,7 +88,6 @@ public class AddBattlePassXpResponse
public int? Level { get; set; } // "level" public int? Level { get; set; } // "level"
public bool? LeveledUp { get; set; } // "leveledUp" public bool? LeveledUp { get; set; } // "leveledUp"
public bool? MaxLevel { get; set; } // "maxLevel" public bool? MaxLevel { get; set; } // "maxLevel"
public ExecutionPlan? Plan { get; set; } // "plan" — scenario continuation
public long? Xp { get; set; } // "xp" public long? Xp { get; set; } // "xp"
} }
@@ -103,7 +102,6 @@ public class ClaimBattlePassRewardResponse
public class PurchaseBattlePassPremiumResponse public class PurchaseBattlePassPremiumResponse
{ {
public string? Error { get; set; } // "error" public string? Error { get; set; } // "error"
public ExecutionPlan? Plan { get; set; } // "plan"
public bool? Success { get; set; } // "success" public bool? Success { get; set; } // "success"
} }
``` ```
@@ -112,36 +110,35 @@ public class PurchaseBattlePassPremiumResponse
`ItemId` (string?). Responses carry business errors in `Error` — check `ItemId` (string?). Responses carry business errors in `Error` — check
`Success`. Claim failures surface codes such as `RudderErrorCodes.LevelNotReached`. `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 ```csharp
public ScenarioNodeContext Context { get; } public string RunId { get; }
public string Id { get; } // node id public string ScenarioId { get; }
public string NodeId { get; }
public T Get<T>(string key, T defaultValue = default!); public T Get<T>(string key, T defaultValue = default!);
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken ct = default); public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken ct = default);
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken ct = default); public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken ct = default);
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken ct = default); public Task<ClaimBattlePassRewardResponse> 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<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken ct = default); public Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken ct = default);
// Boundary crossings (async + fire-and-forget variants):
public Task LevelUpAsync(CancellationToken ct = default); public void LevelUp(); // onLevelUp public Task LevelUpAsync(CancellationToken ct = default); public void LevelUp(); // onLevelUp
public Task MaxLevelAsync(CancellationToken ct = default); public void MaxLevel(); // onMaxLevel public Task EndAsync(CancellationToken ct = default); public void End(); // onComplete
public Task CompleteAsync(CancellationToken ct = default); public void Complete(); // 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 ```csharp
public int Level { get; } // reads the "levelNumber" node data key public int Level { get; } // reads the "levelNumber" node data key
public Task ClaimAsync(CancellationToken ct = default); public void Claim(); 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. has reached the node's configured level.
+4 -30
View File
@@ -19,14 +19,13 @@ public sealed class RudderClient
public BattlePassService BattlePass { get; } public BattlePassService BattlePass { get; }
public QuestsService Quests { get; } public QuestsService Quests { get; }
public ScenarioService Scenario { get; } public ScenarioService Scenario { get; }
public RealtimeService Realtime { get; } public EffectsService Effects { get; }
public string ProjectKey { get; } public string ProjectKey { get; }
public string? RealtimeUrl { get; } public IClock Clock { get; } // Options.Clock, else DateTimeOffset.UtcNow
public IClock Clock { get; } // defaults to DateTimeOffset.UtcNow
public RudderClient(RudderClientOptions options); 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 sealed class RudderClientOptions
{ {
public string? BaseUrl { get; set; } // required public string? BaseUrl { get; set; } // required
public string? RealtimeUrl { get; set; } // required only for Realtime
public string? ProjectKey { get; set; } // required public string? ProjectKey { get; set; } // required
public IRudderTransport? Transport { get; set; } // default HttpClientTransport public IRudderTransport? Transport { get; set; } // default HttpClientTransport
public ITokenStore? TokenStore { get; set; } // default InMemoryTokenStore public ITokenStore? TokenStore { get; set; } // default InMemoryTokenStore
public IDeviceIdProvider? DeviceIdProvider { get; set; }// default GuidDeviceIdProvider public IDeviceIdProvider? DeviceIdProvider { get; set; }// default GuidDeviceIdProvider
public IRudderLogger? Logger { get; set; } // null = silent public IRudderLogger? Logger { get; set; } // null = silent
public IClock? Clock { get; set; } // override in tests public IClock? Clock { get; set; } // time source for the effects client; 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
} }
``` ```
@@ -89,27 +84,6 @@ public interface IRudderLogger { void Log(RudderLogLevel level, string message);
public enum RudderLogLevel { Debug, Info, Warning, Error } public enum RudderLogLevel { Debug, Info, Warning, Error }
public interface IClock { DateTimeOffset UtcNow { get; } } 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<ArraySegment<byte>> Received;
event Action<Exception> Error;
bool IsConnected { get; }
Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default);
Task SendAsync(ArraySegment<byte> 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` Production guidance from the sources: provide a durable `ITokenStore`
+8 -9
View File
@@ -1,8 +1,7 @@
# Errors, exceptions, error codes # Errors, exceptions, error codes
Source: `Exceptions/*.cs`, `Models/RudderErrorCodes.cs`, Source: `Exceptions/*.cs`, `Models/RudderErrorCodes.cs`,
`Models/ErrorResponse.cs`, `HttpClientTransport.cs` (status mapping), `Models/ErrorResponse.cs`, `HttpClientTransport.cs` (status mapping).
`Services/Scenarios/ScenarioService.Runtime.cs` (`TransientBoundaryException`).
## Exception hierarchy ## Exception hierarchy
@@ -59,11 +58,11 @@ UnknownRun = "unknown_run"
Match against `RudderApiException.Code`. Match against `RudderApiException.Code`.
## Scenario boundary errors ## Scenario callback / counter errors
`TransientBoundaryException` (public sealed, wraps the transport error) is On `POST /sdk/v1/scenarios/callback` and `POST /sdk/v1/scenarios/counter`,
used inside the scenario runtime for transient boundary-callback failures `unknown_run`, `run_expired`, and HTTP 404 drop that run and fire
(timeout / `RudderNetworkException`): the run is NOT advanced, the node stays `Effects.OnScenarioFailed`. Other callback errors propagate to the caller.
active and the handle stays pending for retry. A failed scenario counter A failed counter update that is not one of those definitive rejections is
update never fails the run — it is logged via `IRudderLogger` and the node logged via `IRudderLogger` and the effect stays active. Details:
stays active. reference/scenarios.md.
+1 -1
View File
@@ -4,7 +4,7 @@ Source: `Services/QuestsService.cs`, `Services/QuestMetrics.cs`,
`Quests/*.cs` (generated DTOs). `Quests/*.cs` (generated DTOs).
Global quests (list + claim + metric reports). Distinct from scenario quest 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. scenarios.md.
## Methods ## Methods
@@ -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<RealtimeSession> 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<RealtimeSession> 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<Exception>? Error;
public event Action<ArraySegment<byte>>? 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.
@@ -48,5 +48,6 @@ public class RemoteConfig
} }
``` ```
Scenario `remote_config_override` nodes apply patches directly into this Scenario `remote_config_override` nodes are applied server-side and do not
cache before `Scenario.OnConfigChanged` fires (see scenarios.md). reach the client. Call `LoadAsync` (or `GetAsync`) to observe the patched
value via `Get<T>`.
+234 -110
View File
@@ -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`, Source: `Services/ScenarioService.cs`, `Services/EffectsService.cs`,
`Services/Scenarios/Models/*.cs`, `Services/Scenarios/Sessions/*.cs`, `Services/Effects/*.cs`, `Scenarios/*.cs` (generated DTOs).
`Scenarios/*.cs` (generated DTOs), `Models/*.cs`.
Scenario runtime for server-issued execution plans. `TriggerAsync(eventName)` Scenarios are server-authored node graphs (configured in the dashboard) that
starts plans; active nodes surface as typed sessions through the `On*` events run per player. The **server owns the graph**. This SDK is a thin effects
and are advanced by completing those sessions (which crosses server-validated client: it sends trigger events, polls pending effects, raises typed
boundaries). Requires `Update(deltaTime)` every frame for wait-node deadlines. `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 ```csharp
public sealed partial class ScenarioService public sealed class ScenarioService
{ {
// Node events // POST /sdk/v1/scenarios/trigger — body {event}; returned effects are
public event Action<NotificationSession>? OnNotification; // ingested into client.Effects. Returns when the HTTP call finishes.
public event Action<StoreOfferSession>? OnStoreOffer; public Task TriggerAsync(string eventName, CancellationToken ct = default);
public event Action<LeaderboardSession>? OnLeaderboard;
public event Action<ConfigChangedSession>? OnConfigChanged; // patches already applied
public event Action<WaitSession>? OnWait;
public event Action<QuestSession>? OnQuest;
public event Action<BattlePassSession>? OnBattlePass;
public event Action<BattlePassLevelSession>? OnBattlePassLevel;
// Run lifecycle events
public event Action<PlanRun>? OnScenarioCompleted; // run finished all nodes
public event Action<ScenarioFailedEvent>? 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<PlanRun> ActiveRuns { get; } // snapshots
// POST /sdk/v1/scenarios/trigger — body {event}; returns the runs this call started
public Task<IReadOnlyList<PlanRun>> 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);
} }
``` ```
Prefer session methods over `RespondAsync`/`UpdateProgressAsync` — the ## `EffectsService` (`client.Effects`)
parameterless variants only address the first active node of the first run.
## Node types and session handles ```csharp
public sealed class EffectsService
{
public event Action<NotificationEffect>? OnNotification;
public event Action<StoreOfferEffect>? OnStoreOffer;
public event Action<LeaderboardEffect>? OnLeaderboard;
public event Action<WaitEffect>? OnWait;
public event Action<QuestEffect>? OnQuest;
public event Action<BattlePassEffect>? OnBattlePass;
public event Action<BattlePassLevelEffect>? OnBattlePassLevel;
public event Action<ScenarioCompletedEffect>? OnScenarioCompleted;
public event Action<ScenarioFailedEffect>? 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<T>` (not `Func<T, Task>`) — 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<T>(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`) | | `notification` | `OnNotification` | `Done*` → handle `output` |
| `store` | `OnStoreOffer` | `onPurchase`, `onDecline` (`Purchase*`/`Decline*`, one-shot via `IsResolved`) | | `store` | `OnStoreOffer` | `Purchase*` `onPurchase`; `Decline*``onDecline` (one-shot via `IsResolved`) |
| `wait` | `OnWait` | auto-completes `onComplete` at `WaitSession.DeadlineUtc` | | `leaderboard` | `OnLeaderboard` | `End*``onEnd`; `Claim*` `onClaim` (one-shot via `IsResolved`) |
| `remote_config_override` | `OnConfigChanged` | auto-completes `output` after applying patches to `RemoteConfig` | | `wait` | `OnWait` | none — server advances at `DeadlineUtc`; SDK polls pending then |
| `quest` | `OnQuest` | `onComplete`, `onFail`; `AddProgressAsync(counterKey, amount)` feeds counters | | `quest` | `OnQuest` | `ReportProgress*` via counter; server auto-completes when all objectives are satisfied |
| `leaderboard` | `OnLeaderboard` | `onEnd` (`End*`), `onClaim` (`Claim*` / obsolete `RewardClaimed*`) | | `battlepass` | `OnBattlePass` | `LevelUp*``onLevelUp`; `End*` `onComplete`; successful `PurchasePremiumAsync``onPremiumPurchase` |
| `battlepass` | `OnBattlePass` | `onLevelUp`, `onMaxLevel`, `onComplete`, `onPremiumPurchase` (see battlepass.md) | | `battlepass_level` | `OnBattlePassLevel` | `Claim*``onComplete` (server checks the configured level) |
| `battlepass_level` | `OnBattlePassLevel` | `onComplete` via `ClaimAsync` — 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 An unsupported node type fails the run (`OnScenarioFailed`) instead of
stalling. All sessions expose `Context` (`ScenarioNodeContext`), `Id` (node stalling.
id) and `Get<T>(key, defaultValue)` for reading node data.
## ScenarioNodeContext ### `NotificationEffect`
```csharp ```csharp
public PlanRun Run { get; } public string Title { get; } // node data "title"
public ExecutionPlanNode Node { get; } public string Message { get; } // node data "message"
public string RunId / PlanId / ScenarioId / NodeId / Type { get; }
public JObject Data { get; } public Task DoneAsync(CancellationToken ct = default); // handle "output"
public T Get<T>(string key, T defaultValue = default!); public void Done();
public T Get<T>(); // whole payload
public Dictionary<string, object> AsObjectDictionary();
``` ```
## 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 ```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<JObject> 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<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken ct = default);
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken ct = default);
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken ct = default);
// Purchases premium, then posts "onPremiumPurchase" when response.Success == true.
public Task<PurchaseBattlePassPremiumResponse> 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 RunId { get; }
public string PlanId { get; }
public string ScenarioId { get; } public string ScenarioId { get; }
public string UserId { get; }
public IReadOnlyList<string> 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 string NodeId { get; }
public Exception Exception { 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 ```csharp
public class ExecutionPlan public class PendingEffect
{ {
public List<BoundaryNode>? BoundaryNodes { get; set; } public JToken Data { get; set; }
public JToken? Context { get; set; } public string NodeId { get; set; }
public List<PlanEdge>? Edges { get; set; } public string RunId { get; set; }
public List<ExecutionPlanNode>? Nodes { get; set; } public string ScenarioId { get; set; }
public string? PlanId { 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<PendingEffect> Effects { get; set; } }
public class ListPendingScenarioEffectsResponse { public List<PendingEffect> Effects { get; set; } }
public class HandleScenarioCallbackRequest
{
public string? Handle { get; set; }
public string? NodeId { get; set; }
public string? RunId { get; set; } public string? RunId { get; set; }
public string? ScenarioId { 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.