Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef5624ee4e | |||
| 1029f08fba | |||
| 6c590ca520 | |||
| 05dd30f31d |
@@ -2,7 +2,7 @@ using System;
|
|||||||
|
|
||||||
namespace RudderSdk.Core.Abstractions;
|
namespace RudderSdk.Core.Abstractions;
|
||||||
|
|
||||||
/// <summary>Time source used by the scenario runtime; override in tests.</summary>
|
/// <summary>Time source used by the effects client; override in tests.</summary>
|
||||||
public interface IClock
|
public interface IClock
|
||||||
{
|
{
|
||||||
/// <summary>Current UTC time.</summary>
|
/// <summary>Current UTC time.</summary>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using RudderSdk.Core.Models;
|
|
||||||
|
|
||||||
namespace RudderSdk.Core.Models.BattlePass;
|
namespace RudderSdk.Core.Models.BattlePass;
|
||||||
|
|
||||||
@@ -15,9 +14,6 @@ public class AddBattlePassXpResponse
|
|||||||
[JsonProperty("maxLevel")]
|
[JsonProperty("maxLevel")]
|
||||||
public bool? MaxLevel { get; set; }
|
public bool? MaxLevel { get; set; }
|
||||||
|
|
||||||
[JsonProperty("plan")]
|
|
||||||
public ExecutionPlan? Plan { get; set; }
|
|
||||||
|
|
||||||
[JsonProperty("xp")]
|
[JsonProperty("xp")]
|
||||||
public long? Xp { get; set; }
|
public long? Xp { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using RudderSdk.Core.Models;
|
|
||||||
|
|
||||||
namespace RudderSdk.Core.Models.BattlePass;
|
namespace RudderSdk.Core.Models.BattlePass;
|
||||||
|
|
||||||
@@ -9,9 +8,6 @@ public class PurchaseBattlePassPremiumResponse
|
|||||||
[JsonProperty("error")]
|
[JsonProperty("error")]
|
||||||
public string? Error { get; set; }
|
public string? Error { get; set; }
|
||||||
|
|
||||||
[JsonProperty("plan")]
|
|
||||||
public ExecutionPlan? Plan { get; set; }
|
|
||||||
|
|
||||||
[JsonProperty("success")]
|
[JsonProperty("success")]
|
||||||
public bool? Success { get; set; }
|
public bool? Success { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,51 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
Breaking change — major bump. The local scenario engine is replaced by a
|
||||||
|
server-driven effects client; the realtime websocket client is removed.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Local scenario runtime: `PlanRun`, `ExecutionPlan` walking, node sessions
|
||||||
|
(`NotificationSession`, `StoreOfferSession`, `LeaderboardSession`,
|
||||||
|
`WaitSession`, `QuestSession`, `BattlePassSession`,
|
||||||
|
`BattlePassLevelSession`, `ConfigChangedSession`), `RespondAsync` /
|
||||||
|
`Respond`, `RestoreAsync`, `Clear`, `ActiveRuns`, `IsRunning`,
|
||||||
|
`CurrentNodeId`, `OnConfigChanged`.
|
||||||
|
- `RudderClientOptions.PlanStateStore` / `IPlanStateStore` and
|
||||||
|
`IPlanScheduler` — no local plan persistence or delayed plan work.
|
||||||
|
- `RealtimeService` (`client.Realtime`), `RudderClientOptions.RealtimeUrl` /
|
||||||
|
`RealtimeTransportFactory`, `IRealtimeTransport` /
|
||||||
|
`IRealtimeTransportFactory`.
|
||||||
|
|
||||||
|
### Changed (breaking)
|
||||||
|
|
||||||
|
- `client.Scenario` is a trigger only: `TriggerAsync(eventName)` returns
|
||||||
|
`Task` (not started plans). Returned pending effects are ingested into
|
||||||
|
`client.Effects`.
|
||||||
|
- Subscribe to `client.Effects.On*` instead of `client.Scenario.On*`.
|
||||||
|
Sessions are now effect objects (`NotificationEffect`, `StoreOfferEffect`,
|
||||||
|
…). Complete them with the methods on the effect (`DoneAsync`,
|
||||||
|
`PurchaseAsync`/`DeclineAsync`, `EndAsync`/`ClaimAsync`, …).
|
||||||
|
- `client.Update(deltaTime)` is still required every frame — it now pumps
|
||||||
|
the effects client (30s `GET /sdk/v1/scenarios/pending` heartbeat +
|
||||||
|
wait-deadline checks), not a local DAG or a websocket.
|
||||||
|
- After login (or when constructed with a stored access token), the next
|
||||||
|
`Update` fetches pending effects. There is no `loginEvent` option; trigger
|
||||||
|
login-gated scenarios yourself.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
| Before | After |
|
||||||
|
|---|---|
|
||||||
|
| `client.Scenario.OnNotification` (and other `On*`) | `client.Effects.OnNotification` (same event names) |
|
||||||
|
| `NotificationSession` / `StoreOfferSession` / … | `NotificationEffect` / `StoreOfferEffect` / … |
|
||||||
|
| `session.CompleteAsync()` / `RespondAsync("output")` | `effect.DoneAsync()` (and the matching method on each effect) |
|
||||||
|
| `RudderClientOptions.PlanStateStore` + `RestoreAsync` | gone — server owns run state; pending fetch after login + heartbeat |
|
||||||
|
| `client.Realtime` | gone |
|
||||||
|
| `client.Update(deltaTime)` | still required (effects pump) |
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
- Generated DTOs live under `Models/` (`RudderSdk.Core.Models`). Wire fields
|
- Generated DTOs live under `Models/` (`RudderSdk.Core.Models`). Wire fields
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,9 @@ namespace RudderSdk.Core.Models.Player;
|
|||||||
|
|
||||||
public class Player
|
public class Player
|
||||||
{
|
{
|
||||||
|
[JsonProperty("avatarUrl")]
|
||||||
|
public string? AvatarUrl { get; set; }
|
||||||
|
|
||||||
[JsonProperty("createdAt")]
|
[JsonProperty("createdAt")]
|
||||||
public DateTimeOffset? CreatedAt { get; set; }
|
public DateTimeOffset? CreatedAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
.NET client SDK for the Rudder LiveOps platform: authentication, player
|
.NET client SDK for the Rudder LiveOps platform: authentication, player
|
||||||
profile, stores, battle pass, quests, leaderboards, inventory, remote config,
|
profile, stores, battle pass, quests, leaderboards, inventory, remote config,
|
||||||
scenarios, storage and realtime.
|
scenarios (server-driven effects), and storage.
|
||||||
|
|
||||||
- Target framework: `netstandard2.1` (works in Unity, .NET, Xamarin).
|
- Target framework: `netstandard2.1` (works in Unity, .NET, Xamarin).
|
||||||
- JSON: Newtonsoft.Json.
|
- JSON: Newtonsoft.Json.
|
||||||
@@ -36,7 +36,7 @@ Console.WriteLine(profile.Player.Id);
|
|||||||
// Buy an offer (idempotency key is generated when omitted).
|
// Buy an offer (idempotency key is generated when omitted).
|
||||||
var purchase = await client.Stores.PurchaseAsync("main-store", "offer-1");
|
var purchase = await client.Stores.PurchaseAsync("main-store", "offer-1");
|
||||||
|
|
||||||
// Pump time-dependent services (scenarios, realtime) every frame.
|
// Pump the effects client (30s pending heartbeat + wait deadlines) every frame.
|
||||||
client.Update(deltaTime);
|
client.Update(deltaTime);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -58,15 +58,15 @@ stack.
|
|||||||
| `Inventory` | `InventoryService` | `GetAsync` |
|
| `Inventory` | `InventoryService` | `GetAsync` |
|
||||||
| `Leaderboards` | `LeaderboardsService` | `FindBySlug(slug)` → handle: `SubmitAsync`, `ListAsync` |
|
| `Leaderboards` | `LeaderboardsService` | `FindBySlug(slug)` → handle: `SubmitAsync`, `ListAsync` |
|
||||||
| `RemoteConfig` | `RemoteConfigService` | `LoadAsync`, `Get<T>`, `GetAsync<T>` |
|
| `RemoteConfig` | `RemoteConfigService` | `LoadAsync`, `Get<T>`, `GetAsync<T>` |
|
||||||
| `Scenario` | `ScenarioService` | `TriggerAsync`, `RestoreAsync`, `On*` effect events |
|
| `Scenario` | `ScenarioService` | `TriggerAsync` |
|
||||||
|
| `Effects` | `EffectsService` | `On*` effect events; complete via methods on the effect objects |
|
||||||
| `Storage` | `StorageService` | `GetAsync`, `ListAllAsync`, `SaveAsync`, `DeleteAsync` |
|
| `Storage` | `StorageService` | `GetAsync`, `ListAllAsync`, `SaveAsync`, `DeleteAsync` |
|
||||||
| `Realtime` | `RealtimeService` | `ConnectAsync`, `DisconnectAsync` |
|
|
||||||
|
|
||||||
## Quests
|
## Quests
|
||||||
|
|
||||||
`client.Quests` covers the player's global quests — list with per-objective
|
`client.Quests` covers the player's global quests — list with per-objective
|
||||||
progress, claim, and metric reports. These are distinct from scenario quest
|
progress, claim, and metric reports. These are distinct from scenario quest
|
||||||
nodes, which advance through `QuestSession` (`Scenario.OnQuest`).
|
nodes, which advance through `QuestEffect` (`Effects.OnQuest`).
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var quests = await client.Quests.ListAsync();
|
var quests = await client.Quests.ListAsync();
|
||||||
@@ -108,11 +108,18 @@ All of them carry `StatusCode` (`int`), the machine-readable `Code`
|
|||||||
|
|
||||||
## Scenario effects
|
## Scenario effects
|
||||||
|
|
||||||
Subscribe to typed sessions on `client.Scenario`:
|
The server owns scenario execution. Trigger with `client.Scenario.TriggerAsync`,
|
||||||
|
subscribe to typed effects on `client.Effects`, and call `client.Update` every
|
||||||
|
frame:
|
||||||
|
|
||||||
`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnConfigChanged`,
|
`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnWait`, `OnQuest`,
|
||||||
`OnWait`, `OnQuest`, `OnBattlePass`, `OnBattlePassLevel`,
|
`OnBattlePass`, `OnBattlePassLevel`, `OnScenarioCompleted`,
|
||||||
`OnScenarioCompleted`, `OnScenarioFailed`.
|
`OnScenarioFailed`.
|
||||||
|
|
||||||
|
Complete an effect with the methods on the object (`DoneAsync`,
|
||||||
|
`PurchaseAsync`/`DeclineAsync`, `EndAsync`/`ClaimAsync`, …) or the run
|
||||||
|
stalls. `unknown_run` / `run_expired` drop the run and fire
|
||||||
|
`OnScenarioFailed`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -6,9 +6,9 @@
|
|||||||
<AssemblyName>Rudder.Core</AssemblyName>
|
<AssemblyName>Rudder.Core</AssemblyName>
|
||||||
<RootNamespace>RudderSdk.Core</RootNamespace>
|
<RootNamespace>RudderSdk.Core</RootNamespace>
|
||||||
<PackageId>Rudder.Core</PackageId>
|
<PackageId>Rudder.Core</PackageId>
|
||||||
<Version>0.4.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<Authors>Rudder</Authors>
|
<Authors>Rudder</Authors>
|
||||||
<Description>Rudder LiveOps client SDK for .NET: auth, player, stores, battle pass, quests, leaderboards, inventory, remote config, scenarios, storage and realtime.</Description>
|
<Description>Rudder LiveOps client SDK for .NET: auth, player, stores, battle pass, quests, leaderboards, inventory, remote config, scenarios and storage.</Description>
|
||||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
+7
-13
@@ -47,11 +47,11 @@ public sealed class RudderClient
|
|||||||
/// <summary>Global quests.</summary>
|
/// <summary>Global quests.</summary>
|
||||||
public QuestsService Quests { get; }
|
public QuestsService Quests { get; }
|
||||||
|
|
||||||
/// <summary>Scenario runtime: triggers, node sessions, persistence.</summary>
|
/// <summary>Scenario trigger. Execution lives on the server.</summary>
|
||||||
public ScenarioService Scenario { get; }
|
public ScenarioService Scenario { get; }
|
||||||
|
|
||||||
/// <summary>Realtime websocket channel.</summary>
|
/// <summary>Pending scenario effects: subscriptions and completion callbacks.</summary>
|
||||||
public RealtimeService Realtime { get; }
|
public EffectsService Effects { get; }
|
||||||
|
|
||||||
internal RudderClientOptions Options { get; }
|
internal RudderClientOptions Options { get; }
|
||||||
internal IRudderTransport Transport => Options.Transport!;
|
internal IRudderTransport Transport => Options.Transport!;
|
||||||
@@ -61,10 +61,7 @@ public sealed class RudderClient
|
|||||||
/// <summary>Project key from <see cref="RudderClientOptions.ProjectKey"/>.</summary>
|
/// <summary>Project key from <see cref="RudderClientOptions.ProjectKey"/>.</summary>
|
||||||
public string ProjectKey => Options.ProjectKey!;
|
public string ProjectKey => Options.ProjectKey!;
|
||||||
|
|
||||||
/// <summary>Realtime URL from <see cref="RudderClientOptions.RealtimeUrl"/>.</summary>
|
/// <summary>Time source used by the effects client.</summary>
|
||||||
public string? RealtimeUrl => Options.RealtimeUrl;
|
|
||||||
|
|
||||||
/// <summary>Time source used by the scenario runtime.</summary>
|
|
||||||
public IClock Clock => Options.Clock ?? SystemClock.Instance;
|
public IClock Clock => Options.Clock ?? SystemClock.Instance;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -90,17 +87,16 @@ public sealed class RudderClient
|
|||||||
Stores = new StoresService(this);
|
Stores = new StoresService(this);
|
||||||
Leaderboards = new LeaderboardsService(this);
|
Leaderboards = new LeaderboardsService(this);
|
||||||
Inventory = new InventoryService(this);
|
Inventory = new InventoryService(this);
|
||||||
Scenario = new ScenarioService(this);
|
|
||||||
BattlePass = new BattlePassService(this);
|
BattlePass = new BattlePassService(this);
|
||||||
Quests = new QuestsService(this);
|
Quests = new QuestsService(this);
|
||||||
Realtime = new RealtimeService(this);
|
Effects = new EffectsService(this);
|
||||||
|
Scenario = new ScenarioService(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Pumps time-dependent services; call every frame.</summary>
|
/// <summary>Pumps time-dependent services; call every frame.</summary>
|
||||||
public void Update(float deltaTime)
|
public void Update(float deltaTime)
|
||||||
{
|
{
|
||||||
Scenario.Update(deltaTime);
|
Effects.Update(deltaTime);
|
||||||
Realtime.Update(deltaTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal Task<TResponse> SendAsync<TRequest, TResponse>(
|
internal Task<TResponse> SendAsync<TRequest, TResponse>(
|
||||||
@@ -151,7 +147,6 @@ public sealed class RudderClient
|
|||||||
}
|
}
|
||||||
catch (RudderAuthException)
|
catch (RudderAuthException)
|
||||||
{
|
{
|
||||||
// Session rejected — refresh once (single-flight) and retry the call once.
|
|
||||||
if (!await RefreshTokensAsync().ConfigureAwait(false))
|
if (!await RefreshTokensAsync().ConfigureAwait(false))
|
||||||
throw;
|
throw;
|
||||||
|
|
||||||
@@ -218,7 +213,6 @@ public sealed class RudderClient
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Transport-level failure during refresh — session is over.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-13
@@ -12,9 +12,6 @@ public sealed class RudderClientOptions
|
|||||||
/// <summary>API base URL, e.g. https://api.example.com. Required.</summary>
|
/// <summary>API base URL, e.g. https://api.example.com. Required.</summary>
|
||||||
public string? BaseUrl { get; set; }
|
public string? BaseUrl { get; set; }
|
||||||
|
|
||||||
/// <summary>Realtime websocket URL. Required only for <see cref="RealtimeService"/>.</summary>
|
|
||||||
public string? RealtimeUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Project key issued in the admin panel. Required.</summary>
|
/// <summary>Project key issued in the admin panel. Required.</summary>
|
||||||
public string? ProjectKey { get; set; }
|
public string? ProjectKey { get; set; }
|
||||||
|
|
||||||
@@ -30,15 +27,6 @@ public sealed class RudderClientOptions
|
|||||||
/// <summary>Diagnostic sink. Null by default (silent).</summary>
|
/// <summary>Diagnostic sink. Null by default (silent).</summary>
|
||||||
public IRudderLogger? Logger { get; set; }
|
public IRudderLogger? Logger { get; set; }
|
||||||
|
|
||||||
/// <summary>Time source for the scenario runtime; override in tests.</summary>
|
/// <summary>Time source for the effects client; override in tests.</summary>
|
||||||
public IClock? Clock { get; set; }
|
public IClock? Clock { get; set; }
|
||||||
|
|
||||||
/// <summary>Scenario-run persistence between app launches. Optional.</summary>
|
|
||||||
public IPlanStateStore? PlanStateStore { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Optional scheduler for delayed plan work.</summary>
|
|
||||||
public IPlanScheduler? Scheduler { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Realtime transport factory. Required only for <see cref="RealtimeService"/>.</summary>
|
|
||||||
public IRealtimeTransportFactory? RealtimeTransportFactory { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using RudderSdk.Core.Models;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace RudderSdk.Core.Models.Scenarios;
|
namespace RudderSdk.Core.Models.Scenarios;
|
||||||
|
|
||||||
public class HandleScenarioCallbackResponse
|
public class HandleScenarioCallbackResponse
|
||||||
{
|
{
|
||||||
[JsonProperty("plan")]
|
[JsonProperty("effect")]
|
||||||
public ExecutionPlan? Plan { get; set; }
|
public JToken? Effect { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core.Models.Scenarios;
|
||||||
|
|
||||||
|
public class ListPendingScenarioEffectsResponse
|
||||||
|
{
|
||||||
|
[JsonProperty("effects")]
|
||||||
|
public List<PendingEffect> Effects { get; set; } = null!;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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("scenarioId")]
|
||||||
|
public string ScenarioId { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("type")]
|
||||||
|
public string Type { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("waitDeadline")]
|
||||||
|
public DateTimeOffset? WaitDeadline { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using RudderSdk.Core.Models;
|
|
||||||
|
|
||||||
namespace RudderSdk.Core.Models.Scenarios;
|
namespace RudderSdk.Core.Models.Scenarios;
|
||||||
|
|
||||||
public class TriggerScenarioResponse
|
public class TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
[JsonProperty("plans")]
|
[JsonProperty("effects")]
|
||||||
public List<ExecutionPlan>? Plans { get; set; }
|
public List<PendingEffect> Effects { get; set; } = null!;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by apigen. DO NOT EDIT.
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using RudderSdk.Core.Models;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace RudderSdk.Core.Models.Scenarios;
|
namespace RudderSdk.Core.Models.Scenarios;
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ public class UpdateScenarioCounterResponse
|
|||||||
[JsonProperty("completed")]
|
[JsonProperty("completed")]
|
||||||
public bool? Completed { get; set; }
|
public bool? Completed { get; set; }
|
||||||
|
|
||||||
[JsonProperty("plan")]
|
[JsonProperty("effect")]
|
||||||
public ExecutionPlan? Plan { get; set; }
|
public JToken? Effect { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace RudderSdk.Core;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Battle pass progress and rewards. Battle pass state is tied to a scenario
|
/// Battle pass progress and rewards. Battle pass state is tied to a scenario
|
||||||
/// battle pass node, so every call carries the scenario/node ids (and a run id
|
/// battle pass node, so every call carries the scenario/node ids (and a run id
|
||||||
/// for the mutating calls) — <see cref="BattlePassSession"/> supplies them
|
/// for the mutating calls) — <see cref="BattlePassEffect"/> supplies them
|
||||||
/// during scenario runs.
|
/// during scenario runs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BattlePassService
|
public sealed class BattlePassService
|
||||||
|
|||||||
@@ -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 ScenarioId => _handle.ScenarioId;
|
||||||
|
|
||||||
|
/// <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.ScenarioId, _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
|
||||||
|
{
|
||||||
|
ScenarioId = _handle.ScenarioId,
|
||||||
|
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
|
||||||
|
{
|
||||||
|
ScenarioId = _handle.ScenarioId,
|
||||||
|
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
|
||||||
|
{
|
||||||
|
ScenarioId = _handle.ScenarioId,
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -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 ScenarioId => _handle.ScenarioId;
|
||||||
|
|
||||||
|
/// <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");
|
||||||
|
}
|
||||||
@@ -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 ScenarioId => _handle.ScenarioId;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ScenarioId => _handle.ScenarioId;
|
||||||
|
|
||||||
|
/// <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");
|
||||||
|
}
|
||||||
@@ -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 ScenarioId => _handle.ScenarioId;
|
||||||
|
|
||||||
|
/// <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 scenarioId)
|
||||||
|
{
|
||||||
|
RunId = runId;
|
||||||
|
ScenarioId = scenarioId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-issued run id.</summary>
|
||||||
|
public string RunId { get; }
|
||||||
|
|
||||||
|
/// <summary>Scenario id.</summary>
|
||||||
|
public string ScenarioId { get; }
|
||||||
|
}
|
||||||
@@ -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 scenarioId, string nodeId, Exception exception)
|
||||||
|
{
|
||||||
|
RunId = runId;
|
||||||
|
ScenarioId = scenarioId;
|
||||||
|
NodeId = nodeId;
|
||||||
|
Exception = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-issued run id.</summary>
|
||||||
|
public string RunId { get; }
|
||||||
|
|
||||||
|
/// <summary>Scenario id.</summary>
|
||||||
|
public string ScenarioId { get; }
|
||||||
|
|
||||||
|
/// <summary>Node the failure happened at.</summary>
|
||||||
|
public string NodeId { get; }
|
||||||
|
|
||||||
|
/// <summary>The error that failed the run.</summary>
|
||||||
|
public Exception Exception { get; }
|
||||||
|
}
|
||||||
@@ -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 ScenarioId => _handle.ScenarioId;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ScenarioId => _handle.ScenarioId;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
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 HashSet<(string RunId, string NodeId)> _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.Add(key))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
ScenarioId = source.ScenarioId,
|
||||||
|
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.ScenarioId));
|
||||||
|
else
|
||||||
|
Ingest(new[] { next });
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsDefinitiveRejection(ex))
|
||||||
|
{
|
||||||
|
DropRun(source.RunId, source.ScenarioId, 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
|
||||||
|
{
|
||||||
|
ScenarioId = source.ScenarioId,
|
||||||
|
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.ScenarioId));
|
||||||
|
else
|
||||||
|
Ingest(new[] { next });
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsDefinitiveRejection(ex))
|
||||||
|
{
|
||||||
|
DropRun(source.RunId, source.ScenarioId, 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);
|
||||||
|
|
||||||
|
Ingest(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.ScenarioId,
|
||||||
|
effect.NodeId,
|
||||||
|
new Exception($"Unsupported scenario node type '{effect.Type}'")));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropRun(string runId, string scenarioId, string nodeId, Exception exception)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var toRemove = new List<(string RunId, string NodeId)>();
|
||||||
|
foreach (var key in _seen)
|
||||||
|
{
|
||||||
|
if (key.RunId == runId)
|
||||||
|
toRemove.Add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in toRemove)
|
||||||
|
{
|
||||||
|
_seen.Remove(key);
|
||||||
|
_waitDeadlines.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(OnScenarioFailed, new ScenarioFailedEffect(runId, scenarioId, nodeId, exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ScenarioId => _effect.ScenarioId;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace RudderSdk.Core;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global quests (list + claim), distinct from scenario quest nodes which
|
/// Global quests (list + claim), distinct from scenario quest nodes which
|
||||||
/// advance through <see cref="QuestSession"/>.
|
/// advance through <see cref="QuestEffect"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class QuestsService
|
public sealed class QuestsService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -1,71 +1,24 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using RudderSdk.Core.Abstractions;
|
|
||||||
using RudderSdk.Core.Models;
|
|
||||||
using RudderSdk.Core.Models.Scenarios;
|
using RudderSdk.Core.Models.Scenarios;
|
||||||
|
|
||||||
namespace RudderSdk.Core;
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scenario runtime. <see cref="TriggerAsync"/> starts server-issued plans;
|
/// Scenario trigger. Execution lives on the server; resulting effects surface
|
||||||
/// active nodes surface as typed sessions through the On* events and are
|
/// through <see cref="RudderClient.Effects"/>.
|
||||||
/// advanced by completing those sessions.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class ScenarioService
|
public sealed class ScenarioService
|
||||||
{
|
{
|
||||||
private readonly RudderClient _client;
|
private readonly RudderClient _client;
|
||||||
private readonly Dictionary<string, RuntimeRun> _runs = new();
|
|
||||||
|
|
||||||
/// <summary>Raised for a notification node.</summary>
|
|
||||||
public event Action<NotificationSession>? OnNotification;
|
|
||||||
|
|
||||||
/// <summary>Raised for a store-offer node.</summary>
|
|
||||||
public event Action<StoreOfferSession>? OnStoreOffer;
|
|
||||||
|
|
||||||
/// <summary>Raised for a leaderboard node.</summary>
|
|
||||||
public event Action<LeaderboardSession>? OnLeaderboard;
|
|
||||||
|
|
||||||
/// <summary>Raised for a remote-config-override node, after the patches were applied.</summary>
|
|
||||||
public event Action<ConfigChangedSession>? OnConfigChanged;
|
|
||||||
|
|
||||||
/// <summary>Raised for a wait node.</summary>
|
|
||||||
public event Action<WaitSession>? OnWait;
|
|
||||||
|
|
||||||
/// <summary>Raised for a quest node.</summary>
|
|
||||||
public event Action<QuestSession>? OnQuest;
|
|
||||||
|
|
||||||
/// <summary>Raised for a battle-pass node.</summary>
|
|
||||||
public event Action<BattlePassSession>? OnBattlePass;
|
|
||||||
|
|
||||||
/// <summary>Raised for a battle-pass-level node.</summary>
|
|
||||||
public event Action<BattlePassLevelSession>? OnBattlePassLevel;
|
|
||||||
|
|
||||||
/// <summary>Raised when a run finishes all its nodes.</summary>
|
|
||||||
public event Action<PlanRun>? OnScenarioCompleted;
|
|
||||||
|
|
||||||
/// <summary>Raised when a run dies on an unrecoverable error.</summary>
|
|
||||||
public event Action<ScenarioFailedEvent>? OnScenarioFailed;
|
|
||||||
|
|
||||||
internal ScenarioService(RudderClient client) => _client = client;
|
internal ScenarioService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
/// <summary>True while at least one run is active.</summary>
|
|
||||||
public bool IsRunning => _runs.Count > 0;
|
|
||||||
|
|
||||||
/// <summary>First active node id across the runs, or null.</summary>
|
|
||||||
public string? CurrentNodeId => _runs.Values.FirstOrDefault()?.ActiveNodes.Keys.FirstOrDefault();
|
|
||||||
|
|
||||||
/// <summary>Snapshots of the active runs.</summary>
|
|
||||||
public IReadOnlyList<PlanRun> ActiveRuns => _runs.Values.Select(ToPlanRun).ToList();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Triggers scenarios by event name and starts the plans the server
|
/// Triggers scenarios by event name. Returned pending effects are ingested
|
||||||
/// returns. Returns the runs this call started.
|
/// into <see cref="RudderClient.Effects"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IReadOnlyList<PlanRun>> TriggerAsync(string eventName, CancellationToken cancellationToken = default)
|
public async Task TriggerAsync(string eventName, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var response = await _client.SendAsync<TriggerScenarioRequest, TriggerScenarioResponse>(
|
var response = await _client.SendAsync<TriggerScenarioRequest, TriggerScenarioResponse>(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -73,170 +26,6 @@ public sealed partial class ScenarioService
|
|||||||
new TriggerScenarioRequest { Event = eventName },
|
new TriggerScenarioRequest { Event = eventName },
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return StartPlans(response?.Plans);
|
_client.Effects.Ingest(response?.Effects);
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Restores persisted runs, reconciles them with the server and re-dispatches
|
|
||||||
/// active nodes. Call once after startup, after login.
|
|
||||||
/// </summary>
|
|
||||||
public Task RestoreAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return RestoreCoreAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RestoreCoreAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var store = _client.Options.PlanStateStore;
|
|
||||||
if (store == null || string.IsNullOrEmpty(store.State))
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var state = JsonConvert.DeserializeObject<PersistedScenarioState>(store.State);
|
|
||||||
_runs.Clear();
|
|
||||||
if (state?.Runs != null)
|
|
||||||
{
|
|
||||||
foreach (var savedRun in state.Runs)
|
|
||||||
{
|
|
||||||
if (savedRun?.Plan == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Reconcile with server
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
|
|
||||||
"POST",
|
|
||||||
"/sdk/v1/scenarios/run",
|
|
||||||
new GetScenarioRunRequest { RunId = savedRun.RunId },
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (response?.Status == "unknown_run" || response?.Status == "expired")
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (response?.Plan != null)
|
|
||||||
{
|
|
||||||
savedRun.Plan = response.Plan;
|
|
||||||
savedRun.ActiveNodes = null;
|
|
||||||
savedRun.CompletedHandles = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Network error — keep local state as fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
var run = RuntimeRun.FromPersisted(savedRun);
|
|
||||||
_runs[run.RunId] = run;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var run in _runs.Values.ToList())
|
|
||||||
{
|
|
||||||
if (run.ActiveNodes.Count > 0)
|
|
||||||
{
|
|
||||||
foreach (var nodeState in run.ActiveNodes.Values.ToList())
|
|
||||||
DispatchActiveNode(run, nodeState, restored: true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Rebuilt from server — activate start node
|
|
||||||
var startNode = FindNode(run.Plan, run.Plan.StartNodeId) ?? run.Plan.Nodes[0];
|
|
||||||
ActivateNode(run, startNode.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Persist();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_client.Options.Logger?.Log(RudderLogLevel.Error, "[Rudder] Failed to restore scenario state. Clearing persisted state. " + ex.Message);
|
|
||||||
Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Drops all runs and the persisted state.</summary>
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
_runs.Clear();
|
|
||||||
Persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Completes wait nodes whose deadline passed; call every frame.</summary>
|
|
||||||
public void Update(float deltaTime)
|
|
||||||
{
|
|
||||||
var now = _client.Clock.UtcNow;
|
|
||||||
foreach (var run in _runs.Values.ToList())
|
|
||||||
{
|
|
||||||
foreach (var node in run.ActiveNodes.Values.ToList())
|
|
||||||
{
|
|
||||||
if (node.WaitDeadlineUtc.HasValue && now >= node.WaitDeadlineUtc.Value)
|
|
||||||
_ = CompleteNodeAsync(run.RunId, node.NodeId, "onComplete");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Completes the first active node with the given handle.</summary>
|
|
||||||
public Task RespondAsync(string handle, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var run = _runs.Values.FirstOrDefault();
|
|
||||||
var node = run?.ActiveNodes.Values.FirstOrDefault();
|
|
||||||
return run == null || node == null
|
|
||||||
? Task.CompletedTask
|
|
||||||
: CompleteNodeAsync(run.RunId, node.NodeId, handle, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Completes the first active node with the given handle (fire-and-forget).</summary>
|
|
||||||
public void Respond(string handle)
|
|
||||||
{
|
|
||||||
_ = RespondAsync(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Adds progress to a counter of the first active node.</summary>
|
|
||||||
public Task UpdateProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var run = _runs.Values.FirstOrDefault();
|
|
||||||
var node = run?.ActiveNodes.Values.FirstOrDefault();
|
|
||||||
return run == null || node == null
|
|
||||||
? Task.CompletedTask
|
|
||||||
: UpdateProgressAsync(run.RunId, node.NodeId, counterKey, amount, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task UpdateProgressAsync(
|
|
||||||
string runId,
|
|
||||||
string nodeId,
|
|
||||||
string counterKey,
|
|
||||||
long amount,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!_runs.TryGetValue(runId, out var run) || !run.ActiveNodes.ContainsKey(nodeId))
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await _client.SendAsync<UpdateScenarioCounterRequest, UpdateScenarioCounterResponse>(
|
|
||||||
"POST",
|
|
||||||
"/sdk/v1/scenarios/counter",
|
|
||||||
new UpdateScenarioCounterRequest
|
|
||||||
{
|
|
||||||
ScenarioId = run.Plan.ScenarioId,
|
|
||||||
NodeId = nodeId,
|
|
||||||
CounterKey = counterKey,
|
|
||||||
Amount = amount,
|
|
||||||
RunId = run.RunId
|
|
||||||
},
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// The server reports objective completion; it no longer returns a plan from the
|
|
||||||
// counter endpoint. On completion, cross the node's onComplete handle (which
|
|
||||||
// advances the run) — idempotent if the consumer also completes the session.
|
|
||||||
if (response != null && response.Completed == true)
|
|
||||||
await CompleteNodeAsync(runId, nodeId, "onComplete", cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Counter update failure does not fail the run.
|
|
||||||
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Scenario counter update failed at node {nodeId}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: rudder-csharp-sdk
|
name: rudder-csharp-sdk
|
||||||
description: Use when working with the Rudder C# SDK Rudder.Core (namespace RudderSdk.Core) — client init, transports, auth/session lifecycle, and the per-domain services (player, stores, inventory, battle pass, quests, leaderboards, remote config, scenarios, storage, realtime).
|
description: Use when working with the Rudder C# SDK Rudder.Core (namespace RudderSdk.Core) — client init, transports, auth/session lifecycle, and the per-domain services (player, stores, inventory, battle pass, quests, leaderboards, remote config, scenario trigger, scenario effects, storage).
|
||||||
---
|
---
|
||||||
|
|
||||||
# Rudder C# SDK (Rudder.Core)
|
# Rudder C# SDK (Rudder.Core)
|
||||||
@@ -43,8 +43,8 @@ full options surface and the abstraction interfaces):
|
|||||||
- `DeviceIdProvider` → `GuidDeviceIdProvider` (new GUID per run — plug a
|
- `DeviceIdProvider` → `GuidDeviceIdProvider` (new GUID per run — plug a
|
||||||
persistent `IDeviceIdProvider` for production)
|
persistent `IDeviceIdProvider` for production)
|
||||||
|
|
||||||
`RealtimeUrl` + `RealtimeTransportFactory` are required only for
|
`Clock` defaults to `DateTimeOffset.UtcNow`; override in tests. There is no
|
||||||
`client.Realtime`; there is no default websocket transport in Rudder.Core.
|
realtime websocket client in Rudder.Core.
|
||||||
|
|
||||||
## Request pipeline (applies to every service)
|
## Request pipeline (applies to every service)
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ full options surface and the abstraction interfaces):
|
|||||||
failures throw `RudderNetworkException` (`StatusCode == 0`). See
|
failures throw `RudderNetworkException` (`StatusCode == 0`). See
|
||||||
reference/errors.md.
|
reference/errors.md.
|
||||||
- `client.Update(deltaTime)` must be called every frame — it pumps the
|
- `client.Update(deltaTime)` must be called every frame — it pumps the
|
||||||
scenario runtime (wait-node deadlines) and the realtime transport.
|
effects client (30s pending heartbeat and wait-node deadline checks).
|
||||||
|
|
||||||
## Capability map
|
## Capability map
|
||||||
|
|
||||||
@@ -74,19 +74,19 @@ full options surface and the abstraction interfaces):
|
|||||||
| Stores and offer purchases | `client.Stores` | reference/stores.md |
|
| Stores and offer purchases | `client.Stores` | reference/stores.md |
|
||||||
| Leaderboards | `client.Leaderboards.FindBySlug(slug)` | reference/leaderboards.md |
|
| Leaderboards | `client.Leaderboards.FindBySlug(slug)` | reference/leaderboards.md |
|
||||||
| Inventory | `client.Inventory` | reference/inventory.md |
|
| Inventory | `client.Inventory` | reference/inventory.md |
|
||||||
| Battle pass | `client.BattlePass` (+ scenario session) | reference/battlepass.md |
|
| Battle pass | `client.BattlePass` (+ scenario effect) | reference/battlepass.md |
|
||||||
| Quests (global) | `client.Quests` | reference/quests.md |
|
| Quests (global) | `client.Quests` | reference/quests.md |
|
||||||
| Scenarios (event-driven plans, node sessions) | `client.Scenario` | reference/scenarios.md |
|
| Scenario trigger | `client.Scenario` | reference/scenarios.md |
|
||||||
| Realtime websocket | `client.Realtime` | reference/realtime.md |
|
| Scenario effects | `client.Effects.On*` | reference/scenarios.md |
|
||||||
|
|
||||||
Important cross-cutting facts:
|
Important cross-cutting facts:
|
||||||
|
|
||||||
- Battle pass state is tied to a scenario battle-pass node: every
|
- Battle pass state is tied to a scenario battle-pass node: every
|
||||||
`BattlePassService` call carries `ScenarioId`/`NodeId` (mutations also
|
`BattlePassService` call carries `ScenarioId`/`NodeId` (mutations also
|
||||||
`RunId`). During a scenario run, `BattlePassSession` supplies them — prefer
|
`RunId`). During a scenario run, `BattlePassEffect` supplies them — prefer
|
||||||
the session API inside a run.
|
the effect API inside a run.
|
||||||
- Global quests (`client.Quests`) are distinct from scenario quest nodes
|
- Global quests (`client.Quests`) are distinct from scenario quest nodes
|
||||||
(`Scenario.OnQuest` → `QuestSession`).
|
(`Effects.OnQuest` → `QuestEffect`).
|
||||||
- Mutations that take an idempotency key auto-generate a random GUID when the
|
- Mutations that take an idempotency key auto-generate a random GUID when the
|
||||||
key is omitted (`Stores.PurchaseAsync`, `ProjectStorage.SaveAsync`,
|
key is omitted (`Stores.PurchaseAsync`, `ProjectStorage.SaveAsync`,
|
||||||
`BattlePass.PurchasePremiumAsync`). Pass a stable key to make retries safe.
|
`BattlePass.PurchasePremiumAsync`). Pass a stable key to make retries safe.
|
||||||
@@ -96,10 +96,10 @@ Important cross-cutting facts:
|
|||||||
## Relationship to the Unity package (rudder.sdk)
|
## Relationship to the Unity package (rudder.sdk)
|
||||||
|
|
||||||
`rudder.sdk` (UPM, liveops-unity-sdk) wraps `Rudder.Core.dll` with Unity
|
`rudder.sdk` (UPM, liveops-unity-sdk) wraps `Rudder.Core.dll` with Unity
|
||||||
adapters: `UnityWebRequest` transport, `PlayerPrefs` token store, a websocket
|
adapters: `UnityWebRequest` transport, `PlayerPrefs` token store, and a
|
||||||
realtime transport, and a `Rudder` bootstrap component
|
`Rudder` bootstrap component (`RudderSdk.Unity` namespace). The domain
|
||||||
(`RudderSdk.Unity` namespace). The domain services and models are the same
|
services and models are the same ones documented here — for Unity games,
|
||||||
ones documented here — for Unity games, consume them through the package's
|
consume them through the package's adapters instead of wiring
|
||||||
adapters instead of wiring `RudderClientOptions` by hand. Unity requires
|
`RudderClientOptions` by hand. Unity requires
|
||||||
`com.unity.nuget.newtonsoft-json` since Rudder.Core serializes with
|
`com.unity.nuget.newtonsoft-json` since Rudder.Core serializes with
|
||||||
Newtonsoft.Json.
|
Newtonsoft.Json.
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ public class LoginViaDeviceResponse / LoginViaCustomResponse
|
|||||||
## Behavior notes
|
## Behavior notes
|
||||||
|
|
||||||
- A successful login stores the token pair in the configured `ITokenStore`
|
- A successful login stores the token pair in the configured `ITokenStore`
|
||||||
and fires `SignedIn`.
|
and fires `SignedIn`. `EffectsService` treats `SignedIn` as a pending-effects
|
||||||
|
refresh due: the next `client.Update` GETs `/sdk/v1/scenarios/pending`.
|
||||||
|
This SDK does not auto-trigger a login scenario event.
|
||||||
- If the server returns an incomplete session (missing tokens), login throws
|
- If the server returns an incomplete session (missing tokens), login throws
|
||||||
`InvalidOperationException`.
|
`InvalidOperationException`.
|
||||||
- Token refresh is automatic: any API call that gets a 401 triggers one
|
- Token refresh is automatic: any API call that gets a 401 triggers one
|
||||||
@@ -56,3 +58,5 @@ public class LoginViaDeviceResponse / LoginViaCustomResponse
|
|||||||
transparent retry. A failed refresh clears the tokens and fires
|
transparent retry. A failed refresh clears the tokens and fires
|
||||||
`SignedOut`; the original call then throws `RudderAuthException`.
|
`SignedOut`; the original call then throws `RudderAuthException`.
|
||||||
- `RefreshAsync()` shares the same single-flight path.
|
- `RefreshAsync()` shares the same single-flight path.
|
||||||
|
- Logout / `SignedOut` clears the effects client's seen-effect set and wait
|
||||||
|
deadlines.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Battle pass — `client.BattlePass` (`BattlePassService`)
|
# Battle pass — `client.BattlePass` (`BattlePassService`)
|
||||||
|
|
||||||
Source: `Services/BattlePassService.cs`,
|
Source: `Services/BattlePassService.cs`,
|
||||||
`Services/Scenarios/Sessions/BattlePassSession.cs`,
|
`Services/Effects/BattlePassEffect.cs`,
|
||||||
`Services/Scenarios/Sessions/BattlePassLevelSession.cs`, `BattlePass/*.cs`
|
`Services/Effects/BattlePassLevelEffect.cs`, `BattlePass/*.cs`
|
||||||
(generated DTOs).
|
(generated DTOs).
|
||||||
|
|
||||||
Battle pass state is tied to a scenario battle-pass node, so every call
|
Battle pass state is tied to a scenario battle-pass node, so every call
|
||||||
carries `ScenarioId`/`NodeId` (mutations also `RunId`). Inside a scenario run,
|
carries `ScenarioId`/`NodeId` (mutations also `RunId`). Inside a scenario run,
|
||||||
`Scenario.OnBattlePass` hands you a `BattlePassSession` that supplies these
|
`Effects.OnBattlePass` hands you a `BattlePassEffect` that supplies these
|
||||||
ids — prefer it over calling the service directly.
|
ids — prefer it over calling the service directly.
|
||||||
|
|
||||||
## BattlePassService
|
## BattlePassService
|
||||||
@@ -88,7 +88,6 @@ public class AddBattlePassXpResponse
|
|||||||
public int? Level { get; set; } // "level"
|
public int? Level { get; set; } // "level"
|
||||||
public bool? LeveledUp { get; set; } // "leveledUp"
|
public bool? LeveledUp { get; set; } // "leveledUp"
|
||||||
public bool? MaxLevel { get; set; } // "maxLevel"
|
public bool? MaxLevel { get; set; } // "maxLevel"
|
||||||
public ExecutionPlan? Plan { get; set; } // "plan" — scenario continuation
|
|
||||||
public long? Xp { get; set; } // "xp"
|
public long? Xp { get; set; } // "xp"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +102,6 @@ public class ClaimBattlePassRewardResponse
|
|||||||
public class PurchaseBattlePassPremiumResponse
|
public class PurchaseBattlePassPremiumResponse
|
||||||
{
|
{
|
||||||
public string? Error { get; set; } // "error"
|
public string? Error { get; set; } // "error"
|
||||||
public ExecutionPlan? Plan { get; set; } // "plan"
|
|
||||||
public bool? Success { get; set; } // "success"
|
public bool? Success { get; set; } // "success"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -112,36 +110,35 @@ public class PurchaseBattlePassPremiumResponse
|
|||||||
`ItemId` (string?). Responses carry business errors in `Error` — check
|
`ItemId` (string?). Responses carry business errors in `Error` — check
|
||||||
`Success`. Claim failures surface codes such as `RudderErrorCodes.LevelNotReached`.
|
`Success`. Claim failures surface codes such as `RudderErrorCodes.LevelNotReached`.
|
||||||
|
|
||||||
## BattlePassSession (scenario node session)
|
## `BattlePassEffect` (scenario battlepass node)
|
||||||
|
|
||||||
Raised via `Scenario.OnBattlePass`. Bound to the node's scenario/node/run ids:
|
Raised via `Effects.OnBattlePass`. Bound to the node's scenario/node/run ids:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public ScenarioNodeContext Context { get; }
|
public string RunId { get; }
|
||||||
public string Id { get; } // node id
|
public string ScenarioId { get; }
|
||||||
|
public string NodeId { get; }
|
||||||
public T Get<T>(string key, T defaultValue = default!);
|
public T Get<T>(string key, T defaultValue = default!);
|
||||||
|
|
||||||
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken ct = default);
|
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken ct = default);
|
||||||
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken ct = default);
|
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken ct = default);
|
||||||
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken ct = default);
|
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken ct = default);
|
||||||
|
|
||||||
// Purchases premium, then crosses the onPremiumPurchase handle on success.
|
// Purchases premium, then posts onPremiumPurchase when response.Success == true.
|
||||||
public Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken ct = default);
|
public Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
// Boundary crossings (async + fire-and-forget variants):
|
public Task LevelUpAsync(CancellationToken ct = default); public void LevelUp(); // onLevelUp
|
||||||
public Task LevelUpAsync(CancellationToken ct = default); public void LevelUp(); // onLevelUp
|
public Task EndAsync(CancellationToken ct = default); public void End(); // onComplete
|
||||||
public Task MaxLevelAsync(CancellationToken ct = default); public void MaxLevel(); // onMaxLevel
|
|
||||||
public Task CompleteAsync(CancellationToken ct = default); public void Complete(); // onComplete
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## BattlePassLevelSession (scenario battlepass_level node)
|
## `BattlePassLevelEffect` (scenario battlepass_level node)
|
||||||
|
|
||||||
Raised via `Scenario.OnBattlePassLevel`; a single claimable tier:
|
Raised via `Effects.OnBattlePassLevel`; a single claimable tier:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public int Level { get; } // reads the "levelNumber" node data key
|
public int Level { get; } // reads the "levelNumber" node data key
|
||||||
public Task ClaimAsync(CancellationToken ct = default); public void Claim();
|
public Task ClaimAsync(CancellationToken ct = default); public void Claim();
|
||||||
```
|
```
|
||||||
|
|
||||||
`ClaimAsync` crosses `onComplete`; the server accepts it only once the player
|
`ClaimAsync` posts `onComplete`; the server accepts it only once the player
|
||||||
has reached the node's configured level.
|
has reached the node's configured level.
|
||||||
|
|||||||
@@ -19,14 +19,13 @@ public sealed class RudderClient
|
|||||||
public BattlePassService BattlePass { get; }
|
public BattlePassService BattlePass { get; }
|
||||||
public QuestsService Quests { get; }
|
public QuestsService Quests { get; }
|
||||||
public ScenarioService Scenario { get; }
|
public ScenarioService Scenario { get; }
|
||||||
public RealtimeService Realtime { get; }
|
public EffectsService Effects { get; }
|
||||||
|
|
||||||
public string ProjectKey { get; }
|
public string ProjectKey { get; }
|
||||||
public string? RealtimeUrl { get; }
|
public IClock Clock { get; } // Options.Clock, else DateTimeOffset.UtcNow
|
||||||
public IClock Clock { get; } // defaults to DateTimeOffset.UtcNow
|
|
||||||
|
|
||||||
public RudderClient(RudderClientOptions options);
|
public RudderClient(RudderClientOptions options);
|
||||||
public void Update(float deltaTime); // pumps Scenario + Realtime; call every frame
|
public void Update(float deltaTime); // pumps Effects; call every frame
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,16 +39,12 @@ single-flight refresh and one transparent retry (see errors.md).
|
|||||||
public sealed class RudderClientOptions
|
public sealed class RudderClientOptions
|
||||||
{
|
{
|
||||||
public string? BaseUrl { get; set; } // required
|
public string? BaseUrl { get; set; } // required
|
||||||
public string? RealtimeUrl { get; set; } // required only for Realtime
|
|
||||||
public string? ProjectKey { get; set; } // required
|
public string? ProjectKey { get; set; } // required
|
||||||
public IRudderTransport? Transport { get; set; } // default HttpClientTransport
|
public IRudderTransport? Transport { get; set; } // default HttpClientTransport
|
||||||
public ITokenStore? TokenStore { get; set; } // default InMemoryTokenStore
|
public ITokenStore? TokenStore { get; set; } // default InMemoryTokenStore
|
||||||
public IDeviceIdProvider? DeviceIdProvider { get; set; }// default GuidDeviceIdProvider
|
public IDeviceIdProvider? DeviceIdProvider { get; set; }// default GuidDeviceIdProvider
|
||||||
public IRudderLogger? Logger { get; set; } // null = silent
|
public IRudderLogger? Logger { get; set; } // null = silent
|
||||||
public IClock? Clock { get; set; } // override in tests
|
public IClock? Clock { get; set; } // time source for the effects client; override in tests
|
||||||
public IPlanStateStore? PlanStateStore { get; set; } // scenario persistence between launches
|
|
||||||
public IPlanScheduler? Scheduler { get; set; } // optional delayed plan work
|
|
||||||
public IRealtimeTransportFactory? RealtimeTransportFactory { get; set; } // required only for Realtime
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,27 +84,6 @@ public interface IRudderLogger { void Log(RudderLogLevel level, string message);
|
|||||||
public enum RudderLogLevel { Debug, Info, Warning, Error }
|
public enum RudderLogLevel { Debug, Info, Warning, Error }
|
||||||
|
|
||||||
public interface IClock { DateTimeOffset UtcNow { get; } }
|
public interface IClock { DateTimeOffset UtcNow { get; } }
|
||||||
|
|
||||||
public interface IPlanStateStore { string? State { get; set; } } // serialized scenario-run blob
|
|
||||||
|
|
||||||
public interface IPlanScheduler
|
|
||||||
{
|
|
||||||
Task ScheduleAsync(TimeSpan delay, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IRealtimeTransport
|
|
||||||
{
|
|
||||||
event Action Closed;
|
|
||||||
event Action<ArraySegment<byte>> Received;
|
|
||||||
event Action<Exception> Error;
|
|
||||||
bool IsConnected { get; }
|
|
||||||
Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default);
|
|
||||||
Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default);
|
|
||||||
Task CloseAsync(CancellationToken cancellationToken = default);
|
|
||||||
void Update(float deltaTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IRealtimeTransportFactory { IRealtimeTransport Create(); }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Production guidance from the sources: provide a durable `ITokenStore`
|
Production guidance from the sources: provide a durable `ITokenStore`
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Errors, exceptions, error codes
|
# Errors, exceptions, error codes
|
||||||
|
|
||||||
Source: `Exceptions/*.cs`, `Models/RudderErrorCodes.cs`,
|
Source: `Exceptions/*.cs`, `Models/RudderErrorCodes.cs`,
|
||||||
`Models/ErrorResponse.cs`, `HttpClientTransport.cs` (status mapping),
|
`Models/ErrorResponse.cs`, `HttpClientTransport.cs` (status mapping).
|
||||||
`Services/Scenarios/ScenarioService.Runtime.cs` (`TransientBoundaryException`).
|
|
||||||
|
|
||||||
## Exception hierarchy
|
## Exception hierarchy
|
||||||
|
|
||||||
@@ -59,11 +58,11 @@ UnknownRun = "unknown_run"
|
|||||||
|
|
||||||
Match against `RudderApiException.Code`.
|
Match against `RudderApiException.Code`.
|
||||||
|
|
||||||
## Scenario boundary errors
|
## Scenario callback / counter errors
|
||||||
|
|
||||||
`TransientBoundaryException` (public sealed, wraps the transport error) is
|
On `POST /sdk/v1/scenarios/callback` and `POST /sdk/v1/scenarios/counter`,
|
||||||
used inside the scenario runtime for transient boundary-callback failures
|
`unknown_run`, `run_expired`, and HTTP 404 drop that run and fire
|
||||||
(timeout / `RudderNetworkException`): the run is NOT advanced, the node stays
|
`Effects.OnScenarioFailed`. Other callback errors propagate to the caller.
|
||||||
active and the handle stays pending for retry. A failed scenario counter
|
A failed counter update that is not one of those definitive rejections is
|
||||||
update never fails the run — it is logged via `IRudderLogger` and the node
|
logged via `IRudderLogger` and the effect stays active. Details:
|
||||||
stays active.
|
reference/scenarios.md.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Source: `Services/QuestsService.cs`, `Services/QuestMetrics.cs`,
|
|||||||
`Quests/*.cs` (generated DTOs).
|
`Quests/*.cs` (generated DTOs).
|
||||||
|
|
||||||
Global quests (list + claim + metric reports). Distinct from scenario quest
|
Global quests (list + claim + metric reports). Distinct from scenario quest
|
||||||
nodes, which advance through `QuestSession` (`Scenario.OnQuest`) — see
|
nodes, which advance through `QuestEffect` (`Effects.OnQuest`) — see
|
||||||
scenarios.md.
|
scenarios.md.
|
||||||
|
|
||||||
## Methods
|
## Methods
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
# Realtime — `client.Realtime` (`RealtimeService` + `RealtimeSession`)
|
|
||||||
|
|
||||||
Source: `Services/RealtimeService.cs`, `Abstractions/IRealtimeTransport.cs`,
|
|
||||||
`Abstractions/IRealtimeTransportFactory.cs`.
|
|
||||||
|
|
||||||
Websocket channel. Requires `RudderClientOptions.RealtimeUrl` and
|
|
||||||
`RudderClientOptions.RealtimeTransportFactory` — Rudder.Core ships no default
|
|
||||||
websocket transport (the Unity package `rudder.sdk` provides one). The current
|
|
||||||
access token authorizes the connection, so sign in first.
|
|
||||||
|
|
||||||
## RealtimeService
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public RealtimeSession? Session { get; } // null when not connected
|
|
||||||
public bool IsConnected { get; }
|
|
||||||
|
|
||||||
// Connects to the configured RealtimeUrl. Throws InvalidOperationException
|
|
||||||
// when RealtimeUrl is not configured.
|
|
||||||
public Task<RealtimeSession> ConnectAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
// Connects to an explicit URL. Throws InvalidOperationException when
|
|
||||||
// RealtimeTransportFactory is missing or no access token is stored.
|
|
||||||
public Task<RealtimeSession> ConnectAsync(Uri uri, TimeSpan? timeout = null, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
public Task DisconnectAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
public void Update(float deltaTime); // pumps the transport; call every frame (client.Update does this)
|
|
||||||
```
|
|
||||||
|
|
||||||
Default connect timeout: 10 seconds.
|
|
||||||
|
|
||||||
## RealtimeSession
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public string AccessToken { get; } // token the connection was authorized with
|
|
||||||
public bool IsConnected { get; }
|
|
||||||
|
|
||||||
public event Action? Closed;
|
|
||||||
public event Action<Exception>? Error;
|
|
||||||
public event Action<ArraySegment<byte>>? MessageReceived;
|
|
||||||
|
|
||||||
public Task SendAsync(byte[] payload, CancellationToken cancellationToken = default);
|
|
||||||
public Task DisconnectAsync(CancellationToken cancellationToken = default);
|
|
||||||
```
|
|
||||||
|
|
||||||
There is no message envelope or channel abstraction — payloads are raw bytes;
|
|
||||||
define the message shape with the server side of your project.
|
|
||||||
|
|
||||||
Note: the working tree contains an untracked `Services/RealtimeSession.cs`
|
|
||||||
with a stale `RealtimeSession` (no `AccessToken`). The committed definition in
|
|
||||||
`Services/RealtimeService.cs` documented above is authoritative.
|
|
||||||
@@ -48,5 +48,6 @@ public class RemoteConfig
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Scenario `remote_config_override` nodes apply patches directly into this
|
Scenario `remote_config_override` nodes are applied server-side and do not
|
||||||
cache before `Scenario.OnConfigChanged` fires (see scenarios.md).
|
reach the client. Call `LoadAsync` (or `GetAsync`) to observe the patched
|
||||||
|
value via `Get<T>`.
|
||||||
|
|||||||
@@ -1,149 +1,273 @@
|
|||||||
# Scenarios — `client.Scenario` (`ScenarioService`)
|
# Scenarios + effects — `client.Scenario` + `client.Effects`
|
||||||
|
|
||||||
Source: `Services/ScenarioService.cs`, `Services/Scenarios/ScenarioService.{Dispatch,NodeTypes,Persistence,Runtime}.cs`,
|
Source: `Services/ScenarioService.cs`, `Services/EffectsService.cs`,
|
||||||
`Services/Scenarios/Models/*.cs`, `Services/Scenarios/Sessions/*.cs`,
|
`Services/Effects/*.cs`, `Scenarios/*.cs` (generated DTOs).
|
||||||
`Scenarios/*.cs` (generated DTOs), `Models/*.cs`.
|
|
||||||
|
|
||||||
Scenario runtime for server-issued execution plans. `TriggerAsync(eventName)`
|
Scenarios are server-authored node graphs (configured in the dashboard) that
|
||||||
starts plans; active nodes surface as typed sessions through the `On*` events
|
run per player. The **server owns the graph**. This SDK is a thin effects
|
||||||
and are advanced by completing those sessions (which crosses server-validated
|
client: it sends trigger events, polls pending effects, raises typed
|
||||||
boundaries). Requires `Update(deltaTime)` every frame for wait-node deadlines.
|
`client.Effects.On*` events, and posts callbacks when the game completes an
|
||||||
|
effect. There is no local plan, no `IPlanStateStore`, and no restore.
|
||||||
|
|
||||||
## ScenarioService surface
|
## Lifecycle
|
||||||
|
|
||||||
|
- `client.Scenario.TriggerAsync(eventName)` POSTs
|
||||||
|
`/sdk/v1/scenarios/trigger` with `{event}`. Returned `PendingEffect`s are
|
||||||
|
ingested into `client.Effects` (they do **not** come back as a return
|
||||||
|
value).
|
||||||
|
- Drive `client.Update(deltaTime)` every frame. That pumps
|
||||||
|
`EffectsService.Update`: a 30s heartbeat (`GET /sdk/v1/scenarios/pending`)
|
||||||
|
plus an immediate pending fetch when any wait deadline is due. The pump
|
||||||
|
no-ops when there is no access token, and it will not start a second
|
||||||
|
fetch while one is in flight.
|
||||||
|
- After login (`AuthStateChanged` → `SignedIn`), or when the client is
|
||||||
|
constructed with an already-stored access token, a pending fetch is marked
|
||||||
|
due and runs on the next `Update`. Sign-out / logout clears the seen-effect
|
||||||
|
set and wait deadlines.
|
||||||
|
- This SDK does **not** fire a login scenario event (`loginEvent` /
|
||||||
|
`player_login` is a web-SDK option only). Trigger login-gated scenarios
|
||||||
|
yourself with `TriggerAsync`.
|
||||||
|
- An effect with an already-seen `(runId, nodeId)` is not re-emitted.
|
||||||
|
- There is no local plan persistence. Pending work lives on the server.
|
||||||
|
|
||||||
|
## `ScenarioService` (`client.Scenario`)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public sealed partial class ScenarioService
|
public sealed class ScenarioService
|
||||||
{
|
{
|
||||||
// Node events
|
// POST /sdk/v1/scenarios/trigger — body {event}; returned effects are
|
||||||
public event Action<NotificationSession>? OnNotification;
|
// ingested into client.Effects. Returns when the HTTP call finishes.
|
||||||
public event Action<StoreOfferSession>? OnStoreOffer;
|
public Task TriggerAsync(string eventName, CancellationToken ct = default);
|
||||||
public event Action<LeaderboardSession>? OnLeaderboard;
|
|
||||||
public event Action<ConfigChangedSession>? OnConfigChanged; // patches already applied
|
|
||||||
public event Action<WaitSession>? OnWait;
|
|
||||||
public event Action<QuestSession>? OnQuest;
|
|
||||||
public event Action<BattlePassSession>? OnBattlePass;
|
|
||||||
public event Action<BattlePassLevelSession>? OnBattlePassLevel;
|
|
||||||
|
|
||||||
// Run lifecycle events
|
|
||||||
public event Action<PlanRun>? OnScenarioCompleted; // run finished all nodes
|
|
||||||
public event Action<ScenarioFailedEvent>? OnScenarioFailed; // run died on unrecoverable error
|
|
||||||
|
|
||||||
public bool IsRunning { get; } // at least one run active
|
|
||||||
public string? CurrentNodeId { get; } // first active node id across runs
|
|
||||||
public IReadOnlyList<PlanRun> ActiveRuns { get; } // snapshots
|
|
||||||
|
|
||||||
// POST /sdk/v1/scenarios/trigger — body {event}; returns the runs this call started
|
|
||||||
public Task<IReadOnlyList<PlanRun>> TriggerAsync(string eventName, CancellationToken ct = default);
|
|
||||||
|
|
||||||
// Restores persisted runs (needs RudderClientOptions.PlanStateStore),
|
|
||||||
// reconciles with the server, re-dispatches active nodes. Call once after login.
|
|
||||||
public Task RestoreAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
public void Clear(); // drops all runs and the persisted state
|
|
||||||
|
|
||||||
public void Update(float deltaTime); // completes wait nodes past their deadline
|
|
||||||
|
|
||||||
// Completes the FIRST active node of the FIRST run with the given handle.
|
|
||||||
public Task RespondAsync(string handle, CancellationToken ct = default);
|
|
||||||
public void Respond(string handle); // fire-and-forget
|
|
||||||
|
|
||||||
// Adds progress to a counter of the FIRST active node of the FIRST run.
|
|
||||||
public Task UpdateProgressAsync(string counterKey, long amount, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer session methods over `RespondAsync`/`UpdateProgressAsync` — the
|
## `EffectsService` (`client.Effects`)
|
||||||
parameterless variants only address the first active node of the first run.
|
|
||||||
|
|
||||||
## Node types and session handles
|
```csharp
|
||||||
|
public sealed class EffectsService
|
||||||
|
{
|
||||||
|
public event Action<NotificationEffect>? OnNotification;
|
||||||
|
public event Action<StoreOfferEffect>? OnStoreOffer;
|
||||||
|
public event Action<LeaderboardEffect>? OnLeaderboard;
|
||||||
|
public event Action<WaitEffect>? OnWait;
|
||||||
|
public event Action<QuestEffect>? OnQuest;
|
||||||
|
public event Action<BattlePassEffect>? OnBattlePass;
|
||||||
|
public event Action<BattlePassLevelEffect>? OnBattlePassLevel;
|
||||||
|
public event Action<ScenarioCompletedEffect>? OnScenarioCompleted;
|
||||||
|
public event Action<ScenarioFailedEffect>? OnScenarioFailed;
|
||||||
|
|
||||||
| Node `type` | Event | Session completion handles |
|
public void Update(float deltaTime); // also called by client.Update
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Subscribe before login / trigger so you do not miss the first batch.
|
||||||
|
Handlers are `Action<T>` (not `Func<T, Task>`) — do not use `async`
|
||||||
|
lambdas on these events. Exceptions thrown inside handlers are logged via
|
||||||
|
`IRudderLogger` (Error) and do not fail the run. Always subscribe to
|
||||||
|
`OnScenarioFailed` — otherwise run failures surface only as logs.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
client.Effects.OnNotification += n =>
|
||||||
|
{
|
||||||
|
ShowToast(n.Title, n.Message);
|
||||||
|
n.Done(); // always resolve or the run stalls; use DoneAsync from a Task context
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("level_complete");
|
||||||
|
```
|
||||||
|
|
||||||
|
Every effect exposes `RunId`, `ScenarioId`, `NodeId`, `Data` (`JObject`),
|
||||||
|
and `Get<T>(key, defaultValue)` for reading node data. Completion methods
|
||||||
|
have an `Async` variant (awaitable) and a fire-and-forget variant.
|
||||||
|
|
||||||
|
## Effect types
|
||||||
|
|
||||||
|
| Node `type` | Event | How the node advances |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `notification` | `OnNotification` | `output` (`CompleteAsync`/`Complete`) |
|
| `notification` | `OnNotification` | `Done*` → handle `output` |
|
||||||
| `store` | `OnStoreOffer` | `onPurchase`, `onDecline` (`Purchase*`/`Decline*`, one-shot via `IsResolved`) |
|
| `store` | `OnStoreOffer` | `Purchase*` → `onPurchase`; `Decline*` → `onDecline` (one-shot via `IsResolved`) |
|
||||||
| `wait` | `OnWait` | auto-completes `onComplete` at `WaitSession.DeadlineUtc` |
|
| `leaderboard` | `OnLeaderboard` | `End*` → `onEnd`; `Claim*` → `onClaim` (one-shot via `IsResolved`) |
|
||||||
| `remote_config_override` | `OnConfigChanged` | auto-completes `output` after applying patches to `RemoteConfig` |
|
| `wait` | `OnWait` | none — server advances at `DeadlineUtc`; SDK polls pending then |
|
||||||
| `quest` | `OnQuest` | `onComplete`, `onFail`; `AddProgressAsync(counterKey, amount)` feeds counters |
|
| `quest` | `OnQuest` | `ReportProgress*` via counter; server auto-completes when all objectives are satisfied |
|
||||||
| `leaderboard` | `OnLeaderboard` | `onEnd` (`End*`), `onClaim` (`Claim*` / obsolete `RewardClaimed*`) |
|
| `battlepass` | `OnBattlePass` | `LevelUp*` → `onLevelUp`; `End*` → `onComplete`; successful `PurchasePremiumAsync` → `onPremiumPurchase` |
|
||||||
| `battlepass` | `OnBattlePass` | `onLevelUp`, `onMaxLevel`, `onComplete`, `onPremiumPurchase` (see battlepass.md) |
|
| `battlepass_level` | `OnBattlePassLevel` | `Claim*` → `onComplete` (server checks the configured level) |
|
||||||
| `battlepass_level` | `OnBattlePassLevel` | `onComplete` via `ClaimAsync` — server checks the configured level |
|
|
||||||
|
`remote_config_override` is applied server-side and is **not** delivered to
|
||||||
|
the client. There is no `OnConfigChanged`. Reload `client.RemoteConfig` to
|
||||||
|
observe patched values.
|
||||||
|
|
||||||
An unsupported node type fails the run (`OnScenarioFailed`) instead of
|
An unsupported node type fails the run (`OnScenarioFailed`) instead of
|
||||||
stalling. All sessions expose `Context` (`ScenarioNodeContext`), `Id` (node
|
stalling.
|
||||||
id) and `Get<T>(key, defaultValue)` for reading node data.
|
|
||||||
|
|
||||||
## ScenarioNodeContext
|
### `NotificationEffect`
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public PlanRun Run { get; }
|
public string Title { get; } // node data "title"
|
||||||
public ExecutionPlanNode Node { get; }
|
public string Message { get; } // node data "message"
|
||||||
public string RunId / PlanId / ScenarioId / NodeId / Type { get; }
|
|
||||||
public JObject Data { get; }
|
public Task DoneAsync(CancellationToken ct = default); // handle "output"
|
||||||
public T Get<T>(string key, T defaultValue = default!);
|
public void Done();
|
||||||
public T Get<T>(); // whole payload
|
|
||||||
public Dictionary<string, object> AsObjectDictionary();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## PlanRun and failure event
|
### `StoreOfferEffect`
|
||||||
|
|
||||||
|
Posts the scenario callback only — it does **not** call `Stores.PurchaseAsync`.
|
||||||
|
Purchase through `client.Stores` first if the offer should actually be bought,
|
||||||
|
then complete the effect.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public sealed class PlanRun
|
public string StoreSlug { get; } // node data "storeSlug"
|
||||||
|
public string? Message { get; } // node data "message", null if empty
|
||||||
|
public bool IsResolved { get; } // true after Purchase* or Decline* once
|
||||||
|
|
||||||
|
public Task PurchaseAsync(CancellationToken ct = default); // "onPurchase"
|
||||||
|
public void Purchase();
|
||||||
|
public Task DeclineAsync(CancellationToken ct = default); // "onDecline"
|
||||||
|
public void Decline();
|
||||||
|
```
|
||||||
|
|
||||||
|
### `LeaderboardEffect`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool IsResolved { get; }
|
||||||
|
|
||||||
|
public Task EndAsync(CancellationToken ct = default); // "onEnd"
|
||||||
|
public void End();
|
||||||
|
public Task ClaimAsync(CancellationToken ct = default); // "onClaim" — server matches live rank to a place
|
||||||
|
public void Claim();
|
||||||
|
```
|
||||||
|
|
||||||
|
### `WaitEffect`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public DateTimeOffset DeadlineUtc { get; }
|
||||||
|
```
|
||||||
|
|
||||||
|
No completion method. When `Clock.UtcNow >= DeadlineUtc`, the next `Update`
|
||||||
|
fetches pending so the server-advanced successor can be dispatched.
|
||||||
|
|
||||||
|
### `QuestEffect`
|
||||||
|
|
||||||
|
Scenario quest node — distinct from global `client.Quests`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public string Name { get; } // node data "name"
|
||||||
|
public IReadOnlyList<JObject> Objectives { get; } // node data "objectives"
|
||||||
|
|
||||||
|
// POST /sdk/v1/scenarios/counter {scenarioId, nodeId, runId, counterKey, amount}
|
||||||
|
public Task ReportProgressAsync(string objectiveId, long amount = 1, CancellationToken ct = default);
|
||||||
|
public void ReportProgress(string objectiveId, long amount = 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
The node auto-completes server-side once every objective is satisfied; a
|
||||||
|
completed counter response may carry the next `PendingEffect`.
|
||||||
|
|
||||||
|
### `BattlePassEffect`
|
||||||
|
|
||||||
|
Bound to this node's `ScenarioId` / `NodeId` / `RunId` — prefer these over
|
||||||
|
calling `client.BattlePass` by hand. See also reference/battlepass.md.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken ct = default);
|
||||||
|
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken ct = default);
|
||||||
|
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Purchases premium, then posts "onPremiumPurchase" when response.Success == true.
|
||||||
|
public Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
public Task LevelUpAsync(CancellationToken ct = default); public void LevelUp(); // "onLevelUp"
|
||||||
|
public Task EndAsync(CancellationToken ct = default); public void End(); // "onComplete"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public int Level { get; } // node data "levelNumber"
|
||||||
|
|
||||||
|
public Task ClaimAsync(CancellationToken ct = default); // "onComplete"
|
||||||
|
public void Claim();
|
||||||
|
```
|
||||||
|
|
||||||
|
The server accepts `onComplete` only once the player has reached the node's
|
||||||
|
configured level.
|
||||||
|
|
||||||
|
### Run lifecycle effects
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class ScenarioCompletedEffect
|
||||||
{
|
{
|
||||||
public string RunId { get; }
|
public string RunId { get; }
|
||||||
public string PlanId { get; }
|
|
||||||
public string ScenarioId { get; }
|
public string ScenarioId { get; }
|
||||||
public string UserId { get; }
|
|
||||||
public IReadOnlyList<string> ActiveNodeIds { get; }
|
|
||||||
public ExecutionPlan Plan { get; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ScenarioFailedEvent
|
public sealed class ScenarioFailedEffect
|
||||||
{
|
{
|
||||||
public PlanRun Run { get; }
|
public string RunId { get; }
|
||||||
|
public string ScenarioId { get; }
|
||||||
public string NodeId { get; }
|
public string NodeId { get; }
|
||||||
public Exception Exception { get; }
|
public Exception Exception { get; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## ExecutionPlan (generated, `RudderSdk.Core.Models`)
|
`OnScenarioCompleted` fires when a callback / completed-counter response has
|
||||||
|
no next effect (the run finished all its nodes).
|
||||||
|
|
||||||
|
## Reliability
|
||||||
|
|
||||||
|
- Completion methods POST `/sdk/v1/scenarios/callback` with
|
||||||
|
`{scenarioId, runId, nodeId, handle}` using the handles `output`,
|
||||||
|
`onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`,
|
||||||
|
`onPremiumPurchase`. A non-null `effect` in the response is ingested as
|
||||||
|
the next node; a null/missing effect completes the run.
|
||||||
|
- `unknown_run`, `run_expired` (`RudderErrorCodes`), and HTTP 404
|
||||||
|
(`RudderNotFoundException`) on a callback or counter call **drop that
|
||||||
|
run** (forget its seen keys and wait deadlines) and fire
|
||||||
|
`OnScenarioFailed`.
|
||||||
|
- Other callback errors propagate to the `*Async` caller. Counter-update
|
||||||
|
failures that are not a definitive rejection are logged (Warning) and the
|
||||||
|
effect stays active.
|
||||||
|
- Pending-fetch failures are logged (Warning); the 30s heartbeat continues.
|
||||||
|
- Server scenario errors also include `run_not_active`, `node_not_active`,
|
||||||
|
`scenario_not_active`, `early_completion`, `objectives_incomplete`,
|
||||||
|
`level_not_reached`, `forbidden` (`RudderErrorCodes`).
|
||||||
|
|
||||||
|
## Wire DTOs (`RudderSdk.Core.Models.Scenarios`)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class ExecutionPlan
|
public class PendingEffect
|
||||||
{
|
{
|
||||||
public List<BoundaryNode>? BoundaryNodes { get; set; }
|
public JToken Data { get; set; }
|
||||||
public JToken? Context { get; set; }
|
public string NodeId { get; set; }
|
||||||
public List<PlanEdge>? Edges { get; set; }
|
public string RunId { get; set; }
|
||||||
public List<ExecutionPlanNode>? Nodes { get; set; }
|
public string ScenarioId { get; set; }
|
||||||
public string? PlanId { get; set; }
|
public string Type { get; set; }
|
||||||
|
public DateTimeOffset? WaitDeadline { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TriggerScenarioRequest { public string? Event { get; set; } }
|
||||||
|
public class TriggerScenarioResponse { public List<PendingEffect> Effects { get; set; } }
|
||||||
|
public class ListPendingScenarioEffectsResponse { public List<PendingEffect> Effects { get; set; } }
|
||||||
|
|
||||||
|
public class HandleScenarioCallbackRequest
|
||||||
|
{
|
||||||
|
public string? Handle { get; set; }
|
||||||
|
public string? NodeId { get; set; }
|
||||||
public string? RunId { get; set; }
|
public string? RunId { get; set; }
|
||||||
public string? ScenarioId { get; set; }
|
public string? ScenarioId { get; set; }
|
||||||
public string? StartNodeId { get; set; }
|
}
|
||||||
public string? UserId { get; set; }
|
public class HandleScenarioCallbackResponse { public JToken? Effect { get; set; } }
|
||||||
|
|
||||||
|
public class UpdateScenarioCounterRequest
|
||||||
|
{
|
||||||
|
public long? Amount { get; set; }
|
||||||
|
public string? CounterKey { get; set; }
|
||||||
|
public string? NodeId { get; set; }
|
||||||
|
public string? RunId { get; set; }
|
||||||
|
public string? ScenarioId { get; set; }
|
||||||
|
}
|
||||||
|
public class UpdateScenarioCounterResponse
|
||||||
|
{
|
||||||
|
public bool? Completed { get; set; }
|
||||||
|
public JToken? Effect { get; set; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`ExecutionPlanNode`: `Id`, `Type`, `Data` (`JToken`). `PlanEdge`: `Id`,
|
|
||||||
`Source`, `SourceHandle`, `Target`, `TargetHandle`. `BoundaryNode`:
|
|
||||||
`CallbackUrl`, `Enforcement`, `EnteredAt`, `NodeId`, `SourceHandle`,
|
|
||||||
`SourceNodeId`, `WaitDeadline`.
|
|
||||||
|
|
||||||
## Runtime semantics
|
|
||||||
|
|
||||||
- Boundaries: crossing a handle that has a server boundary calls
|
|
||||||
`POST /sdk/v1/scenarios/callback` first; the returned continuation plan
|
|
||||||
replaces/starts the run before the handle is marked complete. On failure the
|
|
||||||
runtime reconciles via `POST /sdk/v1/scenarios/run`; `unknown_run` /
|
|
||||||
`expired` statuses drop the run. Transient failures
|
|
||||||
(`TransientBoundaryException`: timeouts, `RudderNetworkException`) leave the
|
|
||||||
node active for retry; terminal errors fail the run. `rank_not_eligible` on
|
|
||||||
leaderboard Claim leaves the node active (retry Claim or End).
|
|
||||||
- Counter updates (`POST /sdk/v1/scenarios/counter`) never fail the run; when
|
|
||||||
the server reports the objective completed, the node crosses `onComplete`
|
|
||||||
automatically.
|
|
||||||
- Dedup/idempotency: a plan with an already-running `RunId` is not restarted;
|
|
||||||
completed (node, handle) pairs are not re-crossed.
|
|
||||||
- Wait deadlines prefer the server-stamped `BoundaryNode.WaitDeadline` over
|
|
||||||
locally computed `duration`+`unit` node data (`days`/`hours`/`minutes`/
|
|
||||||
`seconds` and abbreviations).
|
|
||||||
- Persistence: with `IPlanStateStore` configured, every state change is
|
|
||||||
serialized into `State`; call `RestoreAsync` once after startup/login.
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class RudderClientTests
|
|||||||
Assert.NotNull(client.BattlePass);
|
Assert.NotNull(client.BattlePass);
|
||||||
Assert.NotNull(client.Quests);
|
Assert.NotNull(client.Quests);
|
||||||
Assert.NotNull(client.Scenario);
|
Assert.NotNull(client.Scenario);
|
||||||
Assert.NotNull(client.Realtime);
|
Assert.NotNull(client.Effects);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -85,37 +85,17 @@ public sealed class RudderClientTests
|
|||||||
Assert.Equal(new[] { RudderAuthState.SignedOut }, states);
|
Assert.Equal(new[] { RudderAuthState.SignedOut }, states);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Realtime_Connect_Returns_Session_From_Injected_Transport()
|
|
||||||
{
|
|
||||||
var realtimeTransport = new FakeRealtimeTransport();
|
|
||||||
var tokenStore = new FakeTokenStore { AccessToken = "access-token" };
|
|
||||||
var client = CreateClient(
|
|
||||||
tokenStore: tokenStore,
|
|
||||||
realtimeTransportFactory: new FakeRealtimeTransportFactory(realtimeTransport));
|
|
||||||
|
|
||||||
var session = await client.Realtime.ConnectAsync();
|
|
||||||
|
|
||||||
Assert.NotNull(session);
|
|
||||||
Assert.True(session.IsConnected);
|
|
||||||
Assert.Equal("access-token", session.AccessToken);
|
|
||||||
Assert.Equal(new Uri("ws://localhost:8090/api/realtime/ws"), realtimeTransport.Uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RudderClient CreateClient(
|
private static RudderClient CreateClient(
|
||||||
FakeTransport? transport = null,
|
FakeTransport? transport = null,
|
||||||
FakeTokenStore? tokenStore = null,
|
FakeTokenStore? tokenStore = null)
|
||||||
IRealtimeTransportFactory? realtimeTransportFactory = null)
|
|
||||||
{
|
{
|
||||||
return new RudderClient(new RudderClientOptions
|
return new RudderClient(new RudderClientOptions
|
||||||
{
|
{
|
||||||
BaseUrl = "http://localhost:8082",
|
BaseUrl = "http://localhost:8082",
|
||||||
RealtimeUrl = "ws://localhost:8090/api/realtime/ws",
|
|
||||||
ProjectKey = "project-key",
|
ProjectKey = "project-key",
|
||||||
Transport = transport ?? new FakeTransport(),
|
Transport = transport ?? new FakeTransport(),
|
||||||
TokenStore = tokenStore ?? new FakeTokenStore(),
|
TokenStore = tokenStore ?? new FakeTokenStore(),
|
||||||
DeviceIdProvider = new FakeDeviceIdProvider(),
|
DeviceIdProvider = new FakeDeviceIdProvider()
|
||||||
RealtimeTransportFactory = realtimeTransportFactory ?? new FakeRealtimeTransportFactory(new FakeRealtimeTransport())
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,46 +149,4 @@ public sealed class RudderClientTests
|
|||||||
{
|
{
|
||||||
public string DeviceId => "device-id";
|
public string DeviceId => "device-id";
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeRealtimeTransportFactory : IRealtimeTransportFactory
|
|
||||||
{
|
|
||||||
private readonly IRealtimeTransport _transport;
|
|
||||||
|
|
||||||
public FakeRealtimeTransportFactory(IRealtimeTransport transport) => _transport = transport;
|
|
||||||
|
|
||||||
public IRealtimeTransport Create() => _transport;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class FakeRealtimeTransport : IRealtimeTransport
|
|
||||||
{
|
|
||||||
public Uri? Uri { get; private set; }
|
|
||||||
public bool IsConnected { get; private set; }
|
|
||||||
public event Action? Closed;
|
|
||||||
public event Action<Exception>? Error;
|
|
||||||
public event Action<ArraySegment<byte>>? Received;
|
|
||||||
|
|
||||||
public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
Uri = uri;
|
|
||||||
IsConnected = true;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task SendAsync(ArraySegment<byte> payload, CancellationToken cancellationToken = default)
|
|
||||||
=> Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task CloseAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
IsConnected = false;
|
|
||||||
Closed?.Invoke();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Update(float deltaTime)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void EmitError(Exception ex) => Error?.Invoke(ex);
|
|
||||||
public void EmitReceived(ArraySegment<byte> data) => Received?.Invoke(data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Newtonsoft.Json.Linq;
|
|||||||
using RudderSdk.Core;
|
using RudderSdk.Core;
|
||||||
using RudderSdk.Core.Abstractions;
|
using RudderSdk.Core.Abstractions;
|
||||||
using RudderSdk.Core.Models;
|
using RudderSdk.Core.Models;
|
||||||
|
using RudderSdk.Core.Models.Auth;
|
||||||
using RudderSdk.Core.Models.Scenarios;
|
using RudderSdk.Core.Models.Scenarios;
|
||||||
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -11,60 +12,98 @@ namespace RudderSdk.Core.Tests;
|
|||||||
public sealed class ScenarioServiceTests
|
public sealed class ScenarioServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TriggerAsync_Starts_All_Returned_Plans()
|
public async Task TriggerAsync_Emits_Returned_Effects()
|
||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
Plans = new List<ExecutionPlan>
|
Effects = new List<PendingEffect>
|
||||||
{
|
{
|
||||||
Plan("plan-1", Node("n1", "notification")),
|
Effect("run-1", "n1", "notification"),
|
||||||
Plan("plan-2", Node("n2", "notification"))
|
Effect("run-2", "n2", "notification")
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
var client = CreateClient(transport);
|
var client = CreateClient(transport);
|
||||||
var notifications = new List<NotificationSession>();
|
var notifications = new List<NotificationEffect>();
|
||||||
client.Scenario.OnNotification += notifications.Add;
|
client.Effects.OnNotification += notifications.Add;
|
||||||
|
|
||||||
var started = await client.Scenario.TriggerAsync("login");
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
|
||||||
Assert.Equal(2, notifications.Count);
|
Assert.Equal(2, notifications.Count);
|
||||||
Assert.Equal(2, started.Count);
|
Assert.Equal("n1", notifications[0].NodeId);
|
||||||
Assert.Equal(2, client.Scenario.ActiveRuns.Count);
|
Assert.Equal("n2", notifications[1].NodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Completing_Node_Activates_All_Matching_Client_Edges()
|
public async Task TriggerAsync_Dedups_By_RunId_And_NodeId()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
var effect = Effect("run-1", "n1", "notification");
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse { Effects = new List<PendingEffect> { effect } });
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse { Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") } });
|
||||||
|
var client = CreateClient(transport);
|
||||||
|
var notifications = 0;
|
||||||
|
client.Effects.OnNotification += _ => notifications++;
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
|
||||||
|
Assert.Equal(1, notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Completing_Notification_Posts_Output_And_Ingests_Next_Effect()
|
||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
Plans = new List<ExecutionPlan>
|
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification", new { title = "hi", message = "there" }) }
|
||||||
{
|
});
|
||||||
Plan(
|
transport.Enqueue(new HandleScenarioCallbackResponse
|
||||||
"plan",
|
{
|
||||||
new[] { Node("start", "notification"), Node("store", "store"), Node("wait", "wait", new { duration = 1, unit = "minutes" }) },
|
Effect = JObject.FromObject(Effect("run-1", "store", "store"))
|
||||||
new[]
|
|
||||||
{
|
|
||||||
Edge("start", "output", "store"),
|
|
||||||
Edge("start", "output", "wait")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
var client = CreateClient(transport);
|
var client = CreateClient(transport);
|
||||||
NotificationSession? notification = null;
|
NotificationEffect? notification = null;
|
||||||
var stores = 0;
|
StoreOfferEffect? store = null;
|
||||||
var waits = 0;
|
client.Effects.OnNotification += e => notification = e;
|
||||||
client.Scenario.OnNotification += session => notification = session;
|
client.Effects.OnStoreOffer += e => store = e;
|
||||||
client.Scenario.OnStoreOffer += _ => stores++;
|
|
||||||
client.Scenario.OnWait += _ => waits++;
|
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
await client.Scenario.TriggerAsync("login");
|
||||||
await notification!.CompleteAsync();
|
Assert.Equal("hi", notification!.Title);
|
||||||
|
Assert.Equal("there", notification.Message);
|
||||||
|
await notification.DoneAsync();
|
||||||
|
|
||||||
Assert.Equal(1, stores);
|
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
|
||||||
Assert.Equal(1, waits);
|
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
|
||||||
Assert.Equal(2, client.Scenario.ActiveRuns.Single().ActiveNodeIds.Count);
|
Assert.Equal("n1", callback.NodeId);
|
||||||
|
Assert.Equal("output", callback.Handle);
|
||||||
|
Assert.Equal("run-1", callback.RunId);
|
||||||
|
Assert.NotNull(store);
|
||||||
|
Assert.Equal("store", store!.NodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Completing_Last_Effect_Raises_ScenarioCompleted()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
|
||||||
|
});
|
||||||
|
transport.Enqueue(new HandleScenarioCallbackResponse());
|
||||||
|
var client = CreateClient(transport);
|
||||||
|
NotificationEffect? notification = null;
|
||||||
|
ScenarioCompletedEffect? completed = null;
|
||||||
|
client.Effects.OnNotification += e => notification = e;
|
||||||
|
client.Effects.OnScenarioCompleted += e => completed = e;
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
await notification!.DoneAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(completed);
|
||||||
|
Assert.Equal("run-1", completed!.RunId);
|
||||||
|
Assert.Equal("scenario-1", completed.ScenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -73,224 +112,166 @@ public sealed class ScenarioServiceTests
|
|||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
Plans = new List<ExecutionPlan>
|
Effects = new List<PendingEffect>
|
||||||
{
|
{
|
||||||
Plan(
|
Effect("run-1", "n1", "notification"),
|
||||||
"plan",
|
Effect("run-2", "n2", "store"),
|
||||||
new[]
|
Effect("run-3", "n3", "quest"),
|
||||||
{
|
Effect("run-4", "n4", "leaderboard"),
|
||||||
Node("start", "notification"),
|
Effect("run-5", "n5", "battlepass"),
|
||||||
Node("store", "store"),
|
Effect("run-6", "n6", "battlepass_level", new { levelNumber = 3 }),
|
||||||
Node("quest", "quest"),
|
Effect("run-7", "n7", "wait", waitDeadline: new DateTimeOffset(2026, 5, 30, 10, 30, 0, TimeSpan.Zero))
|
||||||
Node("leaderboard", "leaderboard"),
|
|
||||||
Node("battlepass", "battlepass"),
|
|
||||||
Node("battlepass-level", "battlepass_level"),
|
|
||||||
},
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
Edge("start", "output", "store"),
|
|
||||||
Edge("start", "output", "quest"),
|
|
||||||
Edge("start", "output", "leaderboard"),
|
|
||||||
Edge("start", "output", "battlepass"),
|
|
||||||
Edge("start", "output", "battlepass-level"),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
var client = CreateClient(transport);
|
var client = CreateClient(transport);
|
||||||
NotificationSession? notification = null;
|
var notification = 0;
|
||||||
var store = 0;
|
var store = 0;
|
||||||
var quest = 0;
|
var quest = 0;
|
||||||
var leaderboard = 0;
|
var leaderboard = 0;
|
||||||
var battlePass = 0;
|
var battlePass = 0;
|
||||||
var battlePassLevel = 0;
|
var battlePassLevel = 0;
|
||||||
client.Scenario.OnNotification += session => notification = session;
|
var wait = 0;
|
||||||
client.Scenario.OnStoreOffer += _ => store++;
|
WaitEffect? waitEffect = null;
|
||||||
client.Scenario.OnQuest += _ => quest++;
|
BattlePassLevelEffect? levelEffect = null;
|
||||||
client.Scenario.OnLeaderboard += _ => leaderboard++;
|
client.Effects.OnNotification += _ => notification++;
|
||||||
client.Scenario.OnBattlePass += _ => battlePass++;
|
client.Effects.OnStoreOffer += _ => store++;
|
||||||
client.Scenario.OnBattlePassLevel += _ => battlePassLevel++;
|
client.Effects.OnQuest += _ => quest++;
|
||||||
|
client.Effects.OnLeaderboard += _ => leaderboard++;
|
||||||
|
client.Effects.OnBattlePass += _ => battlePass++;
|
||||||
|
client.Effects.OnBattlePassLevel += e => { battlePassLevel++; levelEffect = e; };
|
||||||
|
client.Effects.OnWait += e => { wait++; waitEffect = e; };
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
await client.Scenario.TriggerAsync("login");
|
||||||
await notification!.CompleteAsync();
|
|
||||||
|
|
||||||
|
Assert.Equal(1, notification);
|
||||||
Assert.Equal(1, store);
|
Assert.Equal(1, store);
|
||||||
Assert.Equal(1, quest);
|
Assert.Equal(1, quest);
|
||||||
Assert.Equal(1, leaderboard);
|
Assert.Equal(1, leaderboard);
|
||||||
Assert.Equal(1, battlePass);
|
Assert.Equal(1, battlePass);
|
||||||
Assert.Equal(1, battlePassLevel);
|
Assert.Equal(1, battlePassLevel);
|
||||||
|
Assert.Equal(1, wait);
|
||||||
|
Assert.Equal(3, levelEffect!.Level);
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 5, 30, 10, 30, 0, TimeSpan.Zero), waitEffect!.DeadlineUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Wait_Persists_Deadline_And_Completes_After_Restore()
|
public async Task Store_Purchase_And_Decline_Post_Matching_Handles()
|
||||||
{
|
|
||||||
var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero));
|
|
||||||
var stateStore = new FakePlanStateStore();
|
|
||||||
var transport = new FakeTransport();
|
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
|
||||||
{
|
|
||||||
Plans = new List<ExecutionPlan>
|
|
||||||
{
|
|
||||||
Plan(
|
|
||||||
"plan",
|
|
||||||
new[] { Node("wait", "wait", new { duration = 30, unit = "minutes" }), Node("done", "notification") },
|
|
||||||
new[] { Edge("wait", "onComplete", "done") })
|
|
||||||
}
|
|
||||||
});
|
|
||||||
var client = CreateClient(transport, clock: clock, stateStore: stateStore);
|
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
|
||||||
Assert.False(string.IsNullOrEmpty(stateStore.State));
|
|
||||||
|
|
||||||
clock.UtcNow = clock.UtcNow.AddMinutes(31);
|
|
||||||
var restoredClient = CreateClient(new FakeTransport(), clock: clock, stateStore: stateStore);
|
|
||||||
var completed = 0;
|
|
||||||
restoredClient.Scenario.OnNotification += _ => completed++;
|
|
||||||
|
|
||||||
await restoredClient.Scenario.RestoreAsync();
|
|
||||||
restoredClient.Update(0);
|
|
||||||
await Task.Delay(20);
|
|
||||||
|
|
||||||
Assert.Equal(1, completed);
|
|
||||||
var run = Assert.Single(restoredClient.Scenario.ActiveRuns);
|
|
||||||
Assert.Equal("done", Assert.Single(run.ActiveNodeIds));
|
|
||||||
Assert.Contains("\"done\"", stateStore.State);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RemoteConfigOverride_Applies_Patches_And_Continues()
|
|
||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
Plans = new List<ExecutionPlan>
|
Effects = new List<PendingEffect> { Effect("run-1", "store", "store") }
|
||||||
{
|
|
||||||
Plan(
|
|
||||||
"plan",
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
Node("override", "remote_config_override", new
|
|
||||||
{
|
|
||||||
patches = new object[]
|
|
||||||
{
|
|
||||||
new { path = "difficulty", valueType = "string", value = "hard" },
|
|
||||||
new { path = "enemy_count", valueType = "int", value = 12 }
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Node("done", "notification")
|
|
||||||
},
|
|
||||||
new[] { Edge("override", "output", "done") })
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
transport.Enqueue(new HandleScenarioCallbackResponse());
|
||||||
var client = CreateClient(transport);
|
var client = CreateClient(transport);
|
||||||
var notifications = 0;
|
StoreOfferEffect? store = null;
|
||||||
var configChanges = 0;
|
client.Effects.OnStoreOffer += e => store = e;
|
||||||
client.Scenario.OnNotification += _ => notifications++;
|
|
||||||
client.Scenario.OnConfigChanged += _ => configChanges++;
|
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
|
||||||
|
|
||||||
Assert.Equal("hard", client.RemoteConfig.Get("difficulty", "normal"));
|
|
||||||
Assert.Equal(12, client.RemoteConfig.Get("enemy_count", 0));
|
|
||||||
Assert.Equal(1, configChanges);
|
|
||||||
Assert.Equal(1, notifications);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Boundary_Callback_Sends_Source_Node_And_Starts_Continuation_Plan()
|
|
||||||
{
|
|
||||||
var transport = new FakeTransport();
|
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
|
||||||
{
|
|
||||||
Plans = new List<ExecutionPlan>
|
|
||||||
{
|
|
||||||
Plan(
|
|
||||||
"plan",
|
|
||||||
new[] { Node("store", "store") },
|
|
||||||
Array.Empty<PlanEdge>(),
|
|
||||||
new[] { Boundary("store", "onPurchase", "server-condition") },
|
|
||||||
"run-1")
|
|
||||||
}
|
|
||||||
});
|
|
||||||
transport.Enqueue(new HandleScenarioCallbackResponse
|
|
||||||
{
|
|
||||||
Plan = Plan("continuation", Node("done", "notification"), "run-1")
|
|
||||||
});
|
|
||||||
var client = CreateClient(transport);
|
|
||||||
StoreOfferSession? store = null;
|
|
||||||
var notifications = 0;
|
|
||||||
client.Scenario.OnStoreOffer += session => store = session;
|
|
||||||
client.Scenario.OnNotification += _ => notifications++;
|
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
await client.Scenario.TriggerAsync("login");
|
||||||
await store!.PurchaseAsync();
|
await store!.PurchaseAsync();
|
||||||
|
|
||||||
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
|
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
|
||||||
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
|
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
|
||||||
Assert.Equal("store", callback.NodeId);
|
|
||||||
Assert.Equal("onPurchase", callback.Handle);
|
Assert.Equal("onPurchase", callback.Handle);
|
||||||
Assert.Equal(1, notifications);
|
Assert.True(store.IsResolved);
|
||||||
|
|
||||||
|
store.Decline();
|
||||||
|
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/callback"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Boundary_Wins_Over_Local_Edges()
|
public async Task Leaderboard_End_Posts_OnEnd()
|
||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
Plans = new List<ExecutionPlan>
|
Effects = new List<PendingEffect> { Effect("run-1", "lb", "leaderboard") }
|
||||||
{
|
|
||||||
Plan(
|
|
||||||
"plan",
|
|
||||||
new[] { Node("store", "store"), Node("local", "notification") },
|
|
||||||
new[] { Edge("store", "onPurchase", "local") },
|
|
||||||
new[] { Boundary("store", "onPurchase", "server-condition") },
|
|
||||||
"run-1")
|
|
||||||
}
|
|
||||||
});
|
|
||||||
transport.Enqueue(new HandleScenarioCallbackResponse
|
|
||||||
{
|
|
||||||
Plan = Plan("continuation", Node("server", "notification"), "run-1")
|
|
||||||
});
|
});
|
||||||
|
transport.Enqueue(new HandleScenarioCallbackResponse());
|
||||||
var client = CreateClient(transport);
|
var client = CreateClient(transport);
|
||||||
StoreOfferSession? store = null;
|
LeaderboardEffect? leaderboard = null;
|
||||||
var notificationIds = new List<string>();
|
client.Effects.OnLeaderboard += e => leaderboard = e;
|
||||||
client.Scenario.OnStoreOffer += session => store = session;
|
|
||||||
client.Scenario.OnNotification += session => notificationIds.Add(session.Id);
|
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
await client.Scenario.TriggerAsync("login");
|
||||||
await store!.PurchaseAsync();
|
await leaderboard!.EndAsync();
|
||||||
|
|
||||||
Assert.Equal(new[] { "server" }, notificationIds);
|
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
|
||||||
Assert.Contains(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback");
|
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
|
||||||
|
Assert.Equal("onEnd", callback.Handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Quest_Progress_Completed_Response_Completes_The_Node()
|
public async Task Leaderboard_Claim_Posts_OnClaim()
|
||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
Plans = new List<ExecutionPlan>
|
Effects = new List<PendingEffect> { Effect("run-1", "lb", "leaderboard") }
|
||||||
{
|
|
||||||
Plan("plan", Node("quest", "quest"))
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true });
|
transport.Enqueue(new HandleScenarioCallbackResponse());
|
||||||
var client = CreateClient(transport);
|
var client = CreateClient(transport);
|
||||||
QuestSession? quest = null;
|
LeaderboardEffect? leaderboard = null;
|
||||||
var completed = 0;
|
client.Effects.OnLeaderboard += e => leaderboard = e;
|
||||||
client.Scenario.OnQuest += session => quest = session;
|
|
||||||
client.Scenario.OnScenarioCompleted += _ => completed++;
|
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
await client.Scenario.TriggerAsync("login");
|
||||||
await quest!.AddProgressAsync("wins", 1);
|
await leaderboard!.ClaimAsync();
|
||||||
|
|
||||||
|
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
|
||||||
|
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
|
||||||
|
Assert.Equal("onClaim", callback.Handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Quest_Progress_Completed_Response_Ingests_Next_Effect()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest", new { name = "wins" }) }
|
||||||
|
});
|
||||||
|
transport.Enqueue(new UpdateScenarioCounterResponse
|
||||||
|
{
|
||||||
|
Completed = true,
|
||||||
|
Effect = JObject.FromObject(Effect("run-1", "done", "notification"))
|
||||||
|
});
|
||||||
|
var client = CreateClient(transport);
|
||||||
|
QuestEffect? quest = null;
|
||||||
|
NotificationEffect? notification = null;
|
||||||
|
client.Effects.OnQuest += e => quest = e;
|
||||||
|
client.Effects.OnNotification += e => notification = e;
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
Assert.Equal("wins", quest!.Name);
|
||||||
|
await quest.ReportProgressAsync("wins", 1);
|
||||||
|
|
||||||
var counter = Assert.IsType<UpdateScenarioCounterRequest>(
|
var counter = Assert.IsType<UpdateScenarioCounterRequest>(
|
||||||
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/counter").Request);
|
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/counter").Request);
|
||||||
Assert.Equal("quest", counter.NodeId);
|
Assert.Equal("quest", counter.NodeId);
|
||||||
Assert.Equal("wins", counter.CounterKey);
|
Assert.Equal("wins", counter.CounterKey);
|
||||||
|
Assert.Equal(1, counter.Amount);
|
||||||
|
Assert.NotNull(notification);
|
||||||
|
Assert.Equal("done", notification!.NodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Quest_Progress_Completed_Without_Next_Raises_ScenarioCompleted()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest") }
|
||||||
|
});
|
||||||
|
transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true });
|
||||||
|
var client = CreateClient(transport);
|
||||||
|
QuestEffect? quest = null;
|
||||||
|
var completed = 0;
|
||||||
|
client.Effects.OnQuest += e => quest = e;
|
||||||
|
client.Effects.OnScenarioCompleted += _ => completed++;
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
await quest!.ReportProgressAsync("wins", 1);
|
||||||
|
|
||||||
Assert.Equal(1, completed);
|
Assert.Equal(1, completed);
|
||||||
Assert.Empty(client.Scenario.ActiveRuns);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -299,129 +280,253 @@ public sealed class ScenarioServiceTests
|
|||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
Plans = new List<ExecutionPlan>
|
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest") }
|
||||||
{
|
|
||||||
Plan("plan", Node("quest", "quest"))
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
transport.Enqueue(new InvalidOperationException("boom"));
|
transport.Enqueue(new InvalidOperationException("boom"));
|
||||||
var logger = new FakeLogger();
|
var logger = new FakeLogger();
|
||||||
var client = CreateClient(transport, logger: logger);
|
var client = CreateClient(transport, logger: logger);
|
||||||
QuestSession? quest = null;
|
QuestEffect? quest = null;
|
||||||
var failed = 0;
|
var failed = 0;
|
||||||
client.Scenario.OnQuest += session => quest = session;
|
client.Effects.OnQuest += e => quest = e;
|
||||||
client.Scenario.OnScenarioFailed += _ => failed++;
|
client.Effects.OnScenarioFailed += _ => failed++;
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
await client.Scenario.TriggerAsync("login");
|
||||||
await quest!.AddProgressAsync("wins", 1);
|
await quest!.ReportProgressAsync("wins", 1);
|
||||||
|
|
||||||
Assert.Equal(0, failed);
|
Assert.Equal(0, failed);
|
||||||
Assert.Single(client.Scenario.ActiveRuns);
|
|
||||||
Assert.Contains(logger.Messages, m =>
|
Assert.Contains(logger.Messages, m =>
|
||||||
m.Level == RudderLogLevel.Warning && m.Message.Contains("counter update failed"));
|
m.Level == RudderLogLevel.Warning && m.Message.Contains("counter update failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_Run_Drops_Effects_And_Raises_Failed()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
|
||||||
|
});
|
||||||
|
transport.Enqueue(new RudderNotFoundException(404, RudderErrorCodes.UnknownRun, "unknown"));
|
||||||
|
var client = CreateClient(transport);
|
||||||
|
NotificationEffect? notification = null;
|
||||||
|
ScenarioFailedEffect? failure = null;
|
||||||
|
client.Effects.OnNotification += e => notification = e;
|
||||||
|
client.Effects.OnScenarioFailed += e => failure = e;
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
await notification!.DoneAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(failure);
|
||||||
|
Assert.Equal("run-1", failure!.RunId);
|
||||||
|
Assert.Equal("n1", failure.NodeId);
|
||||||
|
Assert.IsType<RudderNotFoundException>(failure.Exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Expired_Run_Drops_Effects()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
|
||||||
|
});
|
||||||
|
transport.Enqueue(new RudderApiException(400, RudderErrorCodes.RunExpired, "expired"));
|
||||||
|
var client = CreateClient(transport);
|
||||||
|
NotificationEffect? notification = null;
|
||||||
|
ScenarioFailedEffect? failure = null;
|
||||||
|
client.Effects.OnNotification += e => notification = e;
|
||||||
|
client.Effects.OnScenarioFailed += e => failure = e;
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
await notification!.DoneAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(failure);
|
||||||
|
Assert.Equal("run-1", failure!.RunId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Unknown_Nodes_Fail_The_Run()
|
public async Task Unknown_Nodes_Fail_The_Run()
|
||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
transport.Enqueue(new TriggerScenarioResponse
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
{
|
{
|
||||||
Plans = new List<ExecutionPlan>
|
Effects = new List<PendingEffect> { Effect("run-1", "future", "future_node") }
|
||||||
{
|
|
||||||
Plan("plan", Node("future", "future_node"))
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
var logger = new FakeLogger();
|
var logger = new FakeLogger();
|
||||||
var client = CreateClient(transport, logger: logger);
|
var client = CreateClient(transport, logger: logger);
|
||||||
ScenarioFailedEvent? failure = null;
|
ScenarioFailedEffect? failure = null;
|
||||||
client.Scenario.OnScenarioFailed += e => failure = e;
|
client.Effects.OnScenarioFailed += e => failure = e;
|
||||||
|
|
||||||
await client.Scenario.TriggerAsync("login");
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
|
||||||
Assert.NotNull(failure);
|
Assert.NotNull(failure);
|
||||||
Assert.Equal("future", failure!.NodeId);
|
Assert.Equal("future", failure!.NodeId);
|
||||||
Assert.Empty(client.Scenario.ActiveRuns);
|
|
||||||
Assert.Contains(logger.Messages, m =>
|
Assert.Contains(logger.Messages, m =>
|
||||||
m.Level == RudderLogLevel.Warning && m.Message.Contains("Unsupported scenario node type 'future_node'"));
|
m.Level == RudderLogLevel.Warning && m.Message.Contains("Unsupported scenario node type 'future_node'"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wait_Deadline_Refreshes_Pending_Without_Posting_Callback()
|
||||||
|
{
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect>
|
||||||
|
{
|
||||||
|
Effect("run-1", "wait", "wait", waitDeadline: clock.UtcNow.AddMinutes(30))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var client = CreateClient(transport, clock: clock);
|
||||||
|
client.Update(0);
|
||||||
|
await Task.Delay(50);
|
||||||
|
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
client.Update(0);
|
||||||
|
await Task.Delay(50);
|
||||||
|
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
|
||||||
|
Assert.DoesNotContain(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback");
|
||||||
|
|
||||||
|
transport.Enqueue(new ListPendingScenarioEffectsResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "done", "notification") }
|
||||||
|
});
|
||||||
|
var notifications = 0;
|
||||||
|
client.Effects.OnNotification += _ => notifications++;
|
||||||
|
clock.UtcNow = clock.UtcNow.AddMinutes(31);
|
||||||
|
client.Update(0);
|
||||||
|
await Task.Delay(50);
|
||||||
|
|
||||||
|
Assert.Equal(2, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
|
||||||
|
Assert.Equal(1, notifications);
|
||||||
|
Assert.DoesNotContain(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Heartbeat_Refreshes_Pending_After_30_Seconds()
|
||||||
|
{
|
||||||
|
var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
|
||||||
|
var client = CreateClient(transport, clock: clock);
|
||||||
|
|
||||||
|
client.Update(0);
|
||||||
|
await Task.Delay(50);
|
||||||
|
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
|
||||||
|
|
||||||
|
clock.UtcNow = clock.UtcNow.AddSeconds(29);
|
||||||
|
client.Update(0);
|
||||||
|
await Task.Delay(50);
|
||||||
|
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
|
||||||
|
|
||||||
|
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
|
||||||
|
clock.UtcNow = clock.UtcNow.AddSeconds(1);
|
||||||
|
client.Update(0);
|
||||||
|
await Task.Delay(50);
|
||||||
|
Assert.Equal(2, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_Fetches_Pending_On_Next_Update()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new LoginViaDeviceResponse
|
||||||
|
{
|
||||||
|
AccessToken = "access-token",
|
||||||
|
RefreshToken = "refresh-token"
|
||||||
|
});
|
||||||
|
transport.Enqueue(new ListPendingScenarioEffectsResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
|
||||||
|
});
|
||||||
|
var tokenStore = new FakeTokenStore();
|
||||||
|
var client = CreateClient(transport, tokenStore: tokenStore);
|
||||||
|
var notifications = 0;
|
||||||
|
client.Effects.OnNotification += _ => notifications++;
|
||||||
|
|
||||||
|
await client.Auth.LoginWithDeviceAsync("en", "en");
|
||||||
|
Assert.Equal(0, notifications);
|
||||||
|
|
||||||
|
client.Update(0);
|
||||||
|
await Task.Delay(50);
|
||||||
|
|
||||||
|
Assert.Equal(1, notifications);
|
||||||
|
Assert.Contains(transport.Calls, call => call.Method == "GET" && call.Path == "/sdk/v1/scenarios/pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BattlePass_LevelUp_Posts_OnLevelUp()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "bp", "battlepass") }
|
||||||
|
});
|
||||||
|
transport.Enqueue(new HandleScenarioCallbackResponse());
|
||||||
|
var client = CreateClient(transport);
|
||||||
|
BattlePassEffect? battlePass = null;
|
||||||
|
client.Effects.OnBattlePass += e => battlePass = e;
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
await battlePass!.LevelUpAsync();
|
||||||
|
|
||||||
|
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
|
||||||
|
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
|
||||||
|
Assert.Equal("onLevelUp", callback.Handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BattlePassLevel_Claim_Posts_OnComplete()
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
transport.Enqueue(new TriggerScenarioResponse
|
||||||
|
{
|
||||||
|
Effects = new List<PendingEffect> { Effect("run-1", "bpl", "battlepass_level", new { levelNumber = 2 }) }
|
||||||
|
});
|
||||||
|
transport.Enqueue(new HandleScenarioCallbackResponse());
|
||||||
|
var client = CreateClient(transport);
|
||||||
|
BattlePassLevelEffect? level = null;
|
||||||
|
client.Effects.OnBattlePassLevel += e => level = e;
|
||||||
|
|
||||||
|
await client.Scenario.TriggerAsync("login");
|
||||||
|
await level!.ClaimAsync();
|
||||||
|
|
||||||
|
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
|
||||||
|
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
|
||||||
|
Assert.Equal("onComplete", callback.Handle);
|
||||||
|
Assert.Equal(2, level.Level);
|
||||||
|
}
|
||||||
|
|
||||||
private static RudderClient CreateClient(
|
private static RudderClient CreateClient(
|
||||||
FakeTransport transport,
|
FakeTransport transport,
|
||||||
FakeClock? clock = null,
|
FakeClock? clock = null,
|
||||||
FakePlanStateStore? stateStore = null,
|
IRudderLogger? logger = null,
|
||||||
IRudderLogger? logger = null)
|
FakeTokenStore? tokenStore = null)
|
||||||
{
|
{
|
||||||
return new RudderClient(new RudderClientOptions
|
return new RudderClient(new RudderClientOptions
|
||||||
{
|
{
|
||||||
BaseUrl = "http://localhost:8082",
|
BaseUrl = "http://localhost:8082",
|
||||||
RealtimeUrl = "ws://localhost:8090/api/realtime/ws",
|
|
||||||
ProjectKey = "project-key",
|
ProjectKey = "project-key",
|
||||||
Transport = transport,
|
Transport = transport,
|
||||||
TokenStore = new FakeTokenStore { AccessToken = "access-token" },
|
TokenStore = tokenStore ?? new FakeTokenStore { AccessToken = "access-token" },
|
||||||
DeviceIdProvider = new FakeDeviceIdProvider(),
|
DeviceIdProvider = new FakeDeviceIdProvider(),
|
||||||
Clock = clock ?? new FakeClock(DateTimeOffset.UtcNow),
|
Clock = clock ?? new FakeClock(DateTimeOffset.UtcNow),
|
||||||
PlanStateStore = stateStore ?? new FakePlanStateStore(),
|
|
||||||
RealtimeTransportFactory = new FakeRealtimeTransportFactory(),
|
|
||||||
Logger = logger
|
Logger = logger
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ExecutionPlan Plan(string id, ExecutionPlanNode node, string? runId = null)
|
private static PendingEffect Effect(string runId, string nodeId, string type, object? data = null, DateTimeOffset? waitDeadline = null)
|
||||||
=> Plan(id, new[] { node }, Array.Empty<PlanEdge>(), runId: runId);
|
|
||||||
|
|
||||||
private static ExecutionPlan Plan(
|
|
||||||
string id,
|
|
||||||
IEnumerable<ExecutionPlanNode> nodes,
|
|
||||||
IEnumerable<PlanEdge> edges,
|
|
||||||
IEnumerable<BoundaryNode>? boundaries = null,
|
|
||||||
string? runId = null)
|
|
||||||
{
|
{
|
||||||
var nodeList = nodes.ToList();
|
return new PendingEffect
|
||||||
return new ExecutionPlan
|
|
||||||
{
|
{
|
||||||
PlanId = id,
|
RunId = runId,
|
||||||
ScenarioId = "scenario-" + id,
|
ScenarioId = "scenario-1",
|
||||||
UserId = "user",
|
NodeId = nodeId,
|
||||||
StartNodeId = nodeList[0].Id,
|
|
||||||
Nodes = nodeList,
|
|
||||||
Edges = edges.ToList(),
|
|
||||||
BoundaryNodes = boundaries?.ToList() ?? new List<BoundaryNode>(),
|
|
||||||
RunId = runId!,
|
|
||||||
Context = new JObject()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ExecutionPlanNode Node(string id, string type, object? data = null)
|
|
||||||
{
|
|
||||||
return new ExecutionPlanNode
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Type = type,
|
Type = type,
|
||||||
Data = data == null ? new JObject() : JObject.FromObject(data)
|
Data = data == null ? new JObject() : JObject.FromObject(data),
|
||||||
};
|
WaitDeadline = waitDeadline
|
||||||
}
|
|
||||||
|
|
||||||
private static PlanEdge Edge(string source, string handle, string target)
|
|
||||||
{
|
|
||||||
return new PlanEdge
|
|
||||||
{
|
|
||||||
Id = source + "-" + handle + "-" + target,
|
|
||||||
Source = source,
|
|
||||||
SourceHandle = handle,
|
|
||||||
Target = target,
|
|
||||||
TargetHandle = "in"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BoundaryNode Boundary(string source, string handle, string target)
|
|
||||||
{
|
|
||||||
return new BoundaryNode
|
|
||||||
{
|
|
||||||
SourceNodeId = source,
|
|
||||||
SourceHandle = handle,
|
|
||||||
NodeId = target,
|
|
||||||
CallbackUrl = "/sdk/v1/scenarios/callback"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +548,11 @@ public sealed class ScenarioServiceTests
|
|||||||
if (_responses.Count == 0)
|
if (_responses.Count == 0)
|
||||||
return Task.FromResult(default(TResponse)!);
|
return Task.FromResult(default(TResponse)!);
|
||||||
|
|
||||||
return Task.FromResult((TResponse)_responses.Dequeue());
|
var next = _responses.Dequeue();
|
||||||
|
if (next is Exception ex)
|
||||||
|
return Task.FromException<TResponse>(ex);
|
||||||
|
|
||||||
|
return Task.FromResult((TResponse)next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,42 +601,9 @@ public sealed class ScenarioServiceTests
|
|||||||
public DateTimeOffset UtcNow { get; set; }
|
public DateTimeOffset UtcNow { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakePlanStateStore : IPlanStateStore
|
|
||||||
{
|
|
||||||
public string? State { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class FakeLogger : IRudderLogger
|
private sealed class FakeLogger : IRudderLogger
|
||||||
{
|
{
|
||||||
public List<(RudderLogLevel Level, string Message)> Messages { get; } = new();
|
public List<(RudderLogLevel Level, string Message)> Messages { get; } = new();
|
||||||
public void Log(RudderLogLevel level, string message) => Messages.Add((level, message));
|
public void Log(RudderLogLevel level, string message) => Messages.Add((level, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeRealtimeTransportFactory : IRealtimeTransportFactory
|
|
||||||
{
|
|
||||||
public IRealtimeTransport Create() => new FakeRealtimeTransport();
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma warning disable CS0067
|
|
||||||
private sealed class FakeRealtimeTransport : IRealtimeTransport
|
|
||||||
{
|
|
||||||
public bool IsConnected { get; private set; }
|
|
||||||
public event Action? Closed;
|
|
||||||
public event Action<Exception>? Error;
|
|
||||||
public event Action<ArraySegment<byte>>? Received;
|
|
||||||
public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
IsConnected = true;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
public Task SendAsync(ArraySegment<byte> payload, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
||||||
public Task CloseAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
IsConnected = false;
|
|
||||||
Closed?.Invoke();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
public void Update(float deltaTime) { }
|
|
||||||
}
|
|
||||||
#pragma warning restore CS0067
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user