6 Commits

Author SHA1 Message Date
edmand46 372ab7cf7e 2.0.0: slug-based quests, scenarios and offers; environment carried by the token; regenerated models
CI / check (push) Successful in 25s
CI / publish (push) Has been skipped
Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
2026-09-06 22:25:32 +03:00
edmand46 62025592ba Effects client reconciles pending effects: drop stale wait deadlines and seen keys, emit completion for vanished runs; run_not_active is terminal
CI / check (push) Successful in 25s
CI / publish (push) Has been skipped
2026-09-06 09:53:45 +03:00
edmand46 ef5624ee4e Release 1.0.0
CI / publish (push) Failing after 19s
CI / check (push) Successful in 28s
2026-09-04 15:37:00 +03:00
edmand46 1029f08fba 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
2026-09-04 14:18:05 +03:00
edmand46 6c590ca520 Server-side scenario execution: effects client replaces local engine
- client.Effects: typed effects + completion methods posting scenario callbacks
- client.Scenario reduced to TriggerAsync
- pending polling via Update() pump: 30s heartbeat + wait-deadline checks
- local engine, plan persistence (IPlanStateStore) and resume removed
- RealtimeService and realtime transports removed (relay never deployed)
- dead IPlanScheduler leftover removed
- models regenerated from openapi (ExecutionPlan/BoundaryNode gone, PendingEffect added)
2026-09-04 14:03:41 +03:00
edmand46 05dd30f31d Regenerate player models with avatarUrl
CI / check (push) Successful in 40s
CI / publish (push) Has been skipped
2026-09-04 12:40:54 +03:00
78 changed files with 1802 additions and 2335 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ using System;
namespace RudderSdk.Core.Abstractions; namespace RudderSdk.Core.Abstractions;
/// <summary>Time source used by the scenario runtime; override in tests.</summary> /// <summary>Time source used by the effects client; override in tests.</summary>
public interface IClock public interface IClock
{ {
/// <summary>Current UTC time.</summary> /// <summary>Current UTC time.</summary>
-12
View File
@@ -1,12 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core.Abstractions;
/// <summary>Optional scheduler abstraction for delayed plan work.</summary>
public interface IPlanScheduler
{
/// <summary>Completes after the given delay.</summary>
Task ScheduleAsync(TimeSpan delay, CancellationToken cancellationToken = default);
}
-11
View File
@@ -1,11 +0,0 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Persists serialized scenario-run state between app launches.
/// Null by default (no persistence).
/// </summary>
public interface IPlanStateStore
{
/// <summary>Serialized state blob, or null when empty.</summary>
string? State { get; set; }
}
-36
View File
@@ -1,36 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Low-level realtime (websocket) transport. There is no default implementation;
/// provide one through <see cref="IRealtimeTransportFactory"/> (Unity ships its own).
/// </summary>
public interface IRealtimeTransport
{
/// <summary>Raised when the connection closes.</summary>
event Action Closed;
/// <summary>Raised for every incoming message.</summary>
event Action<ArraySegment<byte>> Received;
/// <summary>Raised on transport errors.</summary>
event Action<Exception> Error;
/// <summary>True while the connection is open.</summary>
bool IsConnected { get; }
/// <summary>Opens the connection, giving up after <paramref name="timeout"/>.</summary>
Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default);
/// <summary>Sends one message.</summary>
Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default);
/// <summary>Closes the connection.</summary>
Task CloseAsync(CancellationToken cancellationToken = default);
/// <summary>Pumps time-dependent logic; call every frame.</summary>
void Update(float deltaTime);
}
@@ -1,8 +0,0 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>Creates realtime transports on demand (one per connection).</summary>
public interface IRealtimeTransportFactory
{
/// <summary>Creates a new, unconnected transport.</summary>
IRealtimeTransport Create();
}
+2 -2
View File
@@ -14,8 +14,8 @@ public class AddBattlePassXpRequest
[JsonProperty("runId")] [JsonProperty("runId")]
public string? RunId { get; set; } public string? RunId { get; set; }
[JsonProperty("scenarioId")] [JsonProperty("scenarioSlug")]
public string? ScenarioId { get; set; } public string? ScenarioSlug { get; set; }
[JsonProperty("source")] [JsonProperty("source")]
public string? Source { get; set; } public string? Source { get; set; }
-4
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json; using Newtonsoft.Json;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.BattlePass; namespace RudderSdk.Core.Models.BattlePass;
@@ -15,9 +14,6 @@ public class AddBattlePassXpResponse
[JsonProperty("maxLevel")] [JsonProperty("maxLevel")]
public bool? MaxLevel { get; set; } public bool? MaxLevel { get; set; }
[JsonProperty("plan")]
public ExecutionPlan? Plan { get; set; }
[JsonProperty("xp")] [JsonProperty("xp")]
public long? Xp { get; set; } public long? Xp { get; set; }
+2 -2
View File
@@ -14,8 +14,8 @@ public class ClaimBattlePassRewardRequest
[JsonProperty("runId")] [JsonProperty("runId")]
public string? RunId { get; set; } public string? RunId { get; set; }
[JsonProperty("scenarioId")] [JsonProperty("scenarioSlug")]
public string? ScenarioId { get; set; } public string? ScenarioSlug { get; set; }
[JsonProperty("track")] [JsonProperty("track")]
public string? Track { get; set; } public string? Track { get; set; }
+2 -2
View File
@@ -8,7 +8,7 @@ public class GetBattlePassProgressRequest
[JsonProperty("nodeId")] [JsonProperty("nodeId")]
public string? NodeId { get; set; } public string? NodeId { get; set; }
[JsonProperty("scenarioId")] [JsonProperty("scenarioSlug")]
public string? ScenarioId { get; set; } public string? ScenarioSlug { get; set; }
} }
@@ -14,7 +14,7 @@ public class PurchaseBattlePassPremiumRequest
[JsonProperty("runId")] [JsonProperty("runId")]
public string? RunId { get; set; } public string? RunId { get; set; }
[JsonProperty("scenarioId")] [JsonProperty("scenarioSlug")]
public string? ScenarioId { get; set; } public string? ScenarioSlug { get; set; }
} }
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json; using Newtonsoft.Json;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.BattlePass; namespace RudderSdk.Core.Models.BattlePass;
@@ -9,9 +8,6 @@ public class PurchaseBattlePassPremiumResponse
[JsonProperty("error")] [JsonProperty("error")]
public string? Error { get; set; } public string? Error { get; set; }
[JsonProperty("plan")]
public ExecutionPlan? Plan { get; set; }
[JsonProperty("success")] [JsonProperty("success")]
public bool? Success { get; set; } public bool? Success { get; set; }
+77
View File
@@ -1,5 +1,82 @@
# Changelog # Changelog
## 2.0.0
Breaking change — major bump, required by the backend environments release.
Every project now has exactly two environments, `staging` and `prod`, and the
SDK key you configure decides which one the player belongs to. The environment
never appears in the client API: it is resolved at login and carried inside the
access and refresh tokens. Tokens issued before this release have no
environment claim and are rejected with 401, so the first call after the
backend upgrade refreshes, fails, clears the stored tokens and raises
`AuthService.AuthStateChanged` with `RudderAuthState.SignedOut`. Log the player
in again with `Auth.LoginWithDeviceAsync` / `LoginWithCustomAsync`.
Quests, scenarios and offers are addressed by their slug instead of their id,
because ids differ between staging and prod while slugs are stable. Renamed
accordingly: `Quest.Id` is now `Quest.Slug`, `QuestsService.ClaimAsync` takes a
quest slug, `ReportProgressAsync` returns the slugs of the completed quests
(`ReportQuestProgressResponse.CompletedQuestSlugs`), `StoresService.PurchaseAsync`
takes an offer slug and `Offer` carries a `Slug`, `QuestMetrics.PurchaseOffer`
builds its metric from the offer slug, and every scenario-scoped type exposes
`ScenarioSlug` instead of `ScenarioId``PendingEffect`, the effect objects,
`ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle pass requests and
`BattlePassService.GetProgressAsync`. Leaderboards, items and stores already
used slugs and are unchanged.
Also shipped here, previously committed but never published: the effects client
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run that
disappears server-side (finished elsewhere, expired after a promote) now emits
`OnScenarioCompleted` instead of lingering; and `run_not_active` joins
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
emits `OnScenarioFailed`.
## 1.0.0
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
-30
View File
@@ -1,30 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using System;
namespace RudderSdk.Core.Models;
public class BoundaryNode
{
[JsonProperty("callbackUrl")]
public string? CallbackUrl { get; set; }
[JsonProperty("enforcement")]
public string? Enforcement { get; set; }
[JsonProperty("enteredAt")]
public DateTimeOffset? EnteredAt { get; set; }
[JsonProperty("nodeId")]
public string? NodeId { get; set; }
[JsonProperty("sourceHandle")]
public string? SourceHandle { get; set; }
[JsonProperty("sourceNodeId")]
public string? SourceNodeId { get; set; }
[JsonProperty("waitDeadline")]
public DateTimeOffset? WaitDeadline { get; set; }
}
-37
View File
@@ -1,37 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models;
public class ExecutionPlan
{
[JsonProperty("boundaryNodes")]
public List<BoundaryNode>? BoundaryNodes { get; set; }
[JsonProperty("context")]
public JToken? Context { get; set; }
[JsonProperty("edges")]
public List<PlanEdge>? Edges { get; set; }
[JsonProperty("nodes")]
public List<ExecutionPlanNode>? Nodes { get; set; }
[JsonProperty("planId")]
public string? PlanId { get; set; }
[JsonProperty("runId")]
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string? ScenarioId { get; set; }
[JsonProperty("startNodeId")]
public string? StartNodeId { get; set; }
[JsonProperty("userId")]
public string? UserId { get; set; }
}
-18
View File
@@ -1,18 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core.Models;
public class ExecutionPlanNode
{
[JsonProperty("data")]
public JToken? Data { get; set; }
[JsonProperty("id")]
public string? Id { get; set; }
[JsonProperty("type")]
public string? Type { get; set; }
}
-23
View File
@@ -1,23 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
namespace RudderSdk.Core.Models;
public class PlanEdge
{
[JsonProperty("id")]
public string? Id { get; set; }
[JsonProperty("source")]
public string? Source { get; set; }
[JsonProperty("sourceHandle")]
public string? SourceHandle { get; set; }
[JsonProperty("target")]
public string? Target { get; set; }
[JsonProperty("targetHandle")]
public string? TargetHandle { get; set; }
}
+3
View File
@@ -6,6 +6,9 @@ namespace RudderSdk.Core.Models.Player;
public class Player public class Player
{ {
[JsonProperty("avatarUrl")]
public string? AvatarUrl { get; set; }
[JsonProperty("createdAt")] [JsonProperty("createdAt")]
public DateTimeOffset? CreatedAt { get; set; } public DateTimeOffset? CreatedAt { get; set; }
+2 -2
View File
@@ -5,7 +5,7 @@ namespace RudderSdk.Core.Models.Quests;
public class ClaimQuestRequest public class ClaimQuestRequest
{ {
[JsonProperty("questId")] [JsonProperty("questSlug")]
public string? QuestId { get; set; } public string? QuestSlug { get; set; }
} }
+3 -3
View File
@@ -7,9 +7,6 @@ namespace RudderSdk.Core.Models.Quests;
public class Quest public class Quest
{ {
[JsonProperty("id")]
public string? Id { get; set; }
[JsonProperty("name")] [JsonProperty("name")]
public string? Name { get; set; } public string? Name { get; set; }
@@ -19,6 +16,9 @@ public class Quest
[JsonProperty("rewards")] [JsonProperty("rewards")]
public List<Reward>? Rewards { get; set; } public List<Reward>? Rewards { get; set; }
[JsonProperty("slug")]
public string? Slug { get; set; }
[JsonProperty("status")] [JsonProperty("status")]
public string? Status { get; set; } public string? Status { get; set; }
+2 -2
View File
@@ -6,7 +6,7 @@ namespace RudderSdk.Core.Models.Quests;
public class ReportQuestProgressResponse public class ReportQuestProgressResponse
{ {
[JsonProperty("completedQuestIds")] [JsonProperty("completedQuestSlugs")]
public List<string>? CompletedQuestIds { get; set; } public List<string>? CompletedQuestSlugs { get; set; }
} }
+19 -12
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,22 +58,22 @@ 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();
foreach (var quest in quests) foreach (var quest in quests)
{ {
if (quest.Status == "completed") if (quest.Status == "completed")
await client.Quests.ClaimAsync(quest.Id); await client.Quests.ClaimAsync(quest.Slug);
} }
// Custom metrics advance matching objectives server-side; the call returns // Custom metrics advance matching objectives server-side; the call returns
@@ -82,8 +82,8 @@ var completedIds = await client.Quests.ReportProgressAsync("kills", 1);
``` ```
Purchase metrics are reported automatically by store purchases; Purchase metrics are reported automatically by store purchases;
`QuestMetrics.PurchaseOffer(offerId)` / `QuestMetrics.PurchaseItem(itemId)` `QuestMetrics.PurchaseOffer(offerSlug)` / `QuestMetrics.PurchaseItem(itemId)`
name the format (`purchase.offer:<offerId>`, `purchase.item:<itemId>`) so name the format (`purchase.offer:<offerSlug>`, `purchase.item:<itemId>`) so
quest configs and client code agree on it. quest configs and client code agree on it.
## Sessions ## Sessions
@@ -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
+2 -2
View File
@@ -6,9 +6,9 @@
<AssemblyName>Rudder.Core</AssemblyName> <AssemblyName>Rudder.Core</AssemblyName>
<RootNamespace>RudderSdk.Core</RootNamespace> <RootNamespace>RudderSdk.Core</RootNamespace>
<PackageId>Rudder.Core</PackageId> <PackageId>Rudder.Core</PackageId>
<Version>0.4.0</Version> <Version>2.0.0</Version>
<Authors>Rudder</Authors> <Authors>Rudder</Authors>
<Description>Rudder LiveOps client SDK for .NET: auth, player, stores, battle pass, quests, leaderboards, inventory, remote config, scenarios, storage and realtime.</Description> <Description>Rudder LiveOps client SDK for .NET: auth, player, stores, battle pass, quests, leaderboards, inventory, remote config, scenarios and storage.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
+7 -13
View File
@@ -47,11 +47,11 @@ public sealed class RudderClient
/// <summary>Global quests.</summary> /// <summary>Global quests.</summary>
public QuestsService Quests { get; } public QuestsService Quests { get; }
/// <summary>Scenario runtime: triggers, node sessions, persistence.</summary> /// <summary>Scenario trigger. Execution lives on the server.</summary>
public ScenarioService Scenario { get; } public ScenarioService Scenario { get; }
/// <summary>Realtime websocket channel.</summary> /// <summary>Pending scenario effects: subscriptions and completion callbacks.</summary>
public RealtimeService Realtime { get; } public EffectsService Effects { get; }
internal RudderClientOptions Options { get; } internal RudderClientOptions Options { get; }
internal IRudderTransport Transport => Options.Transport!; internal IRudderTransport Transport => Options.Transport!;
@@ -61,10 +61,7 @@ public sealed class RudderClient
/// <summary>Project key from <see cref="RudderClientOptions.ProjectKey"/>.</summary> /// <summary>Project key from <see cref="RudderClientOptions.ProjectKey"/>.</summary>
public string ProjectKey => Options.ProjectKey!; public string ProjectKey => Options.ProjectKey!;
/// <summary>Realtime URL from <see cref="RudderClientOptions.RealtimeUrl"/>.</summary> /// <summary>Time source used by the effects client.</summary>
public string? RealtimeUrl => Options.RealtimeUrl;
/// <summary>Time source used by the scenario runtime.</summary>
public IClock Clock => Options.Clock ?? SystemClock.Instance; public IClock Clock => Options.Clock ?? SystemClock.Instance;
/// <summary> /// <summary>
@@ -90,17 +87,16 @@ public sealed class RudderClient
Stores = new StoresService(this); Stores = new StoresService(this);
Leaderboards = new LeaderboardsService(this); Leaderboards = new LeaderboardsService(this);
Inventory = new InventoryService(this); Inventory = new InventoryService(this);
Scenario = new ScenarioService(this);
BattlePass = new BattlePassService(this); BattlePass = new BattlePassService(this);
Quests = new QuestsService(this); Quests = new QuestsService(this);
Realtime = new RealtimeService(this); Effects = new EffectsService(this);
Scenario = new ScenarioService(this);
} }
/// <summary>Pumps time-dependent services; call every frame.</summary> /// <summary>Pumps time-dependent services; call every frame.</summary>
public void Update(float deltaTime) public void Update(float deltaTime)
{ {
Scenario.Update(deltaTime); Effects.Update(deltaTime);
Realtime.Update(deltaTime);
} }
internal Task<TResponse> SendAsync<TRequest, TResponse>( internal Task<TResponse> SendAsync<TRequest, TResponse>(
@@ -151,7 +147,6 @@ public sealed class RudderClient
} }
catch (RudderAuthException) catch (RudderAuthException)
{ {
// Session rejected — refresh once (single-flight) and retry the call once.
if (!await RefreshTokensAsync().ConfigureAwait(false)) if (!await RefreshTokensAsync().ConfigureAwait(false))
throw; throw;
@@ -218,7 +213,6 @@ public sealed class RudderClient
} }
catch catch
{ {
// Transport-level failure during refresh — session is over.
} }
} }
+1 -13
View File
@@ -12,9 +12,6 @@ public sealed class RudderClientOptions
/// <summary>API base URL, e.g. https://api.example.com. Required.</summary> /// <summary>API base URL, e.g. https://api.example.com. Required.</summary>
public string? BaseUrl { get; set; } public string? BaseUrl { get; set; }
/// <summary>Realtime websocket URL. Required only for <see cref="RealtimeService"/>.</summary>
public string? RealtimeUrl { get; set; }
/// <summary>Project key issued in the admin panel. Required.</summary> /// <summary>Project key issued in the admin panel. Required.</summary>
public string? ProjectKey { get; set; } public string? ProjectKey { get; set; }
@@ -30,15 +27,6 @@ public sealed class RudderClientOptions
/// <summary>Diagnostic sink. Null by default (silent).</summary> /// <summary>Diagnostic sink. Null by default (silent).</summary>
public IRudderLogger? Logger { get; set; } public IRudderLogger? Logger { get; set; }
/// <summary>Time source for the scenario runtime; override in tests.</summary> /// <summary>Time source for the effects client; override in tests.</summary>
public IClock? Clock { get; set; } public IClock? Clock { get; set; }
/// <summary>Scenario-run persistence between app launches. Optional.</summary>
public IPlanStateStore? PlanStateStore { get; set; }
/// <summary>Optional scheduler for delayed plan work.</summary>
public IPlanScheduler? Scheduler { get; set; }
/// <summary>Realtime transport factory. Required only for <see cref="RealtimeService"/>.</summary>
public IRealtimeTransportFactory? RealtimeTransportFactory { get; set; }
} }
-11
View File
@@ -1,11 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
namespace RudderSdk.Core.Models.Scenarios;
public class GetScenarioRunRequest
{
[JsonProperty("runId")]
public string? RunId { get; set; }
}
-18
View File
@@ -1,18 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.Scenarios;
public class GetScenarioRunResponse
{
[JsonProperty("plan")]
public ExecutionPlan? Plan { get; set; }
[JsonProperty("runId")]
public string? RunId { get; set; }
[JsonProperty("status")]
public string? Status { get; set; }
}
+2 -2
View File
@@ -14,7 +14,7 @@ public class HandleScenarioCallbackRequest
[JsonProperty("runId")] [JsonProperty("runId")]
public string? RunId { get; set; } public string? RunId { get; set; }
[JsonProperty("scenarioId")] [JsonProperty("scenarioSlug")]
public string? ScenarioId { get; set; } public string? ScenarioSlug { get; set; }
} }
+3 -3
View File
@@ -1,12 +1,12 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json; using Newtonsoft.Json;
using RudderSdk.Core.Models; using Newtonsoft.Json.Linq;
namespace RudderSdk.Core.Models.Scenarios; namespace RudderSdk.Core.Models.Scenarios;
public class HandleScenarioCallbackResponse public class HandleScenarioCallbackResponse
{ {
[JsonProperty("plan")] [JsonProperty("effect")]
public ExecutionPlan? Plan { get; set; } public JToken? Effect { get; set; }
} }
@@ -0,0 +1,12 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Scenarios;
public class ListPendingScenarioEffectsResponse
{
[JsonProperty("effects")]
public List<PendingEffect> Effects { get; set; } = null!;
}
+28
View File
@@ -0,0 +1,28 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
namespace RudderSdk.Core.Models.Scenarios;
public class PendingEffect
{
[JsonProperty("data")]
public JToken Data { get; set; } = null!;
[JsonProperty("nodeId")]
public string NodeId { get; set; } = null!;
[JsonProperty("runId")]
public string RunId { get; set; } = null!;
[JsonProperty("scenarioSlug")]
public string ScenarioSlug { get; set; } = null!;
[JsonProperty("type")]
public string Type { get; set; } = null!;
[JsonProperty("waitDeadline")]
public DateTimeOffset? WaitDeadline { get; set; }
}
+2 -3
View File
@@ -1,13 +1,12 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Collections.Generic; using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.Scenarios; namespace RudderSdk.Core.Models.Scenarios;
public class TriggerScenarioResponse public class TriggerScenarioResponse
{ {
[JsonProperty("plans")] [JsonProperty("effects")]
public List<ExecutionPlan>? Plans { get; set; } public List<PendingEffect> Effects { get; set; } = null!;
} }
+2 -2
View File
@@ -17,7 +17,7 @@ public class UpdateScenarioCounterRequest
[JsonProperty("runId")] [JsonProperty("runId")]
public string? RunId { get; set; } public string? RunId { get; set; }
[JsonProperty("scenarioId")] [JsonProperty("scenarioSlug")]
public string? ScenarioId { get; set; } public string? ScenarioSlug { get; set; }
} }
+3 -3
View File
@@ -1,6 +1,6 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json; using Newtonsoft.Json;
using RudderSdk.Core.Models; using Newtonsoft.Json.Linq;
namespace RudderSdk.Core.Models.Scenarios; namespace RudderSdk.Core.Models.Scenarios;
@@ -9,7 +9,7 @@ public class UpdateScenarioCounterResponse
[JsonProperty("completed")] [JsonProperty("completed")]
public bool? Completed { get; set; } public bool? Completed { get; set; }
[JsonProperty("plan")] [JsonProperty("effect")]
public ExecutionPlan? Plan { get; set; } public JToken? Effect { get; set; }
} }
+3 -3
View File
@@ -8,7 +8,7 @@ namespace RudderSdk.Core;
/// <summary> /// <summary>
/// Battle pass progress and rewards. Battle pass state is tied to a scenario /// Battle pass progress and rewards. Battle pass state is tied to a scenario
/// battle pass node, so every call carries the scenario/node ids (and a run id /// battle pass node, so every call carries the scenario/node ids (and a run id
/// for the mutating calls) — <see cref="BattlePassSession"/> supplies them /// for the mutating calls) — <see cref="BattlePassEffect"/> supplies them
/// during scenario runs. /// during scenario runs.
/// </summary> /// </summary>
public sealed class BattlePassService public sealed class BattlePassService
@@ -24,11 +24,11 @@ public sealed class BattlePassService
internal BattlePassService(RudderClient client) => _client = client; internal BattlePassService(RudderClient client) => _client = client;
/// <summary>Reads current progress: xp, level, premium ownership, claimed tiers.</summary> /// <summary>Reads current progress: xp, level, premium ownership, claimed tiers.</summary>
public Task<GetBattlePassProgressResponse> GetProgressAsync(string scenarioId, string nodeId, CancellationToken cancellationToken = default) public Task<GetBattlePassProgressResponse> GetProgressAsync(string scenarioSlug, string nodeId, CancellationToken cancellationToken = default)
=> _client.SendAsync<GetBattlePassProgressRequest, GetBattlePassProgressResponse>( => _client.SendAsync<GetBattlePassProgressRequest, GetBattlePassProgressResponse>(
"POST", "POST",
"/sdk/v1/battlepass/progress", "/sdk/v1/battlepass/progress",
new GetBattlePassProgressRequest { ScenarioId = scenarioId, NodeId = nodeId }, new GetBattlePassProgressRequest { ScenarioSlug = scenarioSlug, NodeId = nodeId },
cancellationToken); cancellationToken);
/// <summary>Credits xp and returns the new xp/level and level-up flags.</summary> /// <summary>Credits xp and returns the new xp/level and level-up flags.</summary>
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Models.BattlePass;
namespace RudderSdk.Core;
/// <summary>
/// A scenario battle-pass node. Battle-pass operations are bound to this
/// node's scenario/node/run ids; <see cref="LevelUpAsync"/>, <see cref="EndAsync"/>
/// and a successful <see cref="PurchasePremiumAsync"/> post callbacks.
/// </summary>
public sealed class BattlePassEffect
{
private readonly EffectHandle _handle;
private readonly BattlePassService _battlePass;
internal BattlePassEffect(EffectHandle handle, BattlePassService battlePass)
{
_handle = handle;
_battlePass = battlePass;
}
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Reads current progress (xp, level, premium ownership, claimed tiers) for this node.</summary>
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken cancellationToken = default)
=> _battlePass.GetProgressAsync(_handle.ScenarioSlug, _handle.NodeId, cancellationToken);
/// <summary>Credits xp from a configured source.</summary>
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken cancellationToken = default)
=> _battlePass.AddXpAsync(new AddBattlePassXpRequest
{
ScenarioSlug = _handle.ScenarioSlug,
NodeId = _handle.NodeId,
RunId = _handle.RunId,
Source = source,
Amount = amount
}, cancellationToken);
/// <summary>
/// Claims a tier reward at a reached level.
/// <paramref name="track"/> is <see cref="BattlePassService.TrackFree"/> or <see cref="BattlePassService.TrackPremium"/>.
/// </summary>
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken cancellationToken = default)
=> _battlePass.ClaimRewardAsync(new ClaimBattlePassRewardRequest
{
ScenarioSlug = _handle.ScenarioSlug,
NodeId = _handle.NodeId,
RunId = _handle.RunId,
Level = level,
Track = track
}, cancellationToken);
/// <summary>Purchases the premium track, then posts <c>onPremiumPurchase</c> on success.</summary>
public async Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken cancellationToken = default)
{
var response = await _battlePass.PurchasePremiumAsync(new PurchaseBattlePassPremiumRequest
{
ScenarioSlug = _handle.ScenarioSlug,
NodeId = _handle.NodeId,
RunId = _handle.RunId,
IdempotencyKey = Guid.NewGuid().ToString()
}, cancellationToken).ConfigureAwait(false);
if (response != null && response.Success == true)
await _handle.CompleteAsync("onPremiumPurchase", cancellationToken).ConfigureAwait(false);
return response!;
}
/// <summary>Posts the <c>onLevelUp</c> callback.</summary>
public Task LevelUpAsync(CancellationToken cancellationToken = default) => _handle.CompleteAsync("onLevelUp", cancellationToken);
/// <summary>Posts the <c>onLevelUp</c> callback (fire-and-forget).</summary>
public void LevelUp() => _handle.Complete("onLevelUp");
/// <summary>Posts the <c>onComplete</c> callback.</summary>
public Task EndAsync(CancellationToken cancellationToken = default) => _handle.CompleteAsync("onComplete", cancellationToken);
/// <summary>Posts the <c>onComplete</c> callback (fire-and-forget).</summary>
public void End() => _handle.Complete("onComplete");
}
+41
View File
@@ -0,0 +1,41 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>
/// A scenario battle-pass-level node — a single claimable tier.
/// <see cref="ClaimAsync"/> posts <c>onComplete</c>, which the server accepts
/// only once the player has reached the node's configured level.
/// </summary>
public sealed class BattlePassLevelEffect
{
private readonly EffectHandle _handle;
internal BattlePassLevelEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>The tier level this node claims.</summary>
public int Level => _handle.Get("levelNumber", 0);
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Claims this tier; posts <c>onComplete</c>.</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => _handle.CompleteAsync("onComplete", cancellationToken);
/// <summary>Claims this tier (fire-and-forget).</summary>
public void Claim() => _handle.Complete("onComplete");
}
+61
View File
@@ -0,0 +1,61 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario leaderboard node. End it with <see cref="EndAsync"/> or claim with <see cref="ClaimAsync"/>.</summary>
public sealed class LeaderboardEffect
{
private readonly EffectHandle _handle;
internal LeaderboardEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>True after the effect was resolved once.</summary>
public bool IsResolved { get; private set; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Posts the <c>onEnd</c> callback.</summary>
public Task EndAsync(CancellationToken cancellationToken = default) => ResolveAsync("onEnd", cancellationToken);
/// <summary>Posts the <c>onEnd</c> callback (fire-and-forget).</summary>
public void End() => Resolve("onEnd");
/// <summary>Posts the <c>onClaim</c> callback. The server matches live rank to a place.</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => ResolveAsync("onClaim", cancellationToken);
/// <summary>Posts the <c>onClaim</c> callback (fire-and-forget).</summary>
public void Claim() => Resolve("onClaim");
private async Task ResolveAsync(string handle, CancellationToken cancellationToken)
{
if (IsResolved)
return;
IsResolved = true;
await _handle.CompleteAsync(handle, cancellationToken).ConfigureAwait(false);
}
private void Resolve(string handle)
{
if (IsResolved)
return;
IsResolved = true;
_handle.Complete(handle);
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario notification node. <see cref="DoneAsync"/> posts the <c>output</c> callback.</summary>
public sealed class NotificationEffect
{
private readonly EffectHandle _handle;
internal NotificationEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Notification title.</summary>
public string Title => _handle.Get("title", string.Empty);
/// <summary>Notification message.</summary>
public string Message => _handle.Get("message", string.Empty);
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Posts the <c>output</c> callback.</summary>
public Task DoneAsync(CancellationToken cancellationToken = default) => _handle.CompleteAsync("output", cancellationToken);
/// <summary>Posts the <c>output</c> callback (fire-and-forget).</summary>
public void Done() => _handle.Complete("output");
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>
/// A scenario quest node. Report objective progress; the server auto-completes
/// the node once every objective is satisfied.
/// </summary>
public sealed class QuestEffect
{
private readonly EffectHandle _handle;
internal QuestEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Quest name from the node data.</summary>
public string Name => _handle.Get("name", string.Empty);
/// <summary>Objective definitions from the node data.</summary>
public IReadOnlyList<JObject> Objectives
{
get
{
if (_handle.Data["objectives"] is not JArray array)
return Array.Empty<JObject>();
var list = new List<JObject>(array.Count);
foreach (var item in array)
{
if (item is JObject obj)
list.Add(obj);
}
return list;
}
}
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Reports progress toward an objective via <c>POST /sdk/v1/scenarios/counter</c>.</summary>
public Task ReportProgressAsync(string objectiveId, long amount = 1, CancellationToken cancellationToken = default)
=> _handle.ReportProgressAsync(objectiveId, amount, cancellationToken);
/// <summary>Reports progress toward an objective (fire-and-forget).</summary>
public void ReportProgress(string objectiveId, long amount = 1) => _handle.ReportProgress(objectiveId, amount);
}
@@ -0,0 +1,17 @@
namespace RudderSdk.Core;
/// <summary>Payload of <see cref="EffectsService.OnScenarioCompleted"/>.</summary>
public sealed class ScenarioCompletedEffect
{
internal ScenarioCompletedEffect(string runId, string scenarioSlug)
{
RunId = runId;
ScenarioSlug = scenarioSlug;
}
/// <summary>Server-issued run id.</summary>
public string RunId { get; }
/// <summary>Scenario id.</summary>
public string ScenarioSlug { get; }
}
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace RudderSdk.Core;
/// <summary>Payload of <see cref="EffectsService.OnScenarioFailed"/>.</summary>
public sealed class ScenarioFailedEffect
{
internal ScenarioFailedEffect(string runId, string scenarioSlug, string nodeId, Exception exception)
{
RunId = runId;
ScenarioSlug = scenarioSlug;
NodeId = nodeId;
Exception = exception;
}
/// <summary>Server-issued run id.</summary>
public string RunId { get; }
/// <summary>Scenario id.</summary>
public string ScenarioSlug { get; }
/// <summary>Node the failure happened at.</summary>
public string NodeId { get; }
/// <summary>The error that failed the run.</summary>
public Exception Exception { get; }
}
+74
View File
@@ -0,0 +1,74 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario store-offer node. Resolve it with <see cref="PurchaseAsync"/> or <see cref="DeclineAsync"/>.</summary>
public sealed class StoreOfferEffect
{
private readonly EffectHandle _handle;
internal StoreOfferEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Store slug from the node data, if present.</summary>
public string StoreSlug => _handle.Get("storeSlug", string.Empty);
/// <summary>Optional message from the node data.</summary>
public string? Message
{
get
{
var value = _handle.Get<string?>("message", null);
return string.IsNullOrEmpty(value) ? null : value;
}
}
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>True after the offer was resolved once.</summary>
public bool IsResolved { get; private set; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Posts the <c>onPurchase</c> callback.</summary>
public Task PurchaseAsync(CancellationToken cancellationToken = default) => ResolveAsync("onPurchase", cancellationToken);
/// <summary>Posts the <c>onPurchase</c> callback (fire-and-forget).</summary>
public void Purchase() => Resolve("onPurchase");
/// <summary>Posts the <c>onDecline</c> callback.</summary>
public Task DeclineAsync(CancellationToken cancellationToken = default) => ResolveAsync("onDecline", cancellationToken);
/// <summary>Posts the <c>onDecline</c> callback (fire-and-forget).</summary>
public void Decline() => Resolve("onDecline");
private async Task ResolveAsync(string handle, CancellationToken cancellationToken)
{
if (IsResolved)
return;
IsResolved = true;
await _handle.CompleteAsync(handle, cancellationToken).ConfigureAwait(false);
}
private void Resolve(string handle)
{
if (IsResolved)
return;
IsResolved = true;
_handle.Complete(handle);
}
}
+34
View File
@@ -0,0 +1,34 @@
using System;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario wait node. The run resumes when the server advances it at <see cref="DeadlineUtc"/>.</summary>
public sealed class WaitEffect
{
private readonly EffectHandle _handle;
internal WaitEffect(EffectHandle handle, DateTimeOffset deadlineUtc)
{
_handle = handle;
DeadlineUtc = deadlineUtc;
}
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>When the wait ends (UTC).</summary>
public DateTimeOffset DeadlineUtc { get; }
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
}
+457
View File
@@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core;
/// <summary>
/// Thin effects client. The server owns scenario execution; this service
/// surfaces pending effects and posts callbacks. Drive
/// <see cref="RudderClient.Update"/> every frame for the 30s heartbeat and
/// wait-deadline checks.
/// </summary>
public sealed class EffectsService
{
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(30);
private readonly RudderClient _client;
private readonly object _gate = new();
private readonly Dictionary<(string RunId, string NodeId), string> _seen = new();
private readonly Dictionary<(string RunId, string NodeId), DateTimeOffset> _waitDeadlines = new();
private bool _refreshDue;
private bool _inFlight;
private DateTimeOffset _nextHeartbeat;
/// <summary>Raised for a notification effect.</summary>
public event Action<NotificationEffect>? OnNotification;
/// <summary>Raised for a store-offer effect.</summary>
public event Action<StoreOfferEffect>? OnStoreOffer;
/// <summary>Raised for a leaderboard effect.</summary>
public event Action<LeaderboardEffect>? OnLeaderboard;
/// <summary>Raised for a wait effect.</summary>
public event Action<WaitEffect>? OnWait;
/// <summary>Raised for a quest effect.</summary>
public event Action<QuestEffect>? OnQuest;
/// <summary>Raised for a battle-pass effect.</summary>
public event Action<BattlePassEffect>? OnBattlePass;
/// <summary>Raised for a battle-pass-level effect.</summary>
public event Action<BattlePassLevelEffect>? OnBattlePassLevel;
/// <summary>Raised when a run finishes all its nodes.</summary>
public event Action<ScenarioCompletedEffect>? OnScenarioCompleted;
/// <summary>Raised when a run is dropped after a definitive server rejection or an unsupported effect type.</summary>
public event Action<ScenarioFailedEffect>? OnScenarioFailed;
internal EffectsService(RudderClient client)
{
_client = client;
_nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval;
if (!string.IsNullOrEmpty(_client.TokenStore.GetAccessToken()))
_refreshDue = true;
_client.Auth.AuthStateChanged += OnAuthStateChanged;
}
/// <summary>Pumps heartbeat and wait-deadline checks; call every frame.</summary>
public void Update(float deltaTime)
{
if (string.IsNullOrEmpty(_client.TokenStore.GetAccessToken()))
return;
var now = _client.Clock.UtcNow;
lock (_gate)
{
if (_inFlight)
return;
if (!_refreshDue && now < _nextHeartbeat && !HasDueWaitUnlocked(now))
return;
_refreshDue = false;
_inFlight = true;
}
_ = RefreshPendingAsync();
}
internal void Ingest(IEnumerable<PendingEffect>? effects)
{
if (effects == null)
return;
var batch = new List<PendingEffect>();
lock (_gate)
{
foreach (var effect in effects)
{
if (effect == null || string.IsNullOrEmpty(effect.RunId) || string.IsNullOrEmpty(effect.NodeId))
continue;
var key = (effect.RunId, effect.NodeId);
if (_seen.ContainsKey(key))
continue;
_seen[key] = effect.ScenarioSlug;
if (string.Equals(effect.Type, EffectTypes.Wait, StringComparison.Ordinal))
{
var deadline = effect.WaitDeadline ?? _client.Clock.UtcNow;
_waitDeadlines[key] = deadline;
}
batch.Add(effect);
}
}
foreach (var effect in batch)
Dispatch(effect);
}
internal async Task CompleteAsync(PendingEffect source, string handle, CancellationToken cancellationToken = default)
{
try
{
var response = await _client.SendAsync<HandleScenarioCallbackRequest, HandleScenarioCallbackResponse>(
"POST",
"/sdk/v1/scenarios/callback",
new HandleScenarioCallbackRequest
{
ScenarioSlug = source.ScenarioSlug,
NodeId = source.NodeId,
Handle = handle,
RunId = source.RunId
},
cancellationToken).ConfigureAwait(false);
ForgetWait(source.RunId, source.NodeId);
var next = ReadEffect(response?.Effect);
if (next == null)
Emit(OnScenarioCompleted, new ScenarioCompletedEffect(source.RunId, source.ScenarioSlug));
else
Ingest(new[] { next });
}
catch (Exception ex) when (IsDefinitiveRejection(ex))
{
DropRun(source.RunId, source.ScenarioSlug, source.NodeId, ex);
}
}
internal async Task ReportProgressAsync(
PendingEffect source,
string counterKey,
long amount,
CancellationToken cancellationToken = default)
{
try
{
var response = await _client.SendAsync<UpdateScenarioCounterRequest, UpdateScenarioCounterResponse>(
"POST",
"/sdk/v1/scenarios/counter",
new UpdateScenarioCounterRequest
{
ScenarioSlug = source.ScenarioSlug,
NodeId = source.NodeId,
CounterKey = counterKey,
Amount = amount,
RunId = source.RunId
},
cancellationToken).ConfigureAwait(false);
if (response == null || response.Completed != true)
return;
ForgetWait(source.RunId, source.NodeId);
var next = ReadEffect(response.Effect);
if (next == null)
Emit(OnScenarioCompleted, new ScenarioCompletedEffect(source.RunId, source.ScenarioSlug));
else
Ingest(new[] { next });
}
catch (Exception ex) when (IsDefinitiveRejection(ex))
{
DropRun(source.RunId, source.ScenarioSlug, source.NodeId, ex);
}
catch (Exception ex)
{
_client.Options.Logger?.Log(
RudderLogLevel.Warning,
$"[Rudder] Scenario counter update failed at node {source.NodeId}: {ex.Message}");
}
}
private void OnAuthStateChanged(RudderAuthState state)
{
if (state == RudderAuthState.SignedIn)
{
lock (_gate)
_refreshDue = true;
return;
}
lock (_gate)
{
_seen.Clear();
_waitDeadlines.Clear();
_refreshDue = false;
_nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval;
}
}
private async Task RefreshPendingAsync()
{
try
{
var response = await _client.SendAsync<ListPendingScenarioEffectsResponse>(
"GET",
"/sdk/v1/scenarios/pending",
CancellationToken.None).ConfigureAwait(false);
if (response != null)
{
Ingest(response.Effects);
Reconcile(response.Effects);
}
lock (_gate)
_nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval;
}
catch (Exception ex)
{
_client.Options.Logger?.Log(
RudderLogLevel.Warning,
"[Rudder] Failed to refresh pending scenario effects. " + ex.Message);
lock (_gate)
_nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval;
}
finally
{
lock (_gate)
_inFlight = false;
}
}
private void Dispatch(PendingEffect effect)
{
var handle = new EffectHandle(this, effect);
switch (effect.Type ?? string.Empty)
{
case EffectTypes.Notification:
Emit(OnNotification, new NotificationEffect(handle));
break;
case EffectTypes.Store:
Emit(OnStoreOffer, new StoreOfferEffect(handle));
break;
case EffectTypes.Leaderboard:
Emit(OnLeaderboard, new LeaderboardEffect(handle));
break;
case EffectTypes.Wait:
Emit(OnWait, new WaitEffect(handle, effect.WaitDeadline ?? _client.Clock.UtcNow));
break;
case EffectTypes.Quest:
Emit(OnQuest, new QuestEffect(handle));
break;
case EffectTypes.BattlePass:
Emit(OnBattlePass, new BattlePassEffect(handle, _client.BattlePass));
break;
case EffectTypes.BattlePassLevel:
Emit(OnBattlePassLevel, new BattlePassLevelEffect(handle));
break;
default:
_client.Options.Logger?.Log(
RudderLogLevel.Warning,
$"[Rudder] Unsupported scenario node type '{effect.Type}' ({effect.NodeId}).");
Emit(
OnScenarioFailed,
new ScenarioFailedEffect(
effect.RunId,
effect.ScenarioSlug,
effect.NodeId,
new Exception($"Unsupported scenario node type '{effect.Type}'")));
break;
}
}
private void DropRun(string runId, string scenarioSlug, string nodeId, Exception exception)
{
lock (_gate)
{
var toRemove = new List<(string RunId, string NodeId)>();
foreach (var key in _seen.Keys)
{
if (key.RunId == runId)
toRemove.Add(key);
}
foreach (var key in toRemove)
{
_seen.Remove(key);
_waitDeadlines.Remove(key);
}
}
Emit(OnScenarioFailed, new ScenarioFailedEffect(runId, scenarioSlug, nodeId, exception));
}
private void Reconcile(IEnumerable<PendingEffect>? effects)
{
var incoming = new HashSet<(string RunId, string NodeId)>();
if (effects != null)
{
foreach (var effect in effects)
{
if (effect == null || string.IsNullOrEmpty(effect.RunId) || string.IsNullOrEmpty(effect.NodeId))
continue;
incoming.Add((effect.RunId, effect.NodeId));
}
}
var finished = new Dictionary<string, string>();
lock (_gate)
{
var stale = new List<(string RunId, string NodeId)>();
foreach (var key in _seen.Keys)
{
if (!incoming.Contains(key))
stale.Add(key);
}
foreach (var key in stale)
{
finished[key.RunId] = _seen[key];
_seen.Remove(key);
_waitDeadlines.Remove(key);
}
foreach (var key in _seen.Keys)
finished.Remove(key.RunId);
}
foreach (var entry in finished)
Emit(OnScenarioCompleted, new ScenarioCompletedEffect(entry.Key, entry.Value));
}
private void ForgetWait(string runId, string nodeId)
{
lock (_gate)
_waitDeadlines.Remove((runId, nodeId));
}
private bool HasDueWaitUnlocked(DateTimeOffset now)
{
foreach (var deadline in _waitDeadlines.Values)
{
if (now >= deadline)
return true;
}
return false;
}
private void Emit<T>(Action<T>? handlers, T effect)
{
if (handlers == null)
return;
foreach (var subscriber in handlers.GetInvocationList())
{
try
{
((Action<T>)subscriber).Invoke(effect);
}
catch (Exception ex)
{
_client.Options.Logger?.Log(
RudderLogLevel.Error,
"[Rudder] Effect handler failed. " + ex.Message);
}
}
}
private static PendingEffect? ReadEffect(JToken? token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
return token.ToObject<PendingEffect>();
}
private static bool IsDefinitiveRejection(Exception ex)
{
if (ex is RudderNotFoundException)
return true;
return ex is RudderApiException api
&& (api.Code == RudderErrorCodes.UnknownRun
|| api.Code == RudderErrorCodes.RunExpired
|| api.Code == RudderErrorCodes.RunNotActive);
}
}
internal static class EffectTypes
{
public const string Notification = "notification";
public const string Store = "store";
public const string Leaderboard = "leaderboard";
public const string Wait = "wait";
public const string Quest = "quest";
public const string BattlePass = "battlepass";
public const string BattlePassLevel = "battlepass_level";
}
internal sealed class EffectHandle
{
private readonly EffectsService _service;
private readonly PendingEffect _effect;
public EffectHandle(EffectsService service, PendingEffect effect)
{
_service = service;
_effect = effect;
}
public PendingEffect Source => _effect;
public string RunId => _effect.RunId;
public string ScenarioSlug => _effect.ScenarioSlug;
public string NodeId => _effect.NodeId;
public JObject Data => _effect.Data as JObject ?? new JObject();
public T Get<T>(string key, T defaultValue = default!)
{
if (!Data.TryGetValue(key, out var value) || value == null || value.Type == JTokenType.Null)
return defaultValue;
try
{
return value.ToObject<T>() ?? defaultValue;
}
catch
{
return defaultValue;
}
}
public Task CompleteAsync(string handle, CancellationToken cancellationToken = default)
=> _service.CompleteAsync(_effect, handle, cancellationToken);
public void Complete(string handle) => _ = CompleteAsync(handle);
public Task ReportProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
=> _service.ReportProgressAsync(_effect, counterKey, amount, cancellationToken);
public void ReportProgress(string counterKey, long amount) => _ = ReportProgressAsync(counterKey, amount);
}
+1 -1
View File
@@ -9,7 +9,7 @@ namespace RudderSdk.Core;
public static class QuestMetrics public static class QuestMetrics
{ {
/// <summary>Metric for purchasing a store offer; auto-reported on purchase.</summary> /// <summary>Metric for purchasing a store offer; auto-reported on purchase.</summary>
public static string PurchaseOffer(string offerId) => $"purchase.offer:{offerId}"; public static string PurchaseOffer(string offerSlug) => $"purchase.offer:{offerSlug}";
/// <summary>Metric for purchasing a catalog item; auto-reported on purchase.</summary> /// <summary>Metric for purchasing a catalog item; auto-reported on purchase.</summary>
public static string PurchaseItem(string itemId) => $"purchase.item:{itemId}"; public static string PurchaseItem(string itemId) => $"purchase.item:{itemId}";
+4 -4
View File
@@ -7,7 +7,7 @@ namespace RudderSdk.Core;
/// <summary> /// <summary>
/// Global quests (list + claim), distinct from scenario quest nodes which /// Global quests (list + claim), distinct from scenario quest nodes which
/// advance through <see cref="QuestSession"/>. /// advance through <see cref="QuestEffect"/>.
/// </summary> /// </summary>
public sealed class QuestsService public sealed class QuestsService
{ {
@@ -27,11 +27,11 @@ public sealed class QuestsService
} }
/// <summary>Claims a completed quest's rewards (idempotent server-side).</summary> /// <summary>Claims a completed quest's rewards (idempotent server-side).</summary>
public Task<ClaimQuestResponse> ClaimAsync(string questId, CancellationToken cancellationToken = default) public Task<ClaimQuestResponse> ClaimAsync(string questSlug, CancellationToken cancellationToken = default)
=> _client.SendAsync<ClaimQuestRequest, ClaimQuestResponse>( => _client.SendAsync<ClaimQuestRequest, ClaimQuestResponse>(
"POST", "POST",
"/sdk/v1/quests/claim", "/sdk/v1/quests/claim",
new ClaimQuestRequest { QuestId = questId }, new ClaimQuestRequest { QuestSlug = questSlug },
cancellationToken); cancellationToken);
/// <summary>Reports progress for a metric and returns the ids of quests completed by this report.</summary> /// <summary>Reports progress for a metric and returns the ids of quests completed by this report.</summary>
@@ -43,6 +43,6 @@ public sealed class QuestsService
new ReportQuestProgressRequest { Metric = metric, Amount = amount }, new ReportQuestProgressRequest { Metric = metric, Amount = amount },
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
return response?.CompletedQuestIds ?? new List<string>(); return response?.CompletedQuestSlugs ?? new List<string>();
} }
} }
-105
View File
@@ -1,105 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Core;
/// <summary>
/// Realtime websocket channel. Requires
/// <see cref="RudderClientOptions.RealtimeUrl"/> and
/// <see cref="RudderClientOptions.RealtimeTransportFactory"/>.
/// </summary>
public sealed class RealtimeService
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private readonly RudderClient _client;
private RealtimeSession? _session;
internal RealtimeService(RudderClient client) => _client = client;
/// <summary>The current session, or null when not connected.</summary>
public RealtimeSession? Session => _session;
/// <summary>True while a session is connected.</summary>
public bool IsConnected => _session?.IsConnected == true;
/// <summary>Connects to the configured realtime URL.</summary>
public Task<RealtimeSession> ConnectAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(_client.RealtimeUrl))
throw new InvalidOperationException("RealtimeUrl is not configured.");
return ConnectAsync(new Uri(_client.RealtimeUrl), timeout, cancellationToken);
}
/// <summary>Connects to an explicit realtime URL.</summary>
public async Task<RealtimeSession> ConnectAsync(Uri uri, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var factory = _client.Options.RealtimeTransportFactory
?? throw new InvalidOperationException("RealtimeTransportFactory is not configured.");
var token = _client.TokenStore.GetAccessToken();
if (string.IsNullOrEmpty(token))
throw new InvalidOperationException("LiveOps access token is required for realtime authorization.");
var transport = factory.Create();
await transport.ConnectAsync(uri, timeout ?? DefaultTimeout, cancellationToken).ConfigureAwait(false);
_session = new RealtimeSession(transport, token);
return _session;
}
/// <summary>Closes the current session, if any.</summary>
public Task DisconnectAsync(CancellationToken cancellationToken = default)
=> _session?.DisconnectAsync(cancellationToken) ?? Task.CompletedTask;
/// <summary>Pumps the underlying transport; call every frame.</summary>
public void Update(float deltaTime)
{
_session?.Update(deltaTime);
}
}
/// <summary>An open realtime connection.</summary>
public sealed class RealtimeSession
{
private readonly IRealtimeTransport _transport;
internal RealtimeSession(IRealtimeTransport transport, string accessToken)
{
_transport = transport;
AccessToken = accessToken;
_transport.Closed += () => Closed?.Invoke();
_transport.Error += ex => Error?.Invoke(ex);
_transport.Received += data => MessageReceived?.Invoke(data);
}
/// <summary>Access token the connection was authorized with.</summary>
public string AccessToken { get; }
/// <summary>True while the connection is open.</summary>
public bool IsConnected => _transport.IsConnected;
/// <summary>Raised when the connection closes.</summary>
public event Action? Closed;
/// <summary>Raised on transport errors.</summary>
public event Action<Exception>? Error;
/// <summary>Raised for every incoming message.</summary>
public event Action<ArraySegment<byte>>? MessageReceived;
/// <summary>Sends one message.</summary>
public Task SendAsync(byte[] payload, CancellationToken cancellationToken = default)
=> _transport.SendAsync(new ArraySegment<byte>(payload ?? Array.Empty<byte>()), cancellationToken);
/// <summary>Closes the connection.</summary>
public Task DisconnectAsync(CancellationToken cancellationToken = default)
=> _transport.CloseAsync(cancellationToken);
internal void Update(float deltaTime)
{
_transport.Update(deltaTime);
}
}
+7 -218
View File
@@ -1,71 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios; using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core; namespace RudderSdk.Core;
/// <summary> /// <summary>
/// Scenario runtime. <see cref="TriggerAsync"/> starts server-issued plans; /// Scenario trigger. Execution lives on the server; resulting effects surface
/// active nodes surface as typed sessions through the On* events and are /// through <see cref="RudderClient.Effects"/>.
/// advanced by completing those sessions.
/// </summary> /// </summary>
public sealed partial class ScenarioService public sealed class ScenarioService
{ {
private readonly RudderClient _client; private readonly RudderClient _client;
private readonly Dictionary<string, RuntimeRun> _runs = new();
/// <summary>Raised for a notification node.</summary>
public event Action<NotificationSession>? OnNotification;
/// <summary>Raised for a store-offer node.</summary>
public event Action<StoreOfferSession>? OnStoreOffer;
/// <summary>Raised for a leaderboard node.</summary>
public event Action<LeaderboardSession>? OnLeaderboard;
/// <summary>Raised for a remote-config-override node, after the patches were applied.</summary>
public event Action<ConfigChangedSession>? OnConfigChanged;
/// <summary>Raised for a wait node.</summary>
public event Action<WaitSession>? OnWait;
/// <summary>Raised for a quest node.</summary>
public event Action<QuestSession>? OnQuest;
/// <summary>Raised for a battle-pass node.</summary>
public event Action<BattlePassSession>? OnBattlePass;
/// <summary>Raised for a battle-pass-level node.</summary>
public event Action<BattlePassLevelSession>? OnBattlePassLevel;
/// <summary>Raised when a run finishes all its nodes.</summary>
public event Action<PlanRun>? OnScenarioCompleted;
/// <summary>Raised when a run dies on an unrecoverable error.</summary>
public event Action<ScenarioFailedEvent>? OnScenarioFailed;
internal ScenarioService(RudderClient client) => _client = client; internal ScenarioService(RudderClient client) => _client = client;
/// <summary>True while at least one run is active.</summary>
public bool IsRunning => _runs.Count > 0;
/// <summary>First active node id across the runs, or null.</summary>
public string? CurrentNodeId => _runs.Values.FirstOrDefault()?.ActiveNodes.Keys.FirstOrDefault();
/// <summary>Snapshots of the active runs.</summary>
public IReadOnlyList<PlanRun> ActiveRuns => _runs.Values.Select(ToPlanRun).ToList();
/// <summary> /// <summary>
/// Triggers scenarios by event name and starts the plans the server /// Triggers scenarios by event name. Returned pending effects are ingested
/// returns. Returns the runs this call started. /// into <see cref="RudderClient.Effects"/>.
/// </summary> /// </summary>
public async Task<IReadOnlyList<PlanRun>> TriggerAsync(string eventName, CancellationToken cancellationToken = default) public async Task TriggerAsync(string eventName, CancellationToken cancellationToken = default)
{ {
var response = await _client.SendAsync<TriggerScenarioRequest, TriggerScenarioResponse>( var response = await _client.SendAsync<TriggerScenarioRequest, TriggerScenarioResponse>(
"POST", "POST",
@@ -73,170 +26,6 @@ public sealed partial class ScenarioService
new TriggerScenarioRequest { Event = eventName }, new TriggerScenarioRequest { Event = eventName },
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
return StartPlans(response?.Plans); _client.Effects.Ingest(response?.Effects);
}
/// <summary>
/// Restores persisted runs, reconciles them with the server and re-dispatches
/// active nodes. Call once after startup, after login.
/// </summary>
public Task RestoreAsync(CancellationToken cancellationToken = default)
{
return RestoreCoreAsync(cancellationToken);
}
private async Task RestoreCoreAsync(CancellationToken cancellationToken = default)
{
var store = _client.Options.PlanStateStore;
if (store == null || string.IsNullOrEmpty(store.State))
return;
try
{
var state = JsonConvert.DeserializeObject<PersistedScenarioState>(store.State);
_runs.Clear();
if (state?.Runs != null)
{
foreach (var savedRun in state.Runs)
{
if (savedRun?.Plan == null)
continue;
// Reconcile with server
try
{
var response = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
"POST",
"/sdk/v1/scenarios/run",
new GetScenarioRunRequest { RunId = savedRun.RunId },
cancellationToken);
if (response?.Status == "unknown_run" || response?.Status == "expired")
continue;
if (response?.Plan != null)
{
savedRun.Plan = response.Plan;
savedRun.ActiveNodes = null;
savedRun.CompletedHandles = null;
}
}
catch
{
// Network error — keep local state as fallback
}
var run = RuntimeRun.FromPersisted(savedRun);
_runs[run.RunId] = run;
}
}
foreach (var run in _runs.Values.ToList())
{
if (run.ActiveNodes.Count > 0)
{
foreach (var nodeState in run.ActiveNodes.Values.ToList())
DispatchActiveNode(run, nodeState, restored: true);
}
else
{
// Rebuilt from server — activate start node
var startNode = FindNode(run.Plan, run.Plan.StartNodeId) ?? run.Plan.Nodes[0];
ActivateNode(run, startNode.Id);
}
}
Persist();
}
catch (Exception ex)
{
_client.Options.Logger?.Log(RudderLogLevel.Error, "[Rudder] Failed to restore scenario state. Clearing persisted state. " + ex.Message);
Clear();
}
}
/// <summary>Drops all runs and the persisted state.</summary>
public void Clear()
{
_runs.Clear();
Persist();
}
/// <summary>Completes wait nodes whose deadline passed; call every frame.</summary>
public void Update(float deltaTime)
{
var now = _client.Clock.UtcNow;
foreach (var run in _runs.Values.ToList())
{
foreach (var node in run.ActiveNodes.Values.ToList())
{
if (node.WaitDeadlineUtc.HasValue && now >= node.WaitDeadlineUtc.Value)
_ = CompleteNodeAsync(run.RunId, node.NodeId, "onComplete");
}
}
}
/// <summary>Completes the first active node with the given handle.</summary>
public Task RespondAsync(string handle, CancellationToken cancellationToken = default)
{
var run = _runs.Values.FirstOrDefault();
var node = run?.ActiveNodes.Values.FirstOrDefault();
return run == null || node == null
? Task.CompletedTask
: CompleteNodeAsync(run.RunId, node.NodeId, handle, cancellationToken);
}
/// <summary>Completes the first active node with the given handle (fire-and-forget).</summary>
public void Respond(string handle)
{
_ = RespondAsync(handle);
}
/// <summary>Adds progress to a counter of the first active node.</summary>
public Task UpdateProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
{
var run = _runs.Values.FirstOrDefault();
var node = run?.ActiveNodes.Values.FirstOrDefault();
return run == null || node == null
? Task.CompletedTask
: UpdateProgressAsync(run.RunId, node.NodeId, counterKey, amount, cancellationToken);
}
internal async Task UpdateProgressAsync(
string runId,
string nodeId,
string counterKey,
long amount,
CancellationToken cancellationToken = default)
{
if (!_runs.TryGetValue(runId, out var run) || !run.ActiveNodes.ContainsKey(nodeId))
return;
try
{
var response = await _client.SendAsync<UpdateScenarioCounterRequest, UpdateScenarioCounterResponse>(
"POST",
"/sdk/v1/scenarios/counter",
new UpdateScenarioCounterRequest
{
ScenarioId = run.Plan.ScenarioId,
NodeId = nodeId,
CounterKey = counterKey,
Amount = amount,
RunId = run.RunId
},
cancellationToken).ConfigureAwait(false);
// The server reports objective completion; it no longer returns a plan from the
// counter endpoint. On completion, cross the node's onComplete handle (which
// advances the run) — idempotent if the consumer also completes the session.
if (response != null && response.Completed == true)
await CompleteNodeAsync(runId, nodeId, "onComplete", cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Counter update failure does not fail the run.
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Scenario counter update failed at node {nodeId}: {ex.Message}");
}
} }
} }
-42
View File
@@ -1,42 +0,0 @@
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
/// <summary>Snapshot of one running scenario plan.</summary>
public sealed class PlanRun
{
internal PlanRun(
string runId,
string planId,
string scenarioId,
string userId,
IReadOnlyList<string> activeNodeIds,
ExecutionPlan plan)
{
RunId = runId;
PlanId = planId;
ScenarioId = scenarioId;
UserId = userId;
ActiveNodeIds = activeNodeIds;
Plan = plan;
}
/// <summary>Server-issued run id.</summary>
public string RunId { get; }
/// <summary>Plan id.</summary>
public string PlanId { get; }
/// <summary>Scenario id.</summary>
public string ScenarioId { get; }
/// <summary>Player the run belongs to.</summary>
public string UserId { get; }
/// <summary>Ids of the currently active nodes.</summary>
public IReadOnlyList<string> ActiveNodeIds { get; }
/// <summary>The execution plan being run.</summary>
public ExecutionPlan Plan { get; }
}
@@ -1,23 +0,0 @@
using System;
namespace RudderSdk.Core;
/// <summary>Payload of <see cref="ScenarioService.OnScenarioFailed"/>.</summary>
public sealed class ScenarioFailedEvent
{
internal ScenarioFailedEvent(PlanRun run, string nodeId, Exception exception)
{
Run = run;
NodeId = nodeId;
Exception = exception;
}
/// <summary>The failed run.</summary>
public PlanRun Run { get; }
/// <summary>Node the failure happened at.</summary>
public string NodeId { get; }
/// <summary>The error that failed the run.</summary>
public Exception Exception { get; }
}
@@ -1,78 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
/// <summary>Context of the scenario node a session was created for.</summary>
public class ScenarioNodeContext
{
private readonly ScenarioService _service;
internal ScenarioNodeContext(ScenarioService service, PlanRun run, ExecutionPlanNode node)
{
_service = service;
Run = run;
Node = node;
Data = node?.Data as JObject ?? new JObject();
}
/// <summary>The run this node belongs to.</summary>
public PlanRun Run { get; }
/// <summary>The plan node.</summary>
public ExecutionPlanNode Node { get; }
/// <summary>Run id.</summary>
public string RunId => Run.RunId;
/// <summary>Plan id.</summary>
public string PlanId => Run.PlanId;
/// <summary>Scenario id.</summary>
public string ScenarioId => Run.ScenarioId;
/// <summary>Node id.</summary>
public string NodeId => Node.Id;
/// <summary>Node type.</summary>
public string Type => Node.Type;
/// <summary>Node data payload.</summary>
public JObject Data { get; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!)
{
if (Data == null || !Data.TryGetValue(key, out var value))
return defaultValue;
try { return value.ToObject<T>() ?? defaultValue; }
catch { return defaultValue; }
}
/// <summary>Deserializes the whole node data payload.</summary>
public T Get<T>()
{
try { return Data == null ? default! : Data.ToObject<T>() ?? default!; }
catch { return default!; }
}
/// <summary>Returns the node data as a plain dictionary.</summary>
public Dictionary<string, object> AsObjectDictionary()
{
return Data?.ToObject<Dictionary<string, object>>() ?? new Dictionary<string, object>();
}
internal Task CompleteAsync(string handle, CancellationToken cancellationToken = default)
=> _service.CompleteNodeAsync(RunId, NodeId, handle, cancellationToken);
internal void Complete(string handle)
{
_ = CompleteAsync(handle);
}
internal Task AddProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
=> _service.UpdateProgressAsync(RunId, NodeId, counterKey, amount, cancellationToken);
}
@@ -1,193 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Core;
public sealed partial class ScenarioService
{
private void DispatchActiveNode(RuntimeRun run, ActiveNodeState state, bool restored)
{
var node = FindNode(run.Plan, state.NodeId);
if (node == null)
{
run.ActiveNodes.Remove(state.NodeId);
CheckRunCompleted(run);
return;
}
var context = new ScenarioNodeContext(this, ToPlanRun(run), node);
switch (node.Type ?? string.Empty)
{
case WaitNode:
DispatchWait(run, state, context);
break;
case RemoteConfigOverrideNode:
DispatchRemoteConfigOverride(run, state, context);
break;
case NotificationNode:
EmitNotification(context);
break;
case StoreNode:
EmitStoreOffer(context);
break;
case QuestNode:
EmitQuest(context);
break;
case LeaderboardNode:
EmitLeaderboard(context);
break;
case BattlePassNode:
EmitBattlePass(context);
break;
case BattlePassLevelNode:
EmitBattlePassLevel(context);
break;
default:
// Unsupported node type — fail the run (surfaced via OnScenarioFailed)
// instead of leaving it stalled on a node no handler will complete.
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Unsupported scenario node type '{node.Type}' ({node.Id}).");
FailRun(run, state.NodeId, new Exception($"Unsupported scenario node type '{node.Type}'"));
break;
}
}
private void DispatchWait(RuntimeRun run, ActiveNodeState state, ScenarioNodeContext context)
{
if (!state.WaitDeadlineUtc.HasValue)
{
// Prefer server-provided WaitDeadline from the plan boundary over local calculation.
// The server stamps WaitDeadline on server-enforced wait boundaries (see StampBoundaries).
var boundary = run.Plan.BoundaryNodes?.FirstOrDefault(
b => b.SourceNodeId == state.NodeId && b.WaitDeadline.HasValue
);
if (boundary?.WaitDeadline is { } parsed)
{
state.WaitDeadlineUtc = parsed;
}
else
{
var delay = GetWaitDelay(context.Data);
state.WaitDeadlineUtc = _client.Clock.UtcNow.Add(delay);
}
Persist();
}
var session = new WaitSession(context, state.WaitDeadlineUtc.Value);
OnWait?.Invoke(session);
if (_client.Clock.UtcNow >= state.WaitDeadlineUtc.Value)
_ = CompleteNodeAsync(run.RunId, state.NodeId, "onComplete");
}
private void DispatchRemoteConfigOverride(RuntimeRun run, ActiveNodeState state, ScenarioNodeContext context)
{
var patches = context.Data["patches"] as JArray;
if (patches != null)
{
foreach (var patchToken in patches.OfType<JObject>())
{
var key = patchToken.Value<string>("path");
if (string.IsNullOrEmpty(key))
continue;
var valueType = patchToken.Value<string>("valueType") ?? "json";
var value = SerializeRemoteConfigValue(patchToken["value"], valueType);
_client.RemoteConfig.ApplyOverride(key, value, valueType);
}
}
var session = new ConfigChangedSession(context);
OnConfigChanged?.Invoke(session);
_ = CompleteNodeAsync(run.RunId, state.NodeId, "output");
}
private void EmitNotification(ScenarioNodeContext context)
{
OnNotification?.Invoke(new NotificationSession(context));
}
private void EmitStoreOffer(ScenarioNodeContext context)
{
OnStoreOffer?.Invoke(new StoreOfferSession(context));
}
private void EmitQuest(ScenarioNodeContext context)
{
OnQuest?.Invoke(new QuestSession(context));
}
private void EmitLeaderboard(ScenarioNodeContext context)
{
OnLeaderboard?.Invoke(new LeaderboardSession(context));
}
private void EmitBattlePass(ScenarioNodeContext context)
{
OnBattlePass?.Invoke(new BattlePassSession(context, _client.BattlePass));
}
private void EmitBattlePassLevel(ScenarioNodeContext context)
{
OnBattlePassLevel?.Invoke(new BattlePassLevelSession(context));
}
private static TimeSpan GetWaitDelay(JObject data)
{
var duration = data.Value<double?>("duration") ?? 0;
var unit = data.Value<string>("unit") ?? "seconds";
if (duration <= 0)
return TimeSpan.Zero;
switch (unit)
{
case "days":
case "day":
case "d":
return TimeSpan.FromDays(duration);
case "hours":
case "hour":
case "hr":
case "h":
return TimeSpan.FromHours(duration);
case "minutes":
case "minute":
case "min":
case "m":
return TimeSpan.FromMinutes(duration);
case "seconds":
case "second":
case "sec":
case "s":
return TimeSpan.FromSeconds(duration);
default:
return TimeSpan.FromSeconds(duration);
}
}
private static string? SerializeRemoteConfigValue(JToken? token, string valueType)
{
if (token == null || token.Type == JTokenType.Null)
return null;
switch ((valueType ?? string.Empty).ToLowerInvariant())
{
case "string":
return token.Type == JTokenType.String ? token.Value<string>() : token.ToString(Formatting.None);
case "bool":
case "boolean":
return token.Value<bool>().ToString().ToLowerInvariant();
case "int":
case "integer":
return token.Value<long>().ToString(CultureInfo.InvariantCulture);
case "float":
case "double":
return token.Value<double>().ToString(CultureInfo.InvariantCulture);
default:
return token.Type == JTokenType.String ? token.Value<string>() : token.ToString(Formatting.None);
}
}
}
@@ -1,13 +0,0 @@
namespace RudderSdk.Core;
public sealed partial class ScenarioService
{
private const string NotificationNode = "notification";
private const string StoreNode = "store";
private const string WaitNode = "wait";
private const string RemoteConfigOverrideNode = "remote_config_override";
private const string QuestNode = "quest";
private const string LeaderboardNode = "leaderboard";
private const string BattlePassNode = "battlepass";
private const string BattlePassLevelNode = "battlepass_level";
}
@@ -1,92 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
public sealed partial class ScenarioService
{
private void Persist()
{
var store = _client.Options.PlanStateStore;
if (store == null)
return;
if (_runs.Count == 0)
{
store.State = null;
return;
}
var state = new PersistedScenarioState
{
Runs = _runs.Values.Select(run => run.ToPersisted()).ToList()
};
store.State = JsonConvert.SerializeObject(state);
}
private sealed class RuntimeRun
{
public RuntimeRun(string runId, ExecutionPlan plan)
{
RunId = runId;
Plan = plan;
}
public string RunId { get; }
public ExecutionPlan Plan { get; }
public Dictionary<string, ActiveNodeState> ActiveNodes { get; } = new();
public HashSet<string> CompletedHandles { get; } = new(StringComparer.Ordinal);
public PersistedRun ToPersisted()
{
return new PersistedRun
{
RunId = RunId,
Plan = Plan,
ActiveNodes = ActiveNodes.Values.ToList(),
CompletedHandles = CompletedHandles.ToList()
};
}
public static RuntimeRun FromPersisted(PersistedRun saved)
{
var run = new RuntimeRun(saved.RunId, saved.Plan);
if (saved.ActiveNodes != null)
{
foreach (var node in saved.ActiveNodes)
{
if (!string.IsNullOrEmpty(node?.NodeId))
run.ActiveNodes[node.NodeId] = node;
}
}
if (saved.CompletedHandles != null)
{
foreach (var handle in saved.CompletedHandles)
run.CompletedHandles.Add(handle);
}
return run;
}
}
private sealed class PersistedScenarioState
{
public List<PersistedRun>? Runs { get; set; }
}
private sealed class PersistedRun
{
public string RunId { get; set; } = string.Empty;
public ExecutionPlan Plan { get; set; } = null!;
public List<ActiveNodeState>? ActiveNodes { get; set; }
public List<string>? CompletedHandles { get; set; }
}
private sealed class ActiveNodeState
{
public string NodeId { get; set; } = string.Empty;
public DateTimeOffset? WaitDeadlineUtc { get; set; }
}
}
@@ -1,348 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core;
/// <summary>
/// Thrown when a boundary HTTP call fails with a transient error
/// (network failure, timeout, or server 5xx). The caller should NOT advance
/// the run; the node stays active and the handle stays pending for retry.
/// </summary>
public sealed class TransientBoundaryException : Exception
{
/// <summary>Creates the exception.</summary>
public TransientBoundaryException(string message, Exception inner)
: base(message, inner)
{
}
/// <summary>Creates the exception wrapping the transport error.</summary>
public TransientBoundaryException(Exception inner)
: base($"Transient boundary error: {inner.Message}", inner)
{
}
}
public sealed partial class ScenarioService
{
/// <summary>
/// Returns true for exceptions that may succeed on retry
/// (timeout / network failures).
/// </summary>
private static bool IsTransientException(Exception ex)
{
return ex is OperationCanceledException || ex is RudderNetworkException;
}
private static bool IsRankNotEligible(Exception ex)
{
return ex is RudderApiException api && api.Code == "rank_not_eligible";
}
private IReadOnlyList<PlanRun> StartPlans(IEnumerable<ExecutionPlan>? plans)
{
var started = new List<PlanRun>();
if (plans == null)
return started;
foreach (var plan in plans)
{
var run = StartPlan(plan);
if (run != null)
started.Add(ToPlanRun(run));
}
return started;
}
private RuntimeRun? StartPlan(ExecutionPlan? plan)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return null;
// Dedup: server returned same runId — skip without restarting.
if (!string.IsNullOrEmpty(plan.RunId) && _runs.ContainsKey(plan.RunId))
return null;
if (plan.BoundaryNodes?.Count > 0 && string.IsNullOrEmpty(plan.RunId))
throw new InvalidOperationException("ExecutionPlan has boundaryNodes but missing RunId.");
var runId = plan.RunId ?? Guid.NewGuid().ToString("N");
var startNode = FindNode(plan, plan.StartNodeId) ?? plan.Nodes[0];
var run = new RuntimeRun(runId, plan);
_runs[run.RunId] = run;
ActivateNode(run, startNode.Id);
Persist();
return run;
}
/// <summary>
/// Replaces an existing run's plan with a server-provided continuation (same RunId):
/// the previous segment is done, the new segment's start node becomes active.
/// Idempotent: if the continuation was already applied, nothing is re-dispatched.
/// </summary>
private void ReplaceRun(ExecutionPlan? plan, string? fallbackRunId = null)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return;
var runId = plan.RunId ?? fallbackRunId;
if (string.IsNullOrEmpty(runId))
return;
var startNode = FindNode(plan, plan.StartNodeId) ?? plan.Nodes[0];
if (startNode == null)
return;
if (_runs.TryGetValue(runId, out var existing) && existing.ActiveNodes.ContainsKey(startNode.Id))
return; // continuation already applied (idempotent callback echo)
var run = new RuntimeRun(runId, plan);
_runs[runId] = run;
ActivateNode(run, startNode.Id);
Persist();
}
private void ActivateNode(RuntimeRun run, string nodeId, ActiveNodeState? restoredState = null)
{
var node = FindNode(run.Plan, nodeId);
if (node == null)
return;
var state = restoredState ?? new ActiveNodeState { NodeId = nodeId };
run.ActiveNodes[nodeId] = state;
DispatchActiveNode(run, state, restored: restoredState != null);
}
internal Task CompleteNodeAsync(
string runId,
string nodeId,
string handle,
CancellationToken cancellationToken = default)
{
return CompleteNodeAsync(runId, nodeId, handle, continueOnBoundary: true, cancellationToken);
}
private async Task CompleteNodeAsync(
string runId,
string nodeId,
string handle,
bool continueOnBoundary,
CancellationToken cancellationToken = default)
{
if (!_runs.TryGetValue(runId, out var run))
return;
var key = CompletedHandleKey(nodeId, handle);
if (run.CompletedHandles.Contains(key))
return; // idempotent
try
{
var continuedOnBoundary = false;
if (continueOnBoundary)
{
try
{
continuedOnBoundary = await ContinueBoundaryAsync(
run, nodeId, handle, cancellationToken).ConfigureAwait(false);
}
catch (TransientBoundaryException)
{
// Transient error — don't advance the run.
// Node stays active, handle stays pending for retry on reconnect.
return;
}
}
if (continuedOnBoundary)
{
// The boundary may have replaced or removed the run (continuation plan
// or reconcile). The transferred run owns the state — touching the stale
// object here would complete or delete the new run.
if (!_runs.TryGetValue(runId, out var currentRun) || !ReferenceEquals(currentRun, run))
return;
}
// Only now — after the server has confirmed — mark the handle and node.
run.CompletedHandles.Add(key);
run.ActiveNodes.Remove(nodeId);
Persist();
if (!continuedOnBoundary)
{
foreach (var edge in MatchingEdges(run.Plan, nodeId, handle))
ActivateNode(run, edge.Target);
}
CheckRunCompleted(run);
Persist();
}
catch (Exception ex)
{
if (IsRankNotEligible(ex))
throw;
FailRun(run, nodeId, ex);
}
}
private async Task<bool> ContinueBoundaryAsync(
RuntimeRun run,
string nodeId,
string handle,
CancellationToken cancellationToken)
{
var boundaries = MatchingBoundaryNodes(run.Plan, nodeId, handle).ToList();
if (boundaries.Count == 0)
return false;
foreach (var boundary in boundaries)
{
try
{
var response = await _client.SendAsync<HandleScenarioCallbackRequest, HandleScenarioCallbackResponse>(
"POST",
"/sdk/v1/scenarios/callback",
new HandleScenarioCallbackRequest
{
ScenarioId = run.Plan.ScenarioId,
NodeId = boundary.SourceNodeId,
Handle = boundary.SourceHandle,
RunId = run.RunId
},
cancellationToken).ConfigureAwait(false);
if (response?.Plan != null)
{
// Continuation of the current run (server keeps the RunId) replaces
// the run's plan; fresh/terminal plans start as new runs.
if (_runs.ContainsKey(response.Plan.RunId ?? string.Empty))
ReplaceRun(response.Plan);
else
StartPlan(response.Plan);
}
}
catch (Exception ex)
{
// Boundary call failed — try to reconcile with server.
var reconciled = false;
try
{
var reconcile = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
"POST",
"/sdk/v1/scenarios/run",
new GetScenarioRunRequest { RunId = run.RunId },
cancellationToken).ConfigureAwait(false);
if (reconcile?.Status == "unknown_run" || reconcile?.Status == "expired")
{
_runs.Remove(run.RunId);
Persist();
reconciled = true;
}
if (reconcile?.Plan != null)
{
ReplaceRun(reconcile.Plan, run.RunId);
reconciled = true;
}
}
catch
{
// Reconciliation also failed.
}
if (!reconciled)
{
// Reconcile did not resolve — distinguish transient from terminal.
if (IsTransientException(ex))
throw new TransientBoundaryException(ex);
// Terminal error — let the caller fail the run.
throw;
}
// If reconciled, the boundary was handled (run corrected or removed).
// Fall through to continue to the next boundary.
}
}
return true;
}
private void CheckRunCompleted(RuntimeRun run)
{
if (run.ActiveNodes.Count > 0)
return;
_runs.Remove(run.RunId);
Persist();
OnScenarioCompleted?.Invoke(ToPlanRun(run));
}
private void FailRun(RuntimeRun run, string nodeId, Exception ex)
{
_client.Options.Logger?.Log(RudderLogLevel.Error, $"[Rudder] Scenario run {run.RunId} failed at node {nodeId}: {ex.Message}");
_runs.Remove(run.RunId);
Persist();
OnScenarioFailed?.Invoke(new ScenarioFailedEvent(ToPlanRun(run), nodeId, ex));
}
private static PlanRun ToPlanRun(RuntimeRun run)
{
return new PlanRun(
run.RunId,
run.Plan.PlanId,
run.Plan.ScenarioId,
run.Plan.UserId,
run.ActiveNodes.Keys.ToList(),
run.Plan);
}
private static ExecutionPlanNode? FindNode(ExecutionPlan? plan, string? nodeId)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return null;
if (!string.IsNullOrEmpty(nodeId))
{
foreach (var node in plan.Nodes)
{
if (node.Id == nodeId)
return node;
}
}
return null;
}
private static IEnumerable<PlanEdge> MatchingEdges(ExecutionPlan plan, string sourceNodeId, string sourceHandle)
{
if (plan?.Edges == null)
yield break;
foreach (var edge in plan.Edges)
{
if (edge.Source == sourceNodeId && string.Equals(edge.SourceHandle ?? string.Empty, sourceHandle ?? string.Empty, StringComparison.Ordinal))
yield return edge;
}
}
private static IEnumerable<BoundaryNode> MatchingBoundaryNodes(ExecutionPlan plan, string sourceNodeId, string sourceHandle)
{
if (plan?.BoundaryNodes == null)
yield break;
foreach (var boundary in plan.BoundaryNodes)
{
if (boundary.SourceNodeId == sourceNodeId && string.Equals(boundary.SourceHandle ?? string.Empty, sourceHandle ?? string.Empty, StringComparison.Ordinal))
yield return boundary;
}
}
private static string CompletedHandleKey(string nodeId, string? handle) => nodeId + ":" + (handle ?? string.Empty);
}
@@ -1,32 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>
/// Session of a scenario battle-pass-level node — a single claimable tier.
/// <see cref="ClaimAsync"/> crosses onComplete, which the server accepts only
/// once the player has reached the node's configured level.
/// </summary>
public sealed class BattlePassLevelSession
{
internal BattlePassLevelSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>The tier level this node claims.</summary>
public int Level => Context.Get("levelNumber", 0);
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Claims this tier; crosses onComplete (the server checks the level was reached).</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
/// <summary>Claims this tier (fire-and-forget).</summary>
public void Claim() => Context.Complete("onComplete");
}
@@ -1,95 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Models.BattlePass;
namespace RudderSdk.Core;
/// <summary>
/// Session of a scenario battle-pass node. Exposes the battle pass operations
/// bound to this node's scenario/node/run ids, plus explicit boundary crossings
/// the game drives from its UI; the server validates each crossing.
/// </summary>
public sealed class BattlePassSession
{
private readonly BattlePassService _battlePass;
internal BattlePassSession(ScenarioNodeContext context, BattlePassService battlePass)
{
Context = context;
_battlePass = battlePass;
}
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Reads current progress (xp, level, premium ownership, claimed tiers) for this node.</summary>
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken cancellationToken = default)
=> _battlePass.GetProgressAsync(Context.ScenarioId, Context.NodeId, cancellationToken);
/// <summary>Credits xp from a configured source.</summary>
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken cancellationToken = default)
=> _battlePass.AddXpAsync(new AddBattlePassXpRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
Source = source,
Amount = amount
}, cancellationToken);
/// <summary>
/// Claims a tier reward at a reached level.
/// <paramref name="track"/> is <see cref="BattlePassService.TrackFree"/> or <see cref="BattlePassService.TrackPremium"/>.
/// </summary>
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken cancellationToken = default)
=> _battlePass.ClaimRewardAsync(new ClaimBattlePassRewardRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
Level = level,
Track = track
}, cancellationToken);
/// <summary>Purchases the premium track, then crosses onPremiumPurchase on success.</summary>
public async Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken cancellationToken = default)
{
var response = await _battlePass.PurchasePremiumAsync(new PurchaseBattlePassPremiumRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
IdempotencyKey = Guid.NewGuid().ToString()
}, cancellationToken).ConfigureAwait(false);
if (response != null && response.Success == true)
await Context.CompleteAsync("onPremiumPurchase", cancellationToken).ConfigureAwait(false);
return response!;
}
/// <summary>Advances the run through the onLevelUp handle.</summary>
public Task LevelUpAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onLevelUp", cancellationToken);
/// <summary>Advances the run through the onLevelUp handle (fire-and-forget).</summary>
public void LevelUp() => Context.Complete("onLevelUp");
/// <summary>Advances the run through the onMaxLevel handle.</summary>
public Task MaxLevelAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onMaxLevel", cancellationToken);
/// <summary>Advances the run through the onMaxLevel handle (fire-and-forget).</summary>
public void MaxLevel() => Context.Complete("onMaxLevel");
/// <summary>Advances the run through the onComplete handle.</summary>
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
/// <summary>Advances the run through the onComplete handle (fire-and-forget).</summary>
public void Complete() => Context.Complete("onComplete");
}
@@ -1,13 +0,0 @@
namespace RudderSdk.Core;
/// <summary>
/// Session of a scenario remote-config-override node. The patches are already
/// applied to <see cref="RemoteConfigService"/> when the event fires.
/// </summary>
public sealed class ConfigChangedSession
{
internal ConfigChangedSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
}
@@ -1,40 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>Session of a scenario leaderboard node.</summary>
public sealed class LeaderboardSession
{
internal LeaderboardSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Advances the run through the onEnd handle.</summary>
public Task EndAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onEnd", cancellationToken);
/// <summary>Advances the run through the onEnd handle (fire-and-forget).</summary>
public void End() => Context.Complete("onEnd");
/// <summary>Advances the run through the onClaim handle. The server matches live rank to a place.</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onClaim", cancellationToken);
/// <summary>Advances the run through the onClaim handle (fire-and-forget).</summary>
public void Claim() => Context.Complete("onClaim");
/// <summary>Advances the run through the onClaim handle.</summary>
[Obsolete("Use ClaimAsync. Removed in the next SDK version.")]
public Task RewardClaimedAsync(CancellationToken cancellationToken = default) => ClaimAsync(cancellationToken);
/// <summary>Advances the run through the onClaim handle (fire-and-forget).</summary>
[Obsolete("Use Claim. Removed in the next SDK version.")]
public void RewardClaimed() => Claim();
}
@@ -1,25 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>Session of a scenario notification node.</summary>
public sealed class NotificationSession
{
internal NotificationSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Advances the run through the output handle.</summary>
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("output", cancellationToken);
/// <summary>Advances the run through the output handle (fire-and-forget).</summary>
public void Complete() => Context.Complete("output");
}
@@ -1,37 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>Session of a scenario quest node.</summary>
public sealed class QuestSession
{
internal QuestSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Adds progress to one of the node's counters.</summary>
public Task AddProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default) => Context.AddProgressAsync(counterKey, amount, cancellationToken);
/// <summary>Adds progress to one of the node's counters (fire-and-forget).</summary>
public void AddProgress(string counterKey, long amount) => _ = AddProgressAsync(counterKey, amount);
/// <summary>Advances the run through the onComplete handle.</summary>
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
/// <summary>Advances the run through the onComplete handle (fire-and-forget).</summary>
public void Complete() => Context.Complete("onComplete");
/// <summary>Advances the run through the onFail handle.</summary>
public Task FailAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onFail", cancellationToken);
/// <summary>Advances the run through the onFail handle (fire-and-forget).</summary>
public void Fail() => Context.Complete("onFail");
}
@@ -1,56 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>Session of a scenario store-offer node; resolve it with a purchase or a decline.</summary>
public sealed class StoreOfferSession
{
internal StoreOfferSession(ScenarioNodeContext context)
{
Context = context;
Data = context.AsObjectDictionary();
}
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Node data payload.</summary>
public IReadOnlyDictionary<string, object> Data { get; }
/// <summary>True after the session was resolved once.</summary>
public bool IsResolved { get; private set; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Resolves the offer as purchased.</summary>
public Task PurchaseAsync(CancellationToken cancellationToken = default) => ResolveAsync("onPurchase", cancellationToken);
/// <summary>Resolves the offer as purchased (fire-and-forget).</summary>
public void Purchase() => Resolve("onPurchase");
/// <summary>Resolves the offer as declined.</summary>
public Task DeclineAsync(CancellationToken cancellationToken = default) => ResolveAsync("onDecline", cancellationToken);
/// <summary>Resolves the offer as declined (fire-and-forget).</summary>
public void Decline() => Resolve("onDecline");
private async Task ResolveAsync(string handle, CancellationToken cancellationToken)
{
if (IsResolved) return;
IsResolved = true;
await Context.CompleteAsync(handle, cancellationToken).ConfigureAwait(false);
}
private void Resolve(string handle)
{
if (IsResolved) return;
IsResolved = true;
Context.Complete(handle);
}
}
@@ -1,19 +0,0 @@
using System;
namespace RudderSdk.Core;
/// <summary>Session of a scenario wait node; the run continues automatically at the deadline.</summary>
public sealed class WaitSession
{
internal WaitSession(ScenarioNodeContext context, DateTimeOffset deadlineUtc)
{
Context = context;
DeadlineUtc = deadlineUtc;
}
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>When the wait ends (UTC).</summary>
public DateTimeOffset DeadlineUtc { get; }
}
+3 -3
View File
@@ -35,17 +35,17 @@ public sealed class StoresService
/// </summary> /// </summary>
public Task<PurchaseOfferResponse> PurchaseAsync( public Task<PurchaseOfferResponse> PurchaseAsync(
string storeSlug, string storeSlug,
string offerId, string offerSlug,
string? idempotencyKey = null, string? idempotencyKey = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return _client.SendAsync<PurchaseOfferRequest, PurchaseOfferResponse>( return _client.SendAsync<PurchaseOfferRequest, PurchaseOfferResponse>(
"POST", "POST",
"/sdk/v1/stores/" + Url.Encode(storeSlug) + "/offers/" + Url.Encode(offerId) + "/purchase", "/sdk/v1/stores/" + Url.Encode(storeSlug) + "/offers/" + Url.Encode(offerSlug) + "/purchase",
new PurchaseOfferRequest new PurchaseOfferRequest
{ {
StoreSlug = storeSlug, StoreSlug = storeSlug,
OfferId = offerId, OfferSlug = offerSlug,
IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString() IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString()
}, },
cancellationToken); cancellationToken);
+3
View File
@@ -25,6 +25,9 @@ public class Offer
[JsonProperty("price")] [JsonProperty("price")]
public OfferPrice? Price { get; set; } public OfferPrice? Price { get; set; }
[JsonProperty("slug")]
public string? Slug { get; set; }
[JsonProperty("updatedAt")] [JsonProperty("updatedAt")]
public DateTimeOffset? UpdatedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; }
+2 -2
View File
@@ -8,8 +8,8 @@ public class PurchaseOfferRequest
[JsonProperty("idempotencyKey")] [JsonProperty("idempotencyKey")]
public string? IdempotencyKey { get; set; } public string? IdempotencyKey { get; set; }
[JsonProperty("offerId")] [JsonProperty("offerSlug")]
public string? OfferId { get; set; } public string? OfferSlug { get; set; }
[JsonProperty("storeSlug")] [JsonProperty("storeSlug")]
public string? StoreSlug { get; set; } public string? StoreSlug { get; set; }
+16 -16
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 `ScenarioSlug`/`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.
+18 -1
View File
@@ -24,6 +24,19 @@ public void Logout(); // drops the stored session, fires SignedOut
`RudderAuthState`: `SignedIn`, `SignedOut`. The event fires after a `RudderAuthState`: `SignedIn`, `SignedOut`. The event fires after a
successful login and after logout or a failed token refresh. successful login and after logout or a failed token refresh.
## Environments
A project has two environments, `staging` and `prod`. The SDK key configured
in `RudderClientOptions.ProjectKey` belongs to one of them, so the environment
is resolved at login and carried inside the access and refresh tokens; nothing
in the client API takes an environment argument, and a player created in one
environment is invisible in the other. Content released only to `staging` is
empty for a `prod` key and vice versa.
Tokens issued before SDK 2.0.0 carry no environment claim and are rejected with
401. The refresh pipeline then fails, clears the store and fires
`AuthStateChanged(SignedOut)` — log the player in again.
## Endpoints ## Endpoints
- `POST /sdk/v1/authorization/device` — body `{key, deviceId, region, - `POST /sdk/v1/authorization/device` — body `{key, deviceId, region,
@@ -48,7 +61,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 +71,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 `ScenarioSlug`/`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
@@ -18,7 +18,7 @@ public const string TrackPremium = "premium";
// POST /sdk/v1/battlepass/progress // POST /sdk/v1/battlepass/progress
public Task<GetBattlePassProgressResponse> GetProgressAsync( public Task<GetBattlePassProgressResponse> GetProgressAsync(
string scenarioId, string nodeId, CancellationToken cancellationToken = default); string scenarioSlug, string nodeId, CancellationToken cancellationToken = default);
// POST /sdk/v1/battlepass/xp // POST /sdk/v1/battlepass/xp
public Task<AddBattlePassXpResponse> AddXpAsync( public Task<AddBattlePassXpResponse> AddXpAsync(
@@ -40,7 +40,7 @@ public Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(
public class GetBattlePassProgressRequest public class GetBattlePassProgressRequest
{ {
public string? NodeId { get; set; } // "nodeId" public string? NodeId { get; set; } // "nodeId"
public string? ScenarioId { get; set; } // "scenarioId" public string? ScenarioSlug { get; set; } // "scenarioSlug"
} }
public class AddBattlePassXpRequest public class AddBattlePassXpRequest
@@ -48,7 +48,7 @@ public class AddBattlePassXpRequest
public long? Amount { get; set; } // "amount" public long? Amount { get; set; } // "amount"
public string? NodeId { get; set; } // "nodeId" public string? NodeId { get; set; } // "nodeId"
public string? RunId { get; set; } // "runId" public string? RunId { get; set; } // "runId"
public string? ScenarioId { get; set; } // "scenarioId" public string? ScenarioSlug { get; set; } // "scenarioSlug"
public string? Source { get; set; } // "source" public string? Source { get; set; } // "source"
} }
@@ -57,7 +57,7 @@ public class ClaimBattlePassRewardRequest
public int? Level { get; set; } // "level" public int? Level { get; set; } // "level"
public string? NodeId { get; set; } // "nodeId" public string? NodeId { get; set; } // "nodeId"
public string? RunId { get; set; } // "runId" public string? RunId { get; set; } // "runId"
public string? ScenarioId { get; set; } // "scenarioId" public string? ScenarioSlug { get; set; } // "scenarioSlug"
public string? Track { get; set; } // "track" — TrackFree / TrackPremium public string? Track { get; set; } // "track" — TrackFree / TrackPremium
} }
@@ -66,7 +66,7 @@ public class PurchaseBattlePassPremiumRequest
public string? IdempotencyKey { get; set; } // "idempotencyKey" public string? IdempotencyKey { get; set; } // "idempotencyKey"
public string? NodeId { get; set; } // "nodeId" public string? NodeId { get; set; } // "nodeId"
public string? RunId { get; set; } // "runId" public string? RunId { get; set; } // "runId"
public string? ScenarioId { get; set; } // "scenarioId" public string? ScenarioSlug { get; set; } // "scenarioSlug"
} }
``` ```
@@ -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 ScenarioSlug { 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.
+5 -5
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
@@ -14,10 +14,10 @@ scenarios.md.
public Task<IReadOnlyList<Quest>> ListAsync(CancellationToken cancellationToken = default); public Task<IReadOnlyList<Quest>> ListAsync(CancellationToken cancellationToken = default);
// POST /sdk/v1/quests/claim — claims a completed quest's rewards (idempotent server-side) // POST /sdk/v1/quests/claim — claims a completed quest's rewards (idempotent server-side)
public Task<ClaimQuestResponse> ClaimAsync(string questId, CancellationToken cancellationToken = default); public Task<ClaimQuestResponse> ClaimAsync(string questSlug, CancellationToken cancellationToken = default);
// POST /sdk/v1/quests/progress — reports progress for a metric; returns the // POST /sdk/v1/quests/progress — reports progress for a metric; returns the
// ids of quests completed by THIS report // slugs of quests completed by THIS report
public Task<IReadOnlyList<string>> ReportProgressAsync( public Task<IReadOnlyList<string>> ReportProgressAsync(
string metric, long amount, CancellationToken cancellationToken = default); string metric, long amount, CancellationToken cancellationToken = default);
``` ```
@@ -27,7 +27,7 @@ public Task<IReadOnlyList<string>> ReportProgressAsync(
```csharp ```csharp
public static class QuestMetrics public static class QuestMetrics
{ {
public static string PurchaseOffer(string offerId); // "purchase.offer:{offerId}" public static string PurchaseOffer(string offerSlug); // "purchase.offer:{offerSlug}"
public static string PurchaseItem(string itemId); // "purchase.item:{itemId}" public static string PurchaseItem(string itemId); // "purchase.item:{itemId}"
} }
``` ```
@@ -40,10 +40,10 @@ metric is a custom string passed to `ReportProgressAsync`.
```csharp ```csharp
public class Quest public class Quest
{ {
public string? Id { get; set; } // "id"
public string? Name { get; set; } // "name" public string? Name { get; set; } // "name"
public List<QuestObjectiveProgress>? Objectives { get; set; }// "objectives" public List<QuestObjectiveProgress>? Objectives { get; set; }// "objectives"
public List<Reward>? Rewards { get; set; } // "rewards" public List<Reward>? Rewards { get; set; } // "rewards"
public string? Slug { get; set; } // "slug" — stable across environments
public string? Status { get; set; } // "status" public string? Status { get; set; } // "status"
} }
@@ -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>`.
+240 -112
View File
@@ -1,149 +1,277 @@
# 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. The
seen set is pruned against every pending fetch: keys the server no longer
lists are dropped, so the server stays the source of truth.
- 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`, `ScenarioSlug`, `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 {scenarioSlug, 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 `ScenarioSlug` / `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 ScenarioSlug { 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 ScenarioSlug { 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). It also fires when a run
disappears from the pending list with no keys left for it — the server
advanced a wait, the run expired, or another device completed it.
## Reliability
- Completion methods POST `/sdk/v1/scenarios/callback` with
`{scenarioSlug, 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`, `run_not_active` (`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 ScenarioSlug { 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? ScenarioSlug { 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? ScenarioSlug { 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.
+4 -3
View File
@@ -14,11 +14,11 @@ public Task<IReadOnlyList<Store>> ListAsync(CancellationToken cancellationToken
// GET /sdk/v1/stores/{slug} // GET /sdk/v1/stores/{slug}
public Task<Store> GetAsync(string slug, CancellationToken cancellationToken = default); public Task<Store> GetAsync(string slug, CancellationToken cancellationToken = default);
// POST /sdk/v1/stores/{storeSlug}/offers/{offerId}/purchase // POST /sdk/v1/stores/{storeSlug}/offers/{offerSlug}/purchase
// When idempotencyKey is null a random GUID is generated; pass a stable key to // When idempotencyKey is null a random GUID is generated; pass a stable key to
// make retries safe. // make retries safe.
public Task<PurchaseOfferResponse> PurchaseAsync( public Task<PurchaseOfferResponse> PurchaseAsync(
string storeSlug, string offerId, string? idempotencyKey = null, string storeSlug, string offerSlug, string? idempotencyKey = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
``` ```
@@ -44,6 +44,7 @@ public class Store
public class Offer public class Offer
{ {
public string? Id { get; set; } // "id" public string? Id { get; set; } // "id"
public string? Slug { get; set; } // "slug" — stable across environments
public string? Name { get; set; } // "name" public string? Name { get; set; } // "name"
public OfferPrice? Price { get; set; } // "price" public OfferPrice? Price { get; set; } // "price"
public List<OfferContent>? Contents { get; set; }// "contents" public List<OfferContent>? Contents { get; set; }// "contents"
@@ -65,5 +66,5 @@ public class PurchaseOfferResponse
`PurchaseOfferResponse.Error` carries a business error string when `PurchaseOfferResponse.Error` carries a business error string when
`Success` is false — check the flag instead of relying on exceptions alone. `Success` is false — check the flag instead of relying on exceptions alone.
Purchases also auto-report quest metrics (`purchase.offer:{offerId}`) — see Purchases also auto-report quest metrics (`purchase.offer:{offerSlug}`) — see
quests.md. quests.md.
+3 -65
View File
@@ -23,7 +23,7 @@ public sealed class RudderClientTests
Assert.NotNull(client.BattlePass); Assert.NotNull(client.BattlePass);
Assert.NotNull(client.Quests); Assert.NotNull(client.Quests);
Assert.NotNull(client.Scenario); Assert.NotNull(client.Scenario);
Assert.NotNull(client.Realtime); Assert.NotNull(client.Effects);
} }
[Fact] [Fact]
@@ -85,37 +85,17 @@ public sealed class RudderClientTests
Assert.Equal(new[] { RudderAuthState.SignedOut }, states); Assert.Equal(new[] { RudderAuthState.SignedOut }, states);
} }
[Fact]
public async Task Realtime_Connect_Returns_Session_From_Injected_Transport()
{
var realtimeTransport = new FakeRealtimeTransport();
var tokenStore = new FakeTokenStore { AccessToken = "access-token" };
var client = CreateClient(
tokenStore: tokenStore,
realtimeTransportFactory: new FakeRealtimeTransportFactory(realtimeTransport));
var session = await client.Realtime.ConnectAsync();
Assert.NotNull(session);
Assert.True(session.IsConnected);
Assert.Equal("access-token", session.AccessToken);
Assert.Equal(new Uri("ws://localhost:8090/api/realtime/ws"), realtimeTransport.Uri);
}
private static RudderClient CreateClient( private static RudderClient CreateClient(
FakeTransport? transport = null, FakeTransport? transport = null,
FakeTokenStore? tokenStore = null, FakeTokenStore? tokenStore = null)
IRealtimeTransportFactory? realtimeTransportFactory = null)
{ {
return new RudderClient(new RudderClientOptions return new RudderClient(new RudderClientOptions
{ {
BaseUrl = "http://localhost:8082", BaseUrl = "http://localhost:8082",
RealtimeUrl = "ws://localhost:8090/api/realtime/ws",
ProjectKey = "project-key", ProjectKey = "project-key",
Transport = transport ?? new FakeTransport(), Transport = transport ?? new FakeTransport(),
TokenStore = tokenStore ?? new FakeTokenStore(), TokenStore = tokenStore ?? new FakeTokenStore(),
DeviceIdProvider = new FakeDeviceIdProvider(), DeviceIdProvider = new FakeDeviceIdProvider()
RealtimeTransportFactory = realtimeTransportFactory ?? new FakeRealtimeTransportFactory(new FakeRealtimeTransport())
}); });
} }
@@ -169,46 +149,4 @@ public sealed class RudderClientTests
{ {
public string DeviceId => "device-id"; public string DeviceId => "device-id";
} }
private sealed class FakeRealtimeTransportFactory : IRealtimeTransportFactory
{
private readonly IRealtimeTransport _transport;
public FakeRealtimeTransportFactory(IRealtimeTransport transport) => _transport = transport;
public IRealtimeTransport Create() => _transport;
}
private sealed class FakeRealtimeTransport : IRealtimeTransport
{
public Uri? Uri { get; private set; }
public bool IsConnected { get; private set; }
public event Action? Closed;
public event Action<Exception>? Error;
public event Action<ArraySegment<byte>>? Received;
public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default)
{
Uri = uri;
IsConnected = true;
return Task.CompletedTask;
}
public Task SendAsync(ArraySegment<byte> payload, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task CloseAsync(CancellationToken cancellationToken = default)
{
IsConnected = false;
Closed?.Invoke();
return Task.CompletedTask;
}
public void Update(float deltaTime)
{
}
public void EmitError(Exception ex) => Error?.Invoke(ex);
public void EmitReceived(ArraySegment<byte> data) => Received?.Invoke(data);
}
} }
+370 -294
View File
@@ -2,6 +2,7 @@ using Newtonsoft.Json.Linq;
using RudderSdk.Core; using RudderSdk.Core;
using RudderSdk.Core.Abstractions; using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models; using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Auth;
using RudderSdk.Core.Models.Scenarios; using RudderSdk.Core.Models.Scenarios;
using Xunit; using Xunit;
@@ -11,60 +12,98 @@ namespace RudderSdk.Core.Tests;
public sealed class ScenarioServiceTests public sealed class ScenarioServiceTests
{ {
[Fact] [Fact]
public async Task TriggerAsync_Starts_All_Returned_Plans() public async Task TriggerAsync_Emits_Returned_Effects()
{ {
var transport = new FakeTransport(); var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse transport.Enqueue(new TriggerScenarioResponse
{ {
Plans = new List<ExecutionPlan> Effects = new List<PendingEffect>
{ {
Plan("plan-1", Node("n1", "notification")), Effect("run-1", "n1", "notification"),
Plan("plan-2", Node("n2", "notification")) Effect("run-2", "n2", "notification")
} }
}); });
var client = CreateClient(transport); var client = CreateClient(transport);
var notifications = new List<NotificationSession>(); var notifications = new List<NotificationEffect>();
client.Scenario.OnNotification += notifications.Add; client.Effects.OnNotification += notifications.Add;
var started = await client.Scenario.TriggerAsync("login"); await client.Scenario.TriggerAsync("login");
Assert.Equal(2, notifications.Count); Assert.Equal(2, notifications.Count);
Assert.Equal(2, started.Count); Assert.Equal("n1", notifications[0].NodeId);
Assert.Equal(2, client.Scenario.ActiveRuns.Count); Assert.Equal("n2", notifications[1].NodeId);
} }
[Fact] [Fact]
public async Task Completing_Node_Activates_All_Matching_Client_Edges() public async Task TriggerAsync_Dedups_By_RunId_And_NodeId()
{
var transport = new FakeTransport();
var effect = Effect("run-1", "n1", "notification");
transport.Enqueue(new TriggerScenarioResponse { Effects = new List<PendingEffect> { effect } });
transport.Enqueue(new TriggerScenarioResponse { Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") } });
var client = CreateClient(transport);
var notifications = 0;
client.Effects.OnNotification += _ => notifications++;
await client.Scenario.TriggerAsync("login");
await client.Scenario.TriggerAsync("login");
Assert.Equal(1, notifications);
}
[Fact]
public async Task Completing_Notification_Posts_Output_And_Ingests_Next_Effect()
{ {
var transport = new FakeTransport(); var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse transport.Enqueue(new TriggerScenarioResponse
{ {
Plans = new List<ExecutionPlan> Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification", new { title = "hi", message = "there" }) }
});
transport.Enqueue(new HandleScenarioCallbackResponse
{ {
Plan( Effect = JObject.FromObject(Effect("run-1", "store", "store"))
"plan",
new[] { Node("start", "notification"), Node("store", "store"), Node("wait", "wait", new { duration = 1, unit = "minutes" }) },
new[]
{
Edge("start", "output", "store"),
Edge("start", "output", "wait")
})
}
}); });
var client = CreateClient(transport); var client = CreateClient(transport);
NotificationSession? notification = null; NotificationEffect? notification = null;
var stores = 0; StoreOfferEffect? store = null;
var waits = 0; client.Effects.OnNotification += e => notification = e;
client.Scenario.OnNotification += session => notification = session; client.Effects.OnStoreOffer += e => store = e;
client.Scenario.OnStoreOffer += _ => stores++;
client.Scenario.OnWait += _ => waits++;
await client.Scenario.TriggerAsync("login"); await client.Scenario.TriggerAsync("login");
await notification!.CompleteAsync(); Assert.Equal("hi", notification!.Title);
Assert.Equal("there", notification.Message);
await notification.DoneAsync();
Assert.Equal(1, stores); var callback = Assert.IsType<HandleScenarioCallbackRequest>(
Assert.Equal(1, waits); transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal(2, client.Scenario.ActiveRuns.Single().ActiveNodeIds.Count); Assert.Equal("n1", callback.NodeId);
Assert.Equal("output", callback.Handle);
Assert.Equal("run-1", callback.RunId);
Assert.NotNull(store);
Assert.Equal("store", store!.NodeId);
}
[Fact]
public async Task Completing_Last_Effect_Raises_ScenarioCompleted()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
});
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
NotificationEffect? notification = null;
ScenarioCompletedEffect? completed = null;
client.Effects.OnNotification += e => notification = e;
client.Effects.OnScenarioCompleted += e => completed = e;
await client.Scenario.TriggerAsync("login");
await notification!.DoneAsync();
Assert.NotNull(completed);
Assert.Equal("run-1", completed!.RunId);
Assert.Equal("scenario-1", completed.ScenarioSlug);
} }
[Fact] [Fact]
@@ -73,224 +112,166 @@ public sealed class ScenarioServiceTests
var transport = new FakeTransport(); var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse transport.Enqueue(new TriggerScenarioResponse
{ {
Plans = new List<ExecutionPlan> Effects = new List<PendingEffect>
{ {
Plan( Effect("run-1", "n1", "notification"),
"plan", Effect("run-2", "n2", "store"),
new[] Effect("run-3", "n3", "quest"),
{ Effect("run-4", "n4", "leaderboard"),
Node("start", "notification"), Effect("run-5", "n5", "battlepass"),
Node("store", "store"), Effect("run-6", "n6", "battlepass_level", new { levelNumber = 3 }),
Node("quest", "quest"), Effect("run-7", "n7", "wait", waitDeadline: new DateTimeOffset(2026, 5, 30, 10, 30, 0, TimeSpan.Zero))
Node("leaderboard", "leaderboard"),
Node("battlepass", "battlepass"),
Node("battlepass-level", "battlepass_level"),
},
new[]
{
Edge("start", "output", "store"),
Edge("start", "output", "quest"),
Edge("start", "output", "leaderboard"),
Edge("start", "output", "battlepass"),
Edge("start", "output", "battlepass-level"),
})
} }
}); });
var client = CreateClient(transport); var client = CreateClient(transport);
NotificationSession? notification = null; var notification = 0;
var store = 0; var store = 0;
var quest = 0; var quest = 0;
var leaderboard = 0; var leaderboard = 0;
var battlePass = 0; var battlePass = 0;
var battlePassLevel = 0; var battlePassLevel = 0;
client.Scenario.OnNotification += session => notification = session; var wait = 0;
client.Scenario.OnStoreOffer += _ => store++; WaitEffect? waitEffect = null;
client.Scenario.OnQuest += _ => quest++; BattlePassLevelEffect? levelEffect = null;
client.Scenario.OnLeaderboard += _ => leaderboard++; client.Effects.OnNotification += _ => notification++;
client.Scenario.OnBattlePass += _ => battlePass++; client.Effects.OnStoreOffer += _ => store++;
client.Scenario.OnBattlePassLevel += _ => battlePassLevel++; client.Effects.OnQuest += _ => quest++;
client.Effects.OnLeaderboard += _ => leaderboard++;
client.Effects.OnBattlePass += _ => battlePass++;
client.Effects.OnBattlePassLevel += e => { battlePassLevel++; levelEffect = e; };
client.Effects.OnWait += e => { wait++; waitEffect = e; };
await client.Scenario.TriggerAsync("login"); await client.Scenario.TriggerAsync("login");
await notification!.CompleteAsync();
Assert.Equal(1, notification);
Assert.Equal(1, store); Assert.Equal(1, store);
Assert.Equal(1, quest); Assert.Equal(1, quest);
Assert.Equal(1, leaderboard); Assert.Equal(1, leaderboard);
Assert.Equal(1, battlePass); Assert.Equal(1, battlePass);
Assert.Equal(1, battlePassLevel); Assert.Equal(1, battlePassLevel);
Assert.Equal(1, wait);
Assert.Equal(3, levelEffect!.Level);
Assert.Equal(new DateTimeOffset(2026, 5, 30, 10, 30, 0, TimeSpan.Zero), waitEffect!.DeadlineUtc);
} }
[Fact] [Fact]
public async Task Wait_Persists_Deadline_And_Completes_After_Restore() public async Task Store_Purchase_And_Decline_Post_Matching_Handles()
{
var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero));
var stateStore = new FakePlanStateStore();
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
{
Plan(
"plan",
new[] { Node("wait", "wait", new { duration = 30, unit = "minutes" }), Node("done", "notification") },
new[] { Edge("wait", "onComplete", "done") })
}
});
var client = CreateClient(transport, clock: clock, stateStore: stateStore);
await client.Scenario.TriggerAsync("login");
Assert.False(string.IsNullOrEmpty(stateStore.State));
clock.UtcNow = clock.UtcNow.AddMinutes(31);
var restoredClient = CreateClient(new FakeTransport(), clock: clock, stateStore: stateStore);
var completed = 0;
restoredClient.Scenario.OnNotification += _ => completed++;
await restoredClient.Scenario.RestoreAsync();
restoredClient.Update(0);
await Task.Delay(20);
Assert.Equal(1, completed);
var run = Assert.Single(restoredClient.Scenario.ActiveRuns);
Assert.Equal("done", Assert.Single(run.ActiveNodeIds));
Assert.Contains("\"done\"", stateStore.State);
}
[Fact]
public async Task RemoteConfigOverride_Applies_Patches_And_Continues()
{ {
var transport = new FakeTransport(); var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse transport.Enqueue(new TriggerScenarioResponse
{ {
Plans = new List<ExecutionPlan> Effects = new List<PendingEffect> { Effect("run-1", "store", "store") }
{
Plan(
"plan",
new[]
{
Node("override", "remote_config_override", new
{
patches = new object[]
{
new { path = "difficulty", valueType = "string", value = "hard" },
new { path = "enemy_count", valueType = "int", value = 12 }
}
}),
Node("done", "notification")
},
new[] { Edge("override", "output", "done") })
}
}); });
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport); var client = CreateClient(transport);
var notifications = 0; StoreOfferEffect? store = null;
var configChanges = 0; client.Effects.OnStoreOffer += e => store = e;
client.Scenario.OnNotification += _ => notifications++;
client.Scenario.OnConfigChanged += _ => configChanges++;
await client.Scenario.TriggerAsync("login");
Assert.Equal("hard", client.RemoteConfig.Get("difficulty", "normal"));
Assert.Equal(12, client.RemoteConfig.Get("enemy_count", 0));
Assert.Equal(1, configChanges);
Assert.Equal(1, notifications);
}
[Fact]
public async Task Boundary_Callback_Sends_Source_Node_And_Starts_Continuation_Plan()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
{
Plan(
"plan",
new[] { Node("store", "store") },
Array.Empty<PlanEdge>(),
new[] { Boundary("store", "onPurchase", "server-condition") },
"run-1")
}
});
transport.Enqueue(new HandleScenarioCallbackResponse
{
Plan = Plan("continuation", Node("done", "notification"), "run-1")
});
var client = CreateClient(transport);
StoreOfferSession? store = null;
var notifications = 0;
client.Scenario.OnStoreOffer += session => store = session;
client.Scenario.OnNotification += _ => notifications++;
await client.Scenario.TriggerAsync("login"); await client.Scenario.TriggerAsync("login");
await store!.PurchaseAsync(); await store!.PurchaseAsync();
var callback = Assert.IsType<HandleScenarioCallbackRequest>( var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request); transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("store", callback.NodeId);
Assert.Equal("onPurchase", callback.Handle); Assert.Equal("onPurchase", callback.Handle);
Assert.Equal(1, notifications); Assert.True(store.IsResolved);
store.Decline();
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/callback"));
} }
[Fact] [Fact]
public async Task Boundary_Wins_Over_Local_Edges() public async Task Leaderboard_End_Posts_OnEnd()
{ {
var transport = new FakeTransport(); var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse transport.Enqueue(new TriggerScenarioResponse
{ {
Plans = new List<ExecutionPlan> Effects = new List<PendingEffect> { Effect("run-1", "lb", "leaderboard") }
{
Plan(
"plan",
new[] { Node("store", "store"), Node("local", "notification") },
new[] { Edge("store", "onPurchase", "local") },
new[] { Boundary("store", "onPurchase", "server-condition") },
"run-1")
}
});
transport.Enqueue(new HandleScenarioCallbackResponse
{
Plan = Plan("continuation", Node("server", "notification"), "run-1")
}); });
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport); var client = CreateClient(transport);
StoreOfferSession? store = null; LeaderboardEffect? leaderboard = null;
var notificationIds = new List<string>(); client.Effects.OnLeaderboard += e => leaderboard = e;
client.Scenario.OnStoreOffer += session => store = session;
client.Scenario.OnNotification += session => notificationIds.Add(session.Id);
await client.Scenario.TriggerAsync("login"); await client.Scenario.TriggerAsync("login");
await store!.PurchaseAsync(); await leaderboard!.EndAsync();
Assert.Equal(new[] { "server" }, notificationIds); var callback = Assert.IsType<HandleScenarioCallbackRequest>(
Assert.Contains(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback"); transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onEnd", callback.Handle);
} }
[Fact] [Fact]
public async Task Quest_Progress_Completed_Response_Completes_The_Node() public async Task Leaderboard_Claim_Posts_OnClaim()
{ {
var transport = new FakeTransport(); var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse transport.Enqueue(new TriggerScenarioResponse
{ {
Plans = new List<ExecutionPlan> Effects = new List<PendingEffect> { Effect("run-1", "lb", "leaderboard") }
{
Plan("plan", Node("quest", "quest"))
}
}); });
transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true }); transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport); var client = CreateClient(transport);
QuestSession? quest = null; LeaderboardEffect? leaderboard = null;
var completed = 0; client.Effects.OnLeaderboard += e => leaderboard = e;
client.Scenario.OnQuest += session => quest = session;
client.Scenario.OnScenarioCompleted += _ => completed++;
await client.Scenario.TriggerAsync("login"); await client.Scenario.TriggerAsync("login");
await quest!.AddProgressAsync("wins", 1); await leaderboard!.ClaimAsync();
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onClaim", callback.Handle);
}
[Fact]
public async Task Quest_Progress_Completed_Response_Ingests_Next_Effect()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest", new { name = "wins" }) }
});
transport.Enqueue(new UpdateScenarioCounterResponse
{
Completed = true,
Effect = JObject.FromObject(Effect("run-1", "done", "notification"))
});
var client = CreateClient(transport);
QuestEffect? quest = null;
NotificationEffect? notification = null;
client.Effects.OnQuest += e => quest = e;
client.Effects.OnNotification += e => notification = e;
await client.Scenario.TriggerAsync("login");
Assert.Equal("wins", quest!.Name);
await quest.ReportProgressAsync("wins", 1);
var counter = Assert.IsType<UpdateScenarioCounterRequest>( var counter = Assert.IsType<UpdateScenarioCounterRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/counter").Request); transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/counter").Request);
Assert.Equal("quest", counter.NodeId); Assert.Equal("quest", counter.NodeId);
Assert.Equal("wins", counter.CounterKey); Assert.Equal("wins", counter.CounterKey);
Assert.Equal(1, counter.Amount);
Assert.NotNull(notification);
Assert.Equal("done", notification!.NodeId);
}
[Fact]
public async Task Quest_Progress_Completed_Without_Next_Raises_ScenarioCompleted()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest") }
});
transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true });
var client = CreateClient(transport);
QuestEffect? quest = null;
var completed = 0;
client.Effects.OnQuest += e => quest = e;
client.Effects.OnScenarioCompleted += _ => completed++;
await client.Scenario.TriggerAsync("login");
await quest!.ReportProgressAsync("wins", 1);
Assert.Equal(1, completed); Assert.Equal(1, completed);
Assert.Empty(client.Scenario.ActiveRuns);
} }
[Fact] [Fact]
@@ -299,129 +280,253 @@ public sealed class ScenarioServiceTests
var transport = new FakeTransport(); var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse transport.Enqueue(new TriggerScenarioResponse
{ {
Plans = new List<ExecutionPlan> Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest") }
{
Plan("plan", Node("quest", "quest"))
}
}); });
transport.Enqueue(new InvalidOperationException("boom")); transport.Enqueue(new InvalidOperationException("boom"));
var logger = new FakeLogger(); var logger = new FakeLogger();
var client = CreateClient(transport, logger: logger); var client = CreateClient(transport, logger: logger);
QuestSession? quest = null; QuestEffect? quest = null;
var failed = 0; var failed = 0;
client.Scenario.OnQuest += session => quest = session; client.Effects.OnQuest += e => quest = e;
client.Scenario.OnScenarioFailed += _ => failed++; client.Effects.OnScenarioFailed += _ => failed++;
await client.Scenario.TriggerAsync("login"); await client.Scenario.TriggerAsync("login");
await quest!.AddProgressAsync("wins", 1); await quest!.ReportProgressAsync("wins", 1);
Assert.Equal(0, failed); Assert.Equal(0, failed);
Assert.Single(client.Scenario.ActiveRuns);
Assert.Contains(logger.Messages, m => Assert.Contains(logger.Messages, m =>
m.Level == RudderLogLevel.Warning && m.Message.Contains("counter update failed")); m.Level == RudderLogLevel.Warning && m.Message.Contains("counter update failed"));
} }
[Fact]
public async Task Unknown_Run_Drops_Effects_And_Raises_Failed()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
});
transport.Enqueue(new RudderNotFoundException(404, RudderErrorCodes.UnknownRun, "unknown"));
var client = CreateClient(transport);
NotificationEffect? notification = null;
ScenarioFailedEffect? failure = null;
client.Effects.OnNotification += e => notification = e;
client.Effects.OnScenarioFailed += e => failure = e;
await client.Scenario.TriggerAsync("login");
await notification!.DoneAsync();
Assert.NotNull(failure);
Assert.Equal("run-1", failure!.RunId);
Assert.Equal("n1", failure.NodeId);
Assert.IsType<RudderNotFoundException>(failure.Exception);
}
[Fact]
public async Task Expired_Run_Drops_Effects()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
});
transport.Enqueue(new RudderApiException(400, RudderErrorCodes.RunExpired, "expired"));
var client = CreateClient(transport);
NotificationEffect? notification = null;
ScenarioFailedEffect? failure = null;
client.Effects.OnNotification += e => notification = e;
client.Effects.OnScenarioFailed += e => failure = e;
await client.Scenario.TriggerAsync("login");
await notification!.DoneAsync();
Assert.NotNull(failure);
Assert.Equal("run-1", failure!.RunId);
}
[Fact] [Fact]
public async Task Unknown_Nodes_Fail_The_Run() public async Task Unknown_Nodes_Fail_The_Run()
{ {
var transport = new FakeTransport(); var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse transport.Enqueue(new TriggerScenarioResponse
{ {
Plans = new List<ExecutionPlan> Effects = new List<PendingEffect> { Effect("run-1", "future", "future_node") }
{
Plan("plan", Node("future", "future_node"))
}
}); });
var logger = new FakeLogger(); var logger = new FakeLogger();
var client = CreateClient(transport, logger: logger); var client = CreateClient(transport, logger: logger);
ScenarioFailedEvent? failure = null; ScenarioFailedEffect? failure = null;
client.Scenario.OnScenarioFailed += e => failure = e; client.Effects.OnScenarioFailed += e => failure = e;
await client.Scenario.TriggerAsync("login"); await client.Scenario.TriggerAsync("login");
Assert.NotNull(failure); Assert.NotNull(failure);
Assert.Equal("future", failure!.NodeId); Assert.Equal("future", failure!.NodeId);
Assert.Empty(client.Scenario.ActiveRuns);
Assert.Contains(logger.Messages, m => Assert.Contains(logger.Messages, m =>
m.Level == RudderLogLevel.Warning && m.Message.Contains("Unsupported scenario node type 'future_node'")); m.Level == RudderLogLevel.Warning && m.Message.Contains("Unsupported scenario node type 'future_node'"));
} }
[Fact]
public async Task Wait_Deadline_Refreshes_Pending_Without_Posting_Callback()
{
var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero));
var transport = new FakeTransport();
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect>
{
Effect("run-1", "wait", "wait", waitDeadline: clock.UtcNow.AddMinutes(30))
}
});
var client = CreateClient(transport, clock: clock);
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
await client.Scenario.TriggerAsync("login");
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
Assert.DoesNotContain(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback");
transport.Enqueue(new ListPendingScenarioEffectsResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "done", "notification") }
});
var notifications = 0;
client.Effects.OnNotification += _ => notifications++;
clock.UtcNow = clock.UtcNow.AddMinutes(31);
client.Update(0);
await Task.Delay(50);
Assert.Equal(2, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
Assert.Equal(1, notifications);
Assert.DoesNotContain(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback");
}
[Fact]
public async Task Heartbeat_Refreshes_Pending_After_30_Seconds()
{
var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero));
var transport = new FakeTransport();
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
var client = CreateClient(transport, clock: clock);
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
clock.UtcNow = clock.UtcNow.AddSeconds(29);
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
clock.UtcNow = clock.UtcNow.AddSeconds(1);
client.Update(0);
await Task.Delay(50);
Assert.Equal(2, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
}
[Fact]
public async Task Login_Fetches_Pending_On_Next_Update()
{
var transport = new FakeTransport();
transport.Enqueue(new LoginViaDeviceResponse
{
AccessToken = "access-token",
RefreshToken = "refresh-token"
});
transport.Enqueue(new ListPendingScenarioEffectsResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
});
var tokenStore = new FakeTokenStore();
var client = CreateClient(transport, tokenStore: tokenStore);
var notifications = 0;
client.Effects.OnNotification += _ => notifications++;
await client.Auth.LoginWithDeviceAsync("en", "en");
Assert.Equal(0, notifications);
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, notifications);
Assert.Contains(transport.Calls, call => call.Method == "GET" && call.Path == "/sdk/v1/scenarios/pending");
}
[Fact]
public async Task BattlePass_LevelUp_Posts_OnLevelUp()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "bp", "battlepass") }
});
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
BattlePassEffect? battlePass = null;
client.Effects.OnBattlePass += e => battlePass = e;
await client.Scenario.TriggerAsync("login");
await battlePass!.LevelUpAsync();
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onLevelUp", callback.Handle);
}
[Fact]
public async Task BattlePassLevel_Claim_Posts_OnComplete()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "bpl", "battlepass_level", new { levelNumber = 2 }) }
});
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
BattlePassLevelEffect? level = null;
client.Effects.OnBattlePassLevel += e => level = e;
await client.Scenario.TriggerAsync("login");
await level!.ClaimAsync();
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onComplete", callback.Handle);
Assert.Equal(2, level.Level);
}
private static RudderClient CreateClient( private static RudderClient CreateClient(
FakeTransport transport, FakeTransport transport,
FakeClock? clock = null, FakeClock? clock = null,
FakePlanStateStore? stateStore = null, IRudderLogger? logger = null,
IRudderLogger? logger = null) FakeTokenStore? tokenStore = null)
{ {
return new RudderClient(new RudderClientOptions return new RudderClient(new RudderClientOptions
{ {
BaseUrl = "http://localhost:8082", BaseUrl = "http://localhost:8082",
RealtimeUrl = "ws://localhost:8090/api/realtime/ws",
ProjectKey = "project-key", ProjectKey = "project-key",
Transport = transport, Transport = transport,
TokenStore = new FakeTokenStore { AccessToken = "access-token" }, TokenStore = tokenStore ?? new FakeTokenStore { AccessToken = "access-token" },
DeviceIdProvider = new FakeDeviceIdProvider(), DeviceIdProvider = new FakeDeviceIdProvider(),
Clock = clock ?? new FakeClock(DateTimeOffset.UtcNow), Clock = clock ?? new FakeClock(DateTimeOffset.UtcNow),
PlanStateStore = stateStore ?? new FakePlanStateStore(),
RealtimeTransportFactory = new FakeRealtimeTransportFactory(),
Logger = logger Logger = logger
}); });
} }
private static ExecutionPlan Plan(string id, ExecutionPlanNode node, string? runId = null) private static PendingEffect Effect(string runId, string nodeId, string type, object? data = null, DateTimeOffset? waitDeadline = null)
=> Plan(id, new[] { node }, Array.Empty<PlanEdge>(), runId: runId);
private static ExecutionPlan Plan(
string id,
IEnumerable<ExecutionPlanNode> nodes,
IEnumerable<PlanEdge> edges,
IEnumerable<BoundaryNode>? boundaries = null,
string? runId = null)
{ {
var nodeList = nodes.ToList(); return new PendingEffect
return new ExecutionPlan
{ {
PlanId = id, RunId = runId,
ScenarioId = "scenario-" + id, ScenarioSlug = "scenario-1",
UserId = "user", NodeId = nodeId,
StartNodeId = nodeList[0].Id,
Nodes = nodeList,
Edges = edges.ToList(),
BoundaryNodes = boundaries?.ToList() ?? new List<BoundaryNode>(),
RunId = runId!,
Context = new JObject()
};
}
private static ExecutionPlanNode Node(string id, string type, object? data = null)
{
return new ExecutionPlanNode
{
Id = id,
Type = type, Type = type,
Data = data == null ? new JObject() : JObject.FromObject(data) Data = data == null ? new JObject() : JObject.FromObject(data),
}; WaitDeadline = waitDeadline
}
private static PlanEdge Edge(string source, string handle, string target)
{
return new PlanEdge
{
Id = source + "-" + handle + "-" + target,
Source = source,
SourceHandle = handle,
Target = target,
TargetHandle = "in"
};
}
private static BoundaryNode Boundary(string source, string handle, string target)
{
return new BoundaryNode
{
SourceNodeId = source,
SourceHandle = handle,
NodeId = target,
CallbackUrl = "/sdk/v1/scenarios/callback"
}; };
} }
@@ -443,7 +548,11 @@ public sealed class ScenarioServiceTests
if (_responses.Count == 0) if (_responses.Count == 0)
return Task.FromResult(default(TResponse)!); return Task.FromResult(default(TResponse)!);
return Task.FromResult((TResponse)_responses.Dequeue()); var next = _responses.Dequeue();
if (next is Exception ex)
return Task.FromException<TResponse>(ex);
return Task.FromResult((TResponse)next);
} }
} }
@@ -492,42 +601,9 @@ public sealed class ScenarioServiceTests
public DateTimeOffset UtcNow { get; set; } public DateTimeOffset UtcNow { get; set; }
} }
private sealed class FakePlanStateStore : IPlanStateStore
{
public string? State { get; set; }
}
private sealed class FakeLogger : IRudderLogger private sealed class FakeLogger : IRudderLogger
{ {
public List<(RudderLogLevel Level, string Message)> Messages { get; } = new(); public List<(RudderLogLevel Level, string Message)> Messages { get; } = new();
public void Log(RudderLogLevel level, string message) => Messages.Add((level, message)); public void Log(RudderLogLevel level, string message) => Messages.Add((level, message));
} }
private sealed class FakeRealtimeTransportFactory : IRealtimeTransportFactory
{
public IRealtimeTransport Create() => new FakeRealtimeTransport();
}
#pragma warning disable CS0067
private sealed class FakeRealtimeTransport : IRealtimeTransport
{
public bool IsConnected { get; private set; }
public event Action? Closed;
public event Action<Exception>? Error;
public event Action<ArraySegment<byte>>? Received;
public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default)
{
IsConnected = true;
return Task.CompletedTask;
}
public Task SendAsync(ArraySegment<byte> payload, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task CloseAsync(CancellationToken cancellationToken = default)
{
IsConnected = false;
Closed?.Invoke();
return Task.CompletedTask;
}
public void Update(float deltaTime) { }
}
#pragma warning restore CS0067
} }