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;
/// <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
{
/// <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")]
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string? ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
[JsonProperty("source")]
public string? Source { get; set; }
-4
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.BattlePass;
@@ -15,9 +14,6 @@ public class AddBattlePassXpResponse
[JsonProperty("maxLevel")]
public bool? MaxLevel { get; set; }
[JsonProperty("plan")]
public ExecutionPlan? Plan { get; set; }
[JsonProperty("xp")]
public long? Xp { get; set; }
+2 -2
View File
@@ -14,8 +14,8 @@ public class ClaimBattlePassRewardRequest
[JsonProperty("runId")]
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string? ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
[JsonProperty("track")]
public string? Track { get; set; }
+2 -2
View File
@@ -8,7 +8,7 @@ public class GetBattlePassProgressRequest
[JsonProperty("nodeId")]
public string? NodeId { get; set; }
[JsonProperty("scenarioId")]
public string? ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
}
@@ -14,7 +14,7 @@ public class PurchaseBattlePassPremiumRequest
[JsonProperty("runId")]
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string? ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
}
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.BattlePass;
@@ -9,9 +8,6 @@ public class PurchaseBattlePassPremiumResponse
[JsonProperty("error")]
public string? Error { get; set; }
[JsonProperty("plan")]
public ExecutionPlan? Plan { get; set; }
[JsonProperty("success")]
public bool? Success { get; set; }
+77
View File
@@ -1,5 +1,82 @@
# 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
- 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
{
[JsonProperty("avatarUrl")]
public string? AvatarUrl { get; set; }
[JsonProperty("createdAt")]
public DateTimeOffset? CreatedAt { get; set; }
+2 -2
View File
@@ -5,7 +5,7 @@ namespace RudderSdk.Core.Models.Quests;
public class ClaimQuestRequest
{
[JsonProperty("questId")]
public string? QuestId { get; set; }
[JsonProperty("questSlug")]
public string? QuestSlug { get; set; }
}
+3 -3
View File
@@ -7,9 +7,6 @@ namespace RudderSdk.Core.Models.Quests;
public class Quest
{
[JsonProperty("id")]
public string? Id { get; set; }
[JsonProperty("name")]
public string? Name { get; set; }
@@ -19,6 +16,9 @@ public class Quest
[JsonProperty("rewards")]
public List<Reward>? Rewards { get; set; }
[JsonProperty("slug")]
public string? Slug { get; set; }
[JsonProperty("status")]
public string? Status { get; set; }
+2 -2
View File
@@ -6,7 +6,7 @@ namespace RudderSdk.Core.Models.Quests;
public class ReportQuestProgressResponse
{
[JsonProperty("completedQuestIds")]
public List<string>? CompletedQuestIds { get; set; }
[JsonProperty("completedQuestSlugs")]
public List<string>? CompletedQuestSlugs { get; set; }
}
+19 -12
View File
@@ -2,7 +2,7 @@
.NET client SDK for the Rudder LiveOps platform: authentication, player
profile, stores, battle pass, quests, leaderboards, inventory, remote config,
scenarios, storage and realtime.
scenarios (server-driven effects), and storage.
- Target framework: `netstandard2.1` (works in Unity, .NET, Xamarin).
- JSON: Newtonsoft.Json.
@@ -36,7 +36,7 @@ Console.WriteLine(profile.Player.Id);
// Buy an offer (idempotency key is generated when omitted).
var purchase = await client.Stores.PurchaseAsync("main-store", "offer-1");
// Pump time-dependent services (scenarios, realtime) every frame.
// Pump the effects client (30s pending heartbeat + wait deadlines) every frame.
client.Update(deltaTime);
```
@@ -58,22 +58,22 @@ stack.
| `Inventory` | `InventoryService` | `GetAsync` |
| `Leaderboards` | `LeaderboardsService` | `FindBySlug(slug)` → handle: `SubmitAsync`, `ListAsync` |
| `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` |
| `Realtime` | `RealtimeService` | `ConnectAsync`, `DisconnectAsync` |
## Quests
`client.Quests` covers the player's global quests — list with per-objective
progress, claim, and metric reports. These are distinct from scenario quest
nodes, which advance through `QuestSession` (`Scenario.OnQuest`).
nodes, which advance through `QuestEffect` (`Effects.OnQuest`).
```csharp
var quests = await client.Quests.ListAsync();
foreach (var quest in quests)
{
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
@@ -82,8 +82,8 @@ var completedIds = await client.Quests.ReportProgressAsync("kills", 1);
```
Purchase metrics are reported automatically by store purchases;
`QuestMetrics.PurchaseOffer(offerId)` / `QuestMetrics.PurchaseItem(itemId)`
name the format (`purchase.offer:<offerId>`, `purchase.item:<itemId>`) so
`QuestMetrics.PurchaseOffer(offerSlug)` / `QuestMetrics.PurchaseItem(itemId)`
name the format (`purchase.offer:<offerSlug>`, `purchase.item:<itemId>`) so
quest configs and client code agree on it.
## Sessions
@@ -108,11 +108,18 @@ All of them carry `StatusCode` (`int`), the machine-readable `Code`
## Scenario effects
Subscribe to typed sessions on `client.Scenario`:
The server owns scenario execution. Trigger with `client.Scenario.TriggerAsync`,
subscribe to typed effects on `client.Effects`, and call `client.Update` every
frame:
`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnConfigChanged`,
`OnWait`, `OnQuest`, `OnBattlePass`, `OnBattlePassLevel`,
`OnScenarioCompleted`, `OnScenarioFailed`.
`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnWait`, `OnQuest`,
`OnBattlePass`, `OnBattlePassLevel`, `OnScenarioCompleted`,
`OnScenarioFailed`.
Complete an effect with the methods on the object (`DoneAsync`,
`PurchaseAsync`/`DeclineAsync`, `EndAsync`/`ClaimAsync`, …) or the run
stalls. `unknown_run` / `run_expired` drop the run and fire
`OnScenarioFailed`.
## Tests
+2 -2
View File
@@ -6,9 +6,9 @@
<AssemblyName>Rudder.Core</AssemblyName>
<RootNamespace>RudderSdk.Core</RootNamespace>
<PackageId>Rudder.Core</PackageId>
<Version>0.4.0</Version>
<Version>2.0.0</Version>
<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>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
+7 -13
View File
@@ -47,11 +47,11 @@ public sealed class RudderClient
/// <summary>Global quests.</summary>
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; }
/// <summary>Realtime websocket channel.</summary>
public RealtimeService Realtime { get; }
/// <summary>Pending scenario effects: subscriptions and completion callbacks.</summary>
public EffectsService Effects { get; }
internal RudderClientOptions Options { get; }
internal IRudderTransport Transport => Options.Transport!;
@@ -61,10 +61,7 @@ public sealed class RudderClient
/// <summary>Project key from <see cref="RudderClientOptions.ProjectKey"/>.</summary>
public string ProjectKey => Options.ProjectKey!;
/// <summary>Realtime URL from <see cref="RudderClientOptions.RealtimeUrl"/>.</summary>
public string? RealtimeUrl => Options.RealtimeUrl;
/// <summary>Time source used by the scenario runtime.</summary>
/// <summary>Time source used by the effects client.</summary>
public IClock Clock => Options.Clock ?? SystemClock.Instance;
/// <summary>
@@ -90,17 +87,16 @@ public sealed class RudderClient
Stores = new StoresService(this);
Leaderboards = new LeaderboardsService(this);
Inventory = new InventoryService(this);
Scenario = new ScenarioService(this);
BattlePass = new BattlePassService(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>
public void Update(float deltaTime)
{
Scenario.Update(deltaTime);
Realtime.Update(deltaTime);
Effects.Update(deltaTime);
}
internal Task<TResponse> SendAsync<TRequest, TResponse>(
@@ -151,7 +147,6 @@ public sealed class RudderClient
}
catch (RudderAuthException)
{
// Session rejected — refresh once (single-flight) and retry the call once.
if (!await RefreshTokensAsync().ConfigureAwait(false))
throw;
@@ -218,7 +213,6 @@ public sealed class RudderClient
}
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>
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>
public string? ProjectKey { get; set; }
@@ -30,15 +27,6 @@ public sealed class RudderClientOptions
/// <summary>Diagnostic sink. Null by default (silent).</summary>
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; }
/// <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")]
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string? ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
}
+3 -3
View File
@@ -1,12 +1,12 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using RudderSdk.Core.Models;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core.Models.Scenarios;
public class HandleScenarioCallbackResponse
{
[JsonProperty("plan")]
public ExecutionPlan? Plan { get; set; }
[JsonProperty("effect")]
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.
using Newtonsoft.Json;
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.Scenarios;
public class TriggerScenarioResponse
{
[JsonProperty("plans")]
public List<ExecutionPlan>? Plans { get; set; }
[JsonProperty("effects")]
public List<PendingEffect> Effects { get; set; } = null!;
}
+2 -2
View File
@@ -17,7 +17,7 @@ public class UpdateScenarioCounterRequest
[JsonProperty("runId")]
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string? ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using RudderSdk.Core.Models;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core.Models.Scenarios;
@@ -9,7 +9,7 @@ public class UpdateScenarioCounterResponse
[JsonProperty("completed")]
public bool? Completed { get; set; }
[JsonProperty("plan")]
public ExecutionPlan? Plan { get; set; }
[JsonProperty("effect")]
public JToken? Effect { get; set; }
}
+3 -3
View File
@@ -8,7 +8,7 @@ namespace RudderSdk.Core;
/// <summary>
/// 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
/// for the mutating calls) — <see cref="BattlePassSession"/> supplies them
/// for the mutating calls) — <see cref="BattlePassEffect"/> supplies them
/// during scenario runs.
/// </summary>
public sealed class BattlePassService
@@ -24,11 +24,11 @@ public sealed class BattlePassService
internal BattlePassService(RudderClient client) => _client = client;
/// <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>(
"POST",
"/sdk/v1/battlepass/progress",
new GetBattlePassProgressRequest { ScenarioId = scenarioId, NodeId = nodeId },
new GetBattlePassProgressRequest { ScenarioSlug = scenarioSlug, NodeId = nodeId },
cancellationToken);
/// <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
{
/// <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>
public static string PurchaseItem(string itemId) => $"purchase.item:{itemId}";
+4 -4
View File
@@ -7,7 +7,7 @@ namespace RudderSdk.Core;
/// <summary>
/// Global quests (list + claim), distinct from scenario quest nodes which
/// advance through <see cref="QuestSession"/>.
/// advance through <see cref="QuestEffect"/>.
/// </summary>
public sealed class QuestsService
{
@@ -27,11 +27,11 @@ public sealed class QuestsService
}
/// <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>(
"POST",
"/sdk/v1/quests/claim",
new ClaimQuestRequest { QuestId = questId },
new ClaimQuestRequest { QuestSlug = questSlug },
cancellationToken);
/// <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 },
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.Tasks;
using Newtonsoft.Json;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core;
/// <summary>
/// Scenario runtime. <see cref="TriggerAsync"/> starts server-issued plans;
/// active nodes surface as typed sessions through the On* events and are
/// advanced by completing those sessions.
/// Scenario trigger. Execution lives on the server; resulting effects surface
/// through <see cref="RudderClient.Effects"/>.
/// </summary>
public sealed partial class ScenarioService
public sealed class ScenarioService
{
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;
/// <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>
/// Triggers scenarios by event name and starts the plans the server
/// returns. Returns the runs this call started.
/// Triggers scenarios by event name. Returned pending effects are ingested
/// into <see cref="RudderClient.Effects"/>.
/// </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>(
"POST",
@@ -73,170 +26,6 @@ public sealed partial class ScenarioService
new TriggerScenarioRequest { Event = eventName },
cancellationToken).ConfigureAwait(false);
return StartPlans(response?.Plans);
}
/// <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}");
}
_client.Effects.Ingest(response?.Effects);
}
}
-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>
public Task<PurchaseOfferResponse> PurchaseAsync(
string storeSlug,
string offerId,
string offerSlug,
string? idempotencyKey = null,
CancellationToken cancellationToken = default)
{
return _client.SendAsync<PurchaseOfferRequest, PurchaseOfferResponse>(
"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
{
StoreSlug = storeSlug,
OfferId = offerId,
OfferSlug = offerSlug,
IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString()
},
cancellationToken);
+3
View File
@@ -25,6 +25,9 @@ public class Offer
[JsonProperty("price")]
public OfferPrice? Price { get; set; }
[JsonProperty("slug")]
public string? Slug { get; set; }
[JsonProperty("updatedAt")]
public DateTimeOffset? UpdatedAt { get; set; }
+2 -2
View File
@@ -8,8 +8,8 @@ public class PurchaseOfferRequest
[JsonProperty("idempotencyKey")]
public string? IdempotencyKey { get; set; }
[JsonProperty("offerId")]
public string? OfferId { get; set; }
[JsonProperty("offerSlug")]
public string? OfferSlug { get; set; }
[JsonProperty("storeSlug")]
public string? StoreSlug { get; set; }
+16 -16
View File
@@ -1,6 +1,6 @@
---
name: rudder-csharp-sdk
description: Use when working with the Rudder C# SDK Rudder.Core (namespace RudderSdk.Core) — client init, transports, auth/session lifecycle, and the per-domain services (player, stores, inventory, battle pass, quests, leaderboards, remote config, scenarios, storage, realtime).
description: Use when working with the Rudder C# SDK Rudder.Core (namespace RudderSdk.Core) — client init, transports, auth/session lifecycle, and the per-domain services (player, stores, inventory, battle pass, quests, leaderboards, remote config, scenario trigger, scenario effects, storage).
---
# Rudder C# SDK (Rudder.Core)
@@ -43,8 +43,8 @@ full options surface and the abstraction interfaces):
- `DeviceIdProvider``GuidDeviceIdProvider` (new GUID per run — plug a
persistent `IDeviceIdProvider` for production)
`RealtimeUrl` + `RealtimeTransportFactory` are required only for
`client.Realtime`; there is no default websocket transport in Rudder.Core.
`Clock` defaults to `DateTimeOffset.UtcNow`; override in tests. There is no
realtime websocket client in Rudder.Core.
## Request pipeline (applies to every service)
@@ -58,7 +58,7 @@ full options surface and the abstraction interfaces):
failures throw `RudderNetworkException` (`StatusCode == 0`). See
reference/errors.md.
- `client.Update(deltaTime)` must be called every frame — it pumps the
scenario runtime (wait-node deadlines) and the realtime transport.
effects client (30s pending heartbeat and wait-node deadline checks).
## Capability map
@@ -74,19 +74,19 @@ full options surface and the abstraction interfaces):
| Stores and offer purchases | `client.Stores` | reference/stores.md |
| Leaderboards | `client.Leaderboards.FindBySlug(slug)` | reference/leaderboards.md |
| Inventory | `client.Inventory` | reference/inventory.md |
| Battle pass | `client.BattlePass` (+ scenario session) | reference/battlepass.md |
| Battle pass | `client.BattlePass` (+ scenario effect) | reference/battlepass.md |
| Quests (global) | `client.Quests` | reference/quests.md |
| Scenarios (event-driven plans, node sessions) | `client.Scenario` | reference/scenarios.md |
| Realtime websocket | `client.Realtime` | reference/realtime.md |
| Scenario trigger | `client.Scenario` | reference/scenarios.md |
| Scenario effects | `client.Effects.On*` | reference/scenarios.md |
Important cross-cutting facts:
- Battle pass state is tied to a scenario battle-pass node: every
`BattlePassService` call carries `ScenarioId`/`NodeId` (mutations also
`RunId`). During a scenario run, `BattlePassSession` supplies them — prefer
the session API inside a run.
`BattlePassService` call carries `ScenarioSlug`/`NodeId` (mutations also
`RunId`). During a scenario run, `BattlePassEffect` supplies them — prefer
the effect API inside a run.
- Global quests (`client.Quests`) are distinct from scenario quest nodes
(`Scenario.OnQuest``QuestSession`).
(`Effects.OnQuest``QuestEffect`).
- Mutations that take an idempotency key auto-generate a random GUID when the
key is omitted (`Stores.PurchaseAsync`, `ProjectStorage.SaveAsync`,
`BattlePass.PurchasePremiumAsync`). Pass a stable key to make retries safe.
@@ -96,10 +96,10 @@ Important cross-cutting facts:
## Relationship to the Unity package (rudder.sdk)
`rudder.sdk` (UPM, liveops-unity-sdk) wraps `Rudder.Core.dll` with Unity
adapters: `UnityWebRequest` transport, `PlayerPrefs` token store, a websocket
realtime transport, and a `Rudder` bootstrap component
(`RudderSdk.Unity` namespace). The domain services and models are the same
ones documented here — for Unity games, consume them through the package's
adapters instead of wiring `RudderClientOptions` by hand. Unity requires
adapters: `UnityWebRequest` transport, `PlayerPrefs` token store, and a
`Rudder` bootstrap component (`RudderSdk.Unity` namespace). The domain
services and models are the same ones documented here — for Unity games,
consume them through the package's adapters instead of wiring
`RudderClientOptions` by hand. Unity requires
`com.unity.nuget.newtonsoft-json` since Rudder.Core serializes with
Newtonsoft.Json.
+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
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
- `POST /sdk/v1/authorization/device` — body `{key, deviceId, region,
@@ -48,7 +61,9 @@ public class LoginViaDeviceResponse / LoginViaCustomResponse
## Behavior notes
- A successful login stores the token pair in the configured `ITokenStore`
and fires `SignedIn`.
and fires `SignedIn`. `EffectsService` treats `SignedIn` as a pending-effects
refresh due: the next `client.Update` GETs `/sdk/v1/scenarios/pending`.
This SDK does not auto-trigger a login scenario event.
- If the server returns an incomplete session (missing tokens), login throws
`InvalidOperationException`.
- Token refresh is automatic: any API call that gets a 401 triggers one
@@ -56,3 +71,5 @@ public class LoginViaDeviceResponse / LoginViaCustomResponse
transparent retry. A failed refresh clears the tokens and fires
`SignedOut`; the original call then throws `RudderAuthException`.
- `RefreshAsync()` shares the same single-flight path.
- Logout / `SignedOut` clears the effects client's seen-effect set and wait
deadlines.
@@ -1,13 +1,13 @@
# Battle pass — `client.BattlePass` (`BattlePassService`)
Source: `Services/BattlePassService.cs`,
`Services/Scenarios/Sessions/BattlePassSession.cs`,
`Services/Scenarios/Sessions/BattlePassLevelSession.cs`, `BattlePass/*.cs`
`Services/Effects/BattlePassEffect.cs`,
`Services/Effects/BattlePassLevelEffect.cs`, `BattlePass/*.cs`
(generated DTOs).
Battle pass state is tied to a scenario battle-pass node, so every call
carries `ScenarioId`/`NodeId` (mutations also `RunId`). Inside a scenario run,
`Scenario.OnBattlePass` hands you a `BattlePassSession` that supplies these
carries `ScenarioSlug`/`NodeId` (mutations also `RunId`). Inside a scenario run,
`Effects.OnBattlePass` hands you a `BattlePassEffect` that supplies these
ids — prefer it over calling the service directly.
## BattlePassService
@@ -18,7 +18,7 @@ public const string TrackPremium = "premium";
// POST /sdk/v1/battlepass/progress
public Task<GetBattlePassProgressResponse> GetProgressAsync(
string scenarioId, string nodeId, CancellationToken cancellationToken = default);
string scenarioSlug, string nodeId, CancellationToken cancellationToken = default);
// POST /sdk/v1/battlepass/xp
public Task<AddBattlePassXpResponse> AddXpAsync(
@@ -40,7 +40,7 @@ public Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(
public class GetBattlePassProgressRequest
{
public string? NodeId { get; set; } // "nodeId"
public string? ScenarioId { get; set; } // "scenarioId"
public string? ScenarioSlug { get; set; } // "scenarioSlug"
}
public class AddBattlePassXpRequest
@@ -48,7 +48,7 @@ public class AddBattlePassXpRequest
public long? Amount { get; set; } // "amount"
public string? NodeId { get; set; } // "nodeId"
public string? RunId { get; set; } // "runId"
public string? ScenarioId { get; set; } // "scenarioId"
public string? ScenarioSlug { get; set; } // "scenarioSlug"
public string? Source { get; set; } // "source"
}
@@ -57,7 +57,7 @@ public class ClaimBattlePassRewardRequest
public int? Level { get; set; } // "level"
public string? NodeId { get; set; } // "nodeId"
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
}
@@ -66,7 +66,7 @@ public class PurchaseBattlePassPremiumRequest
public string? IdempotencyKey { get; set; } // "idempotencyKey"
public string? NodeId { get; set; } // "nodeId"
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 bool? LeveledUp { get; set; } // "leveledUp"
public bool? MaxLevel { get; set; } // "maxLevel"
public ExecutionPlan? Plan { get; set; } // "plan" — scenario continuation
public long? Xp { get; set; } // "xp"
}
@@ -103,7 +102,6 @@ public class ClaimBattlePassRewardResponse
public class PurchaseBattlePassPremiumResponse
{
public string? Error { get; set; } // "error"
public ExecutionPlan? Plan { get; set; } // "plan"
public bool? Success { get; set; } // "success"
}
```
@@ -112,36 +110,35 @@ public class PurchaseBattlePassPremiumResponse
`ItemId` (string?). Responses carry business errors in `Error` — check
`Success`. Claim failures surface codes such as `RudderErrorCodes.LevelNotReached`.
## BattlePassSession (scenario node session)
## `BattlePassEffect` (scenario battlepass node)
Raised via `Scenario.OnBattlePass`. Bound to the node's scenario/node/run ids:
Raised via `Effects.OnBattlePass`. Bound to the node's scenario/node/run ids:
```csharp
public ScenarioNodeContext Context { get; }
public string Id { get; } // node id
public string RunId { get; }
public string ScenarioSlug { get; }
public string NodeId { get; }
public T Get<T>(string key, T defaultValue = default!);
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 crosses the onPremiumPurchase handle on success.
// Purchases premium, then posts onPremiumPurchase when response.Success == true.
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 MaxLevelAsync(CancellationToken ct = default); public void MaxLevel(); // onMaxLevel
public Task CompleteAsync(CancellationToken ct = default); public void Complete(); // onComplete
public Task EndAsync(CancellationToken ct = default); public void End(); // onComplete
```
## BattlePassLevelSession (scenario battlepass_level node)
## `BattlePassLevelEffect` (scenario battlepass_level node)
Raised via `Scenario.OnBattlePassLevel`; a single claimable tier:
Raised via `Effects.OnBattlePassLevel`; a single claimable tier:
```csharp
public int Level { get; } // reads the "levelNumber" node data key
public Task ClaimAsync(CancellationToken ct = default); public void Claim();
```
`ClaimAsync` crosses `onComplete`; the server accepts it only once the player
`ClaimAsync` posts `onComplete`; the server accepts it only once the player
has reached the node's configured level.
+4 -30
View File
@@ -19,14 +19,13 @@ public sealed class RudderClient
public BattlePassService BattlePass { get; }
public QuestsService Quests { get; }
public ScenarioService Scenario { get; }
public RealtimeService Realtime { get; }
public EffectsService Effects { get; }
public string ProjectKey { get; }
public string? RealtimeUrl { get; }
public IClock Clock { get; } // defaults to DateTimeOffset.UtcNow
public IClock Clock { get; } // Options.Clock, else DateTimeOffset.UtcNow
public RudderClient(RudderClientOptions options);
public void Update(float deltaTime); // pumps Scenario + Realtime; call every frame
public void Update(float deltaTime); // pumps Effects; call every frame
}
```
@@ -40,16 +39,12 @@ single-flight refresh and one transparent retry (see errors.md).
public sealed class RudderClientOptions
{
public string? BaseUrl { get; set; } // required
public string? RealtimeUrl { get; set; } // required only for Realtime
public string? ProjectKey { get; set; } // required
public IRudderTransport? Transport { get; set; } // default HttpClientTransport
public ITokenStore? TokenStore { get; set; } // default InMemoryTokenStore
public IDeviceIdProvider? DeviceIdProvider { get; set; }// default GuidDeviceIdProvider
public IRudderLogger? Logger { get; set; } // null = silent
public IClock? Clock { get; set; } // override in tests
public IPlanStateStore? PlanStateStore { get; set; } // scenario persistence between launches
public IPlanScheduler? Scheduler { get; set; } // optional delayed plan work
public IRealtimeTransportFactory? RealtimeTransportFactory { get; set; } // required only for Realtime
public IClock? Clock { get; set; } // time source for the effects client; override in tests
}
```
@@ -89,27 +84,6 @@ public interface IRudderLogger { void Log(RudderLogLevel level, string message);
public enum RudderLogLevel { Debug, Info, Warning, Error }
public interface IClock { DateTimeOffset UtcNow { get; } }
public interface IPlanStateStore { string? State { get; set; } } // serialized scenario-run blob
public interface IPlanScheduler
{
Task ScheduleAsync(TimeSpan delay, CancellationToken cancellationToken = default);
}
public interface IRealtimeTransport
{
event Action Closed;
event Action<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`
+8 -9
View File
@@ -1,8 +1,7 @@
# Errors, exceptions, error codes
Source: `Exceptions/*.cs`, `Models/RudderErrorCodes.cs`,
`Models/ErrorResponse.cs`, `HttpClientTransport.cs` (status mapping),
`Services/Scenarios/ScenarioService.Runtime.cs` (`TransientBoundaryException`).
`Models/ErrorResponse.cs`, `HttpClientTransport.cs` (status mapping).
## Exception hierarchy
@@ -59,11 +58,11 @@ UnknownRun = "unknown_run"
Match against `RudderApiException.Code`.
## Scenario boundary errors
## Scenario callback / counter errors
`TransientBoundaryException` (public sealed, wraps the transport error) is
used inside the scenario runtime for transient boundary-callback failures
(timeout / `RudderNetworkException`): the run is NOT advanced, the node stays
active and the handle stays pending for retry. A failed scenario counter
update never fails the run — it is logged via `IRudderLogger` and the node
stays active.
On `POST /sdk/v1/scenarios/callback` and `POST /sdk/v1/scenarios/counter`,
`unknown_run`, `run_expired`, and HTTP 404 drop that run and fire
`Effects.OnScenarioFailed`. Other callback errors propagate to the caller.
A failed counter update that is not one of those definitive rejections is
logged via `IRudderLogger` and the effect stays active. Details:
reference/scenarios.md.
+5 -5
View File
@@ -4,7 +4,7 @@ Source: `Services/QuestsService.cs`, `Services/QuestMetrics.cs`,
`Quests/*.cs` (generated DTOs).
Global quests (list + claim + metric reports). Distinct from scenario quest
nodes, which advance through `QuestSession` (`Scenario.OnQuest`) — see
nodes, which advance through `QuestEffect` (`Effects.OnQuest`) — see
scenarios.md.
## Methods
@@ -14,10 +14,10 @@ scenarios.md.
public Task<IReadOnlyList<Quest>> ListAsync(CancellationToken cancellationToken = default);
// 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
// ids of quests completed by THIS report
// slugs of quests completed by THIS report
public Task<IReadOnlyList<string>> ReportProgressAsync(
string metric, long amount, CancellationToken cancellationToken = default);
```
@@ -27,7 +27,7 @@ public Task<IReadOnlyList<string>> ReportProgressAsync(
```csharp
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}"
}
```
@@ -40,10 +40,10 @@ metric is a custom string passed to `ReportProgressAsync`.
```csharp
public class Quest
{
public string? Id { get; set; } // "id"
public string? Name { get; set; } // "name"
public List<QuestObjectiveProgress>? Objectives { get; set; }// "objectives"
public List<Reward>? Rewards { get; set; } // "rewards"
public string? Slug { get; set; } // "slug" — stable across environments
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
cache before `Scenario.OnConfigChanged` fires (see scenarios.md).
Scenario `remote_config_override` nodes are applied server-side and do not
reach the client. Call `LoadAsync` (or `GetAsync`) to observe the patched
value via `Get<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`,
`Services/Scenarios/Models/*.cs`, `Services/Scenarios/Sessions/*.cs`,
`Scenarios/*.cs` (generated DTOs), `Models/*.cs`.
Source: `Services/ScenarioService.cs`, `Services/EffectsService.cs`,
`Services/Effects/*.cs`, `Scenarios/*.cs` (generated DTOs).
Scenario runtime for server-issued execution plans. `TriggerAsync(eventName)`
starts plans; active nodes surface as typed sessions through the `On*` events
and are advanced by completing those sessions (which crosses server-validated
boundaries). Requires `Update(deltaTime)` every frame for wait-node deadlines.
Scenarios are server-authored node graphs (configured in the dashboard) that
run per player. The **server owns the graph**. This SDK is a thin effects
client: it sends trigger events, polls pending effects, raises typed
`client.Effects.On*` events, and posts callbacks when the game completes an
effect. There is no local plan, no `IPlanStateStore`, and no restore.
## ScenarioService surface
## Lifecycle
- `client.Scenario.TriggerAsync(eventName)` POSTs
`/sdk/v1/scenarios/trigger` with `{event}`. Returned `PendingEffect`s are
ingested into `client.Effects` (they do **not** come back as a return
value).
- Drive `client.Update(deltaTime)` every frame. That pumps
`EffectsService.Update`: a 30s heartbeat (`GET /sdk/v1/scenarios/pending`)
plus an immediate pending fetch when any wait deadline is due. The pump
no-ops when there is no access token, and it will not start a second
fetch while one is in flight.
- After login (`AuthStateChanged``SignedIn`), or when the client is
constructed with an already-stored access token, a pending fetch is marked
due and runs on the next `Update`. Sign-out / logout clears the seen-effect
set and wait deadlines.
- This SDK does **not** fire a login scenario event (`loginEvent` /
`player_login` is a web-SDK option only). Trigger login-gated scenarios
yourself with `TriggerAsync`.
- An effect with an already-seen `(runId, nodeId)` is not re-emitted. 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
public sealed partial class ScenarioService
public sealed class ScenarioService
{
// Node events
public event Action<NotificationSession>? OnNotification;
public event Action<StoreOfferSession>? OnStoreOffer;
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);
// POST /sdk/v1/scenarios/trigger — body {event}; returned effects are
// ingested into client.Effects. Returns when the HTTP call finishes.
public Task TriggerAsync(string eventName, CancellationToken ct = default);
}
```
Prefer session methods over `RespondAsync`/`UpdateProgressAsync` — the
parameterless variants only address the first active node of the first run.
## `EffectsService` (`client.Effects`)
## Node types and session handles
```csharp
public sealed class EffectsService
{
public event Action<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`) |
| `store` | `OnStoreOffer` | `onPurchase`, `onDecline` (`Purchase*`/`Decline*`, one-shot via `IsResolved`) |
| `wait` | `OnWait` | auto-completes `onComplete` at `WaitSession.DeadlineUtc` |
| `remote_config_override` | `OnConfigChanged` | auto-completes `output` after applying patches to `RemoteConfig` |
| `quest` | `OnQuest` | `onComplete`, `onFail`; `AddProgressAsync(counterKey, amount)` feeds counters |
| `leaderboard` | `OnLeaderboard` | `onEnd` (`End*`), `onClaim` (`Claim*` / obsolete `RewardClaimed*`) |
| `battlepass` | `OnBattlePass` | `onLevelUp`, `onMaxLevel`, `onComplete`, `onPremiumPurchase` (see battlepass.md) |
| `battlepass_level` | `OnBattlePassLevel` | `onComplete` via `ClaimAsync` — server checks the configured level |
| `notification` | `OnNotification` | `Done*` → handle `output` |
| `store` | `OnStoreOffer` | `Purchase*` `onPurchase`; `Decline*``onDecline` (one-shot via `IsResolved`) |
| `leaderboard` | `OnLeaderboard` | `End*``onEnd`; `Claim*` `onClaim` (one-shot via `IsResolved`) |
| `wait` | `OnWait` | none — server advances at `DeadlineUtc`; SDK polls pending then |
| `quest` | `OnQuest` | `ReportProgress*` via counter; server auto-completes when all objectives are satisfied |
| `battlepass` | `OnBattlePass` | `LevelUp*``onLevelUp`; `End*` `onComplete`; successful `PurchasePremiumAsync``onPremiumPurchase` |
| `battlepass_level` | `OnBattlePassLevel` | `Claim*``onComplete` (server checks the configured level) |
`remote_config_override` is applied server-side and is **not** delivered to
the client. There is no `OnConfigChanged`. Reload `client.RemoteConfig` to
observe patched values.
An unsupported node type fails the run (`OnScenarioFailed`) instead of
stalling. All sessions expose `Context` (`ScenarioNodeContext`), `Id` (node
id) and `Get<T>(key, defaultValue)` for reading node data.
stalling.
## ScenarioNodeContext
### `NotificationEffect`
```csharp
public PlanRun Run { get; }
public ExecutionPlanNode Node { get; }
public string RunId / PlanId / ScenarioId / NodeId / Type { get; }
public JObject Data { get; }
public T Get<T>(string key, T defaultValue = default!);
public T Get<T>(); // whole payload
public Dictionary<string, object> AsObjectDictionary();
public string Title { get; } // node data "title"
public string Message { get; } // node data "message"
public Task DoneAsync(CancellationToken ct = default); // handle "output"
public void Done();
```
## PlanRun and failure event
### `StoreOfferEffect`
Posts the scenario callback only — it does **not** call `Stores.PurchaseAsync`.
Purchase through `client.Stores` first if the offer should actually be bought,
then complete the effect.
```csharp
public sealed class PlanRun
public string StoreSlug { get; } // node data "storeSlug"
public string? Message { get; } // node data "message", null if empty
public bool IsResolved { get; } // true after Purchase* or Decline* once
public Task PurchaseAsync(CancellationToken ct = default); // "onPurchase"
public void Purchase();
public Task DeclineAsync(CancellationToken ct = default); // "onDecline"
public void Decline();
```
### `LeaderboardEffect`
```csharp
public bool IsResolved { get; }
public Task EndAsync(CancellationToken ct = default); // "onEnd"
public void End();
public Task ClaimAsync(CancellationToken ct = default); // "onClaim" — server matches live rank to a place
public void Claim();
```
### `WaitEffect`
```csharp
public DateTimeOffset DeadlineUtc { get; }
```
No completion method. When `Clock.UtcNow >= DeadlineUtc`, the next `Update`
fetches pending so the server-advanced successor can be dispatched.
### `QuestEffect`
Scenario quest node — distinct from global `client.Quests`.
```csharp
public string Name { get; } // node data "name"
public IReadOnlyList<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 PlanId { get; }
public string ScenarioId { get; }
public string UserId { get; }
public IReadOnlyList<string> ActiveNodeIds { get; }
public ExecutionPlan Plan { get; }
public string ScenarioSlug { 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 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
public class ExecutionPlan
public class PendingEffect
{
public List<BoundaryNode>? BoundaryNodes { get; set; }
public JToken? Context { get; set; }
public List<PlanEdge>? Edges { get; set; }
public List<ExecutionPlanNode>? Nodes { get; set; }
public string? PlanId { get; set; }
public JToken Data { get; set; }
public string NodeId { get; set; }
public string RunId { get; set; }
public string ScenarioSlug { 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? ScenarioId { get; set; }
public string? StartNodeId { get; set; }
public string? UserId { get; set; }
public string? ScenarioSlug { 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}
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
// make retries safe.
public Task<PurchaseOfferResponse> PurchaseAsync(
string storeSlug, string offerId, string? idempotencyKey = null,
string storeSlug, string offerSlug, string? idempotencyKey = null,
CancellationToken cancellationToken = default);
```
@@ -44,6 +44,7 @@ public class Store
public class Offer
{
public string? Id { get; set; } // "id"
public string? Slug { get; set; } // "slug" — stable across environments
public string? Name { get; set; } // "name"
public OfferPrice? Price { get; set; } // "price"
public List<OfferContent>? Contents { get; set; }// "contents"
@@ -65,5 +66,5 @@ public class PurchaseOfferResponse
`PurchaseOfferResponse.Error` carries a business error string when
`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.
+3 -65
View File
@@ -23,7 +23,7 @@ public sealed class RudderClientTests
Assert.NotNull(client.BattlePass);
Assert.NotNull(client.Quests);
Assert.NotNull(client.Scenario);
Assert.NotNull(client.Realtime);
Assert.NotNull(client.Effects);
}
[Fact]
@@ -85,37 +85,17 @@ public sealed class RudderClientTests
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(
FakeTransport? transport = null,
FakeTokenStore? tokenStore = null,
IRealtimeTransportFactory? realtimeTransportFactory = null)
FakeTokenStore? tokenStore = null)
{
return new RudderClient(new RudderClientOptions
{
BaseUrl = "http://localhost:8082",
RealtimeUrl = "ws://localhost:8090/api/realtime/ws",
ProjectKey = "project-key",
Transport = transport ?? new FakeTransport(),
TokenStore = tokenStore ?? new FakeTokenStore(),
DeviceIdProvider = new FakeDeviceIdProvider(),
RealtimeTransportFactory = realtimeTransportFactory ?? new FakeRealtimeTransportFactory(new FakeRealtimeTransport())
DeviceIdProvider = new FakeDeviceIdProvider()
});
}
@@ -169,46 +149,4 @@ public sealed class RudderClientTests
{
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.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Auth;
using RudderSdk.Core.Models.Scenarios;
using Xunit;
@@ -11,60 +12,98 @@ namespace RudderSdk.Core.Tests;
public sealed class ScenarioServiceTests
{
[Fact]
public async Task TriggerAsync_Starts_All_Returned_Plans()
public async Task TriggerAsync_Emits_Returned_Effects()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
Effects = new List<PendingEffect>
{
Plan("plan-1", Node("n1", "notification")),
Plan("plan-2", Node("n2", "notification"))
Effect("run-1", "n1", "notification"),
Effect("run-2", "n2", "notification")
}
});
var client = CreateClient(transport);
var notifications = new List<NotificationSession>();
client.Scenario.OnNotification += notifications.Add;
var notifications = new List<NotificationEffect>();
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, started.Count);
Assert.Equal(2, client.Scenario.ActiveRuns.Count);
Assert.Equal("n1", notifications[0].NodeId);
Assert.Equal("n2", notifications[1].NodeId);
}
[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();
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(
"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")
})
}
Effect = JObject.FromObject(Effect("run-1", "store", "store"))
});
var client = CreateClient(transport);
NotificationSession? notification = null;
var stores = 0;
var waits = 0;
client.Scenario.OnNotification += session => notification = session;
client.Scenario.OnStoreOffer += _ => stores++;
client.Scenario.OnWait += _ => waits++;
NotificationEffect? notification = null;
StoreOfferEffect? store = null;
client.Effects.OnNotification += e => notification = e;
client.Effects.OnStoreOffer += e => store = e;
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);
Assert.Equal(1, waits);
Assert.Equal(2, client.Scenario.ActiveRuns.Single().ActiveNodeIds.Count);
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
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]
@@ -73,224 +112,166 @@ public sealed class ScenarioServiceTests
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
Effects = new List<PendingEffect>
{
Plan(
"plan",
new[]
{
Node("start", "notification"),
Node("store", "store"),
Node("quest", "quest"),
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"),
})
Effect("run-1", "n1", "notification"),
Effect("run-2", "n2", "store"),
Effect("run-3", "n3", "quest"),
Effect("run-4", "n4", "leaderboard"),
Effect("run-5", "n5", "battlepass"),
Effect("run-6", "n6", "battlepass_level", new { levelNumber = 3 }),
Effect("run-7", "n7", "wait", waitDeadline: new DateTimeOffset(2026, 5, 30, 10, 30, 0, TimeSpan.Zero))
}
});
var client = CreateClient(transport);
NotificationSession? notification = null;
var notification = 0;
var store = 0;
var quest = 0;
var leaderboard = 0;
var battlePass = 0;
var battlePassLevel = 0;
client.Scenario.OnNotification += session => notification = session;
client.Scenario.OnStoreOffer += _ => store++;
client.Scenario.OnQuest += _ => quest++;
client.Scenario.OnLeaderboard += _ => leaderboard++;
client.Scenario.OnBattlePass += _ => battlePass++;
client.Scenario.OnBattlePassLevel += _ => battlePassLevel++;
var wait = 0;
WaitEffect? waitEffect = null;
BattlePassLevelEffect? levelEffect = null;
client.Effects.OnNotification += _ => notification++;
client.Effects.OnStoreOffer += _ => store++;
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 notification!.CompleteAsync();
Assert.Equal(1, notification);
Assert.Equal(1, store);
Assert.Equal(1, quest);
Assert.Equal(1, leaderboard);
Assert.Equal(1, battlePass);
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]
public async Task Wait_Persists_Deadline_And_Completes_After_Restore()
{
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()
public async Task Store_Purchase_And_Decline_Post_Matching_Handles()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
{
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") })
}
Effects = new List<PendingEffect> { Effect("run-1", "store", "store") }
});
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
var notifications = 0;
var configChanges = 0;
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++;
StoreOfferEffect? store = null;
client.Effects.OnStoreOffer += e => store = e;
await client.Scenario.TriggerAsync("login");
await store!.PurchaseAsync();
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("store", callback.NodeId);
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]
public async Task Boundary_Wins_Over_Local_Edges()
public async Task Leaderboard_End_Posts_OnEnd()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
{
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")
Effects = new List<PendingEffect> { Effect("run-1", "lb", "leaderboard") }
});
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
StoreOfferSession? store = null;
var notificationIds = new List<string>();
client.Scenario.OnStoreOffer += session => store = session;
client.Scenario.OnNotification += session => notificationIds.Add(session.Id);
LeaderboardEffect? leaderboard = null;
client.Effects.OnLeaderboard += e => leaderboard = e;
await client.Scenario.TriggerAsync("login");
await store!.PurchaseAsync();
await leaderboard!.EndAsync();
Assert.Equal(new[] { "server" }, notificationIds);
Assert.Contains(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback");
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onEnd", callback.Handle);
}
[Fact]
public async Task Quest_Progress_Completed_Response_Completes_The_Node()
public async Task Leaderboard_Claim_Posts_OnClaim()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
{
Plan("plan", Node("quest", "quest"))
}
Effects = new List<PendingEffect> { Effect("run-1", "lb", "leaderboard") }
});
transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true });
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
QuestSession? quest = null;
var completed = 0;
client.Scenario.OnQuest += session => quest = session;
client.Scenario.OnScenarioCompleted += _ => completed++;
LeaderboardEffect? leaderboard = null;
client.Effects.OnLeaderboard += e => leaderboard = e;
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>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/counter").Request);
Assert.Equal("quest", counter.NodeId);
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.Empty(client.Scenario.ActiveRuns);
}
[Fact]
@@ -299,129 +280,253 @@ public sealed class ScenarioServiceTests
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
{
Plan("plan", Node("quest", "quest"))
}
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest") }
});
transport.Enqueue(new InvalidOperationException("boom"));
var logger = new FakeLogger();
var client = CreateClient(transport, logger: logger);
QuestSession? quest = null;
QuestEffect? quest = null;
var failed = 0;
client.Scenario.OnQuest += session => quest = session;
client.Scenario.OnScenarioFailed += _ => failed++;
client.Effects.OnQuest += e => quest = e;
client.Effects.OnScenarioFailed += _ => failed++;
await client.Scenario.TriggerAsync("login");
await quest!.AddProgressAsync("wins", 1);
await quest!.ReportProgressAsync("wins", 1);
Assert.Equal(0, failed);
Assert.Single(client.Scenario.ActiveRuns);
Assert.Contains(logger.Messages, m =>
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]
public async Task Unknown_Nodes_Fail_The_Run()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Plans = new List<ExecutionPlan>
{
Plan("plan", Node("future", "future_node"))
}
Effects = new List<PendingEffect> { Effect("run-1", "future", "future_node") }
});
var logger = new FakeLogger();
var client = CreateClient(transport, logger: logger);
ScenarioFailedEvent? failure = null;
client.Scenario.OnScenarioFailed += e => failure = e;
ScenarioFailedEffect? failure = null;
client.Effects.OnScenarioFailed += e => failure = e;
await client.Scenario.TriggerAsync("login");
Assert.NotNull(failure);
Assert.Equal("future", failure!.NodeId);
Assert.Empty(client.Scenario.ActiveRuns);
Assert.Contains(logger.Messages, m =>
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(
FakeTransport transport,
FakeClock? clock = null,
FakePlanStateStore? stateStore = null,
IRudderLogger? logger = null)
IRudderLogger? logger = null,
FakeTokenStore? tokenStore = null)
{
return new RudderClient(new RudderClientOptions
{
BaseUrl = "http://localhost:8082",
RealtimeUrl = "ws://localhost:8090/api/realtime/ws",
ProjectKey = "project-key",
Transport = transport,
TokenStore = new FakeTokenStore { AccessToken = "access-token" },
TokenStore = tokenStore ?? new FakeTokenStore { AccessToken = "access-token" },
DeviceIdProvider = new FakeDeviceIdProvider(),
Clock = clock ?? new FakeClock(DateTimeOffset.UtcNow),
PlanStateStore = stateStore ?? new FakePlanStateStore(),
RealtimeTransportFactory = new FakeRealtimeTransportFactory(),
Logger = logger
});
}
private static ExecutionPlan Plan(string id, ExecutionPlanNode node, string? runId = 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)
private static PendingEffect Effect(string runId, string nodeId, string type, object? data = null, DateTimeOffset? waitDeadline = null)
{
var nodeList = nodes.ToList();
return new ExecutionPlan
return new PendingEffect
{
PlanId = id,
ScenarioId = "scenario-" + id,
UserId = "user",
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,
RunId = runId,
ScenarioSlug = "scenario-1",
NodeId = nodeId,
Type = type,
Data = data == null ? new JObject() : JObject.FromObject(data)
};
}
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"
Data = data == null ? new JObject() : JObject.FromObject(data),
WaitDeadline = waitDeadline
};
}
@@ -443,7 +548,11 @@ public sealed class ScenarioServiceTests
if (_responses.Count == 0)
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; }
}
private sealed class FakePlanStateStore : IPlanStateStore
{
public string? State { get; set; }
}
private sealed class FakeLogger : IRudderLogger
{
public List<(RudderLogLevel Level, string Message)> Messages { get; } = new();
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
}