Server-side scenario execution: effects client replaces local engine

- client.Effects: typed effects + completion methods posting scenario callbacks
- client.Scenario reduced to TriggerAsync
- pending polling via Update() pump: 30s heartbeat + wait-deadline checks
- local engine, plan persistence (IPlanStateStore) and resume removed
- RealtimeService and realtime transports removed (relay never deployed)
- dead IPlanScheduler leftover removed
- models regenerated from openapi (ExecutionPlan/BoundaryNode gone, PendingEffect added)
This commit is contained in:
edmand46
2026-09-04 14:03:41 +03:00
parent 05dd30f31d
commit 6c590ca520
52 changed files with 1304 additions and 2040 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ namespace RudderSdk.Core;
/// <summary>
/// Battle pass progress and rewards. Battle pass state is tied to a scenario
/// battle pass node, so every call carries the scenario/node ids (and a run id
/// for the mutating calls) — <see cref="BattlePassSession"/> supplies them
/// for the mutating calls) — <see cref="BattlePassEffect"/> supplies them
/// during scenario runs.
/// </summary>
public sealed class BattlePassService
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Models.BattlePass;
namespace RudderSdk.Core;
/// <summary>
/// A scenario battle-pass node. Battle-pass operations are bound to this
/// node's scenario/node/run ids; <see cref="LevelUpAsync"/>, <see cref="EndAsync"/>
/// and a successful <see cref="PurchasePremiumAsync"/> post callbacks.
/// </summary>
public sealed class BattlePassEffect
{
private readonly EffectHandle _handle;
private readonly BattlePassService _battlePass;
internal BattlePassEffect(EffectHandle handle, BattlePassService battlePass)
{
_handle = handle;
_battlePass = battlePass;
}
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string 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");
}
+41
View File
@@ -0,0 +1,41 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>
/// A scenario battle-pass-level node — a single claimable tier.
/// <see cref="ClaimAsync"/> posts <c>onComplete</c>, which the server accepts
/// only once the player has reached the node's configured level.
/// </summary>
public sealed class BattlePassLevelEffect
{
private readonly EffectHandle _handle;
internal BattlePassLevelEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string 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");
}
+61
View File
@@ -0,0 +1,61 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario leaderboard node. End it with <see cref="EndAsync"/> or claim with <see cref="ClaimAsync"/>.</summary>
public sealed class LeaderboardEffect
{
private readonly EffectHandle _handle;
internal LeaderboardEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string 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);
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario notification node. <see cref="DoneAsync"/> posts the <c>output</c> callback.</summary>
public sealed class NotificationEffect
{
private readonly EffectHandle _handle;
internal NotificationEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string 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");
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>
/// A scenario quest node. Report objective progress; the server auto-completes
/// the node once every objective is satisfied.
/// </summary>
public sealed class QuestEffect
{
private readonly EffectHandle _handle;
internal QuestEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string 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; }
}
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace RudderSdk.Core;
/// <summary>Payload of <see cref="EffectsService.OnScenarioFailed"/>.</summary>
public sealed class ScenarioFailedEffect
{
internal ScenarioFailedEffect(string runId, string 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; }
}
+74
View File
@@ -0,0 +1,74 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario store-offer node. Resolve it with <see cref="PurchaseAsync"/> or <see cref="DeclineAsync"/>.</summary>
public sealed class StoreOfferEffect
{
private readonly EffectHandle _handle;
internal StoreOfferEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string 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);
}
}
+34
View File
@@ -0,0 +1,34 @@
using System;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario wait node. The run resumes when the server advances it at <see cref="DeadlineUtc"/>.</summary>
public sealed class WaitEffect
{
private readonly EffectHandle _handle;
internal WaitEffect(EffectHandle handle, DateTimeOffset deadlineUtc)
{
_handle = handle;
DeadlineUtc = deadlineUtc;
}
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string 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);
}
+410
View File
@@ -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);
}
+1 -1
View File
@@ -7,7 +7,7 @@ namespace RudderSdk.Core;
/// <summary>
/// Global quests (list + claim), distinct from scenario quest nodes which
/// advance through <see cref="QuestSession"/>.
/// advance through <see cref="QuestEffect"/>.
/// </summary>
public sealed class QuestsService
{
-105
View File
@@ -1,105 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Core;
/// <summary>
/// Realtime websocket channel. Requires
/// <see cref="RudderClientOptions.RealtimeUrl"/> and
/// <see cref="RudderClientOptions.RealtimeTransportFactory"/>.
/// </summary>
public sealed class RealtimeService
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private readonly RudderClient _client;
private RealtimeSession? _session;
internal RealtimeService(RudderClient client) => _client = client;
/// <summary>The current session, or null when not connected.</summary>
public RealtimeSession? Session => _session;
/// <summary>True while a session is connected.</summary>
public bool IsConnected => _session?.IsConnected == true;
/// <summary>Connects to the configured realtime URL.</summary>
public Task<RealtimeSession> ConnectAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(_client.RealtimeUrl))
throw new InvalidOperationException("RealtimeUrl is not configured.");
return ConnectAsync(new Uri(_client.RealtimeUrl), timeout, cancellationToken);
}
/// <summary>Connects to an explicit realtime URL.</summary>
public async Task<RealtimeSession> ConnectAsync(Uri uri, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var factory = _client.Options.RealtimeTransportFactory
?? throw new InvalidOperationException("RealtimeTransportFactory is not configured.");
var token = _client.TokenStore.GetAccessToken();
if (string.IsNullOrEmpty(token))
throw new InvalidOperationException("LiveOps access token is required for realtime authorization.");
var transport = factory.Create();
await transport.ConnectAsync(uri, timeout ?? DefaultTimeout, cancellationToken).ConfigureAwait(false);
_session = new RealtimeSession(transport, token);
return _session;
}
/// <summary>Closes the current session, if any.</summary>
public Task DisconnectAsync(CancellationToken cancellationToken = default)
=> _session?.DisconnectAsync(cancellationToken) ?? Task.CompletedTask;
/// <summary>Pumps the underlying transport; call every frame.</summary>
public void Update(float deltaTime)
{
_session?.Update(deltaTime);
}
}
/// <summary>An open realtime connection.</summary>
public sealed class RealtimeSession
{
private readonly IRealtimeTransport _transport;
internal RealtimeSession(IRealtimeTransport transport, string accessToken)
{
_transport = transport;
AccessToken = accessToken;
_transport.Closed += () => Closed?.Invoke();
_transport.Error += ex => Error?.Invoke(ex);
_transport.Received += data => MessageReceived?.Invoke(data);
}
/// <summary>Access token the connection was authorized with.</summary>
public string AccessToken { get; }
/// <summary>True while the connection is open.</summary>
public bool IsConnected => _transport.IsConnected;
/// <summary>Raised when the connection closes.</summary>
public event Action? Closed;
/// <summary>Raised on transport errors.</summary>
public event Action<Exception>? Error;
/// <summary>Raised for every incoming message.</summary>
public event Action<ArraySegment<byte>>? MessageReceived;
/// <summary>Sends one message.</summary>
public Task SendAsync(byte[] payload, CancellationToken cancellationToken = default)
=> _transport.SendAsync(new ArraySegment<byte>(payload ?? Array.Empty<byte>()), cancellationToken);
/// <summary>Closes the connection.</summary>
public Task DisconnectAsync(CancellationToken cancellationToken = default)
=> _transport.CloseAsync(cancellationToken);
internal void Update(float deltaTime)
{
_transport.Update(deltaTime);
}
}
+7 -218
View File
@@ -1,71 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core;
/// <summary>
/// Scenario runtime. <see cref="TriggerAsync"/> starts server-issued plans;
/// active nodes surface as typed sessions through the On* events and are
/// advanced by completing those sessions.
/// Scenario trigger. Execution lives on the server; resulting effects surface
/// through <see cref="RudderClient.Effects"/>.
/// </summary>
public sealed partial class ScenarioService
public sealed class ScenarioService
{
private readonly RudderClient _client;
private readonly Dictionary<string, RuntimeRun> _runs = new();
/// <summary>Raised for a notification node.</summary>
public event Action<NotificationSession>? OnNotification;
/// <summary>Raised for a store-offer node.</summary>
public event Action<StoreOfferSession>? OnStoreOffer;
/// <summary>Raised for a leaderboard node.</summary>
public event Action<LeaderboardSession>? OnLeaderboard;
/// <summary>Raised for a remote-config-override node, after the patches were applied.</summary>
public event Action<ConfigChangedSession>? OnConfigChanged;
/// <summary>Raised for a wait node.</summary>
public event Action<WaitSession>? OnWait;
/// <summary>Raised for a quest node.</summary>
public event Action<QuestSession>? OnQuest;
/// <summary>Raised for a battle-pass node.</summary>
public event Action<BattlePassSession>? OnBattlePass;
/// <summary>Raised for a battle-pass-level node.</summary>
public event Action<BattlePassLevelSession>? OnBattlePassLevel;
/// <summary>Raised when a run finishes all its nodes.</summary>
public event Action<PlanRun>? OnScenarioCompleted;
/// <summary>Raised when a run dies on an unrecoverable error.</summary>
public event Action<ScenarioFailedEvent>? OnScenarioFailed;
internal ScenarioService(RudderClient client) => _client = client;
/// <summary>True while at least one run is active.</summary>
public bool IsRunning => _runs.Count > 0;
/// <summary>First active node id across the runs, or null.</summary>
public string? CurrentNodeId => _runs.Values.FirstOrDefault()?.ActiveNodes.Keys.FirstOrDefault();
/// <summary>Snapshots of the active runs.</summary>
public IReadOnlyList<PlanRun> ActiveRuns => _runs.Values.Select(ToPlanRun).ToList();
/// <summary>
/// Triggers scenarios by event name and starts the plans the server
/// returns. Returns the runs this call started.
/// Triggers scenarios by event name. Returned pending effects are ingested
/// into <see cref="RudderClient.Effects"/>.
/// </summary>
public async Task<IReadOnlyList<PlanRun>> TriggerAsync(string eventName, CancellationToken cancellationToken = default)
public async Task TriggerAsync(string eventName, CancellationToken cancellationToken = default)
{
var response = await _client.SendAsync<TriggerScenarioRequest, TriggerScenarioResponse>(
"POST",
@@ -73,170 +26,6 @@ public sealed partial class ScenarioService
new TriggerScenarioRequest { Event = eventName },
cancellationToken).ConfigureAwait(false);
return StartPlans(response?.Plans);
}
/// <summary>
/// Restores persisted runs, reconciles them with the server and re-dispatches
/// active nodes. Call once after startup, after login.
/// </summary>
public Task RestoreAsync(CancellationToken cancellationToken = default)
{
return RestoreCoreAsync(cancellationToken);
}
private async Task RestoreCoreAsync(CancellationToken cancellationToken = default)
{
var store = _client.Options.PlanStateStore;
if (store == null || string.IsNullOrEmpty(store.State))
return;
try
{
var state = JsonConvert.DeserializeObject<PersistedScenarioState>(store.State);
_runs.Clear();
if (state?.Runs != null)
{
foreach (var savedRun in state.Runs)
{
if (savedRun?.Plan == null)
continue;
// Reconcile with server
try
{
var response = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
"POST",
"/sdk/v1/scenarios/run",
new GetScenarioRunRequest { RunId = savedRun.RunId },
cancellationToken);
if (response?.Status == "unknown_run" || response?.Status == "expired")
continue;
if (response?.Plan != null)
{
savedRun.Plan = response.Plan;
savedRun.ActiveNodes = null;
savedRun.CompletedHandles = null;
}
}
catch
{
// Network error — keep local state as fallback
}
var run = RuntimeRun.FromPersisted(savedRun);
_runs[run.RunId] = run;
}
}
foreach (var run in _runs.Values.ToList())
{
if (run.ActiveNodes.Count > 0)
{
foreach (var nodeState in run.ActiveNodes.Values.ToList())
DispatchActiveNode(run, nodeState, restored: true);
}
else
{
// Rebuilt from server — activate start node
var startNode = FindNode(run.Plan, run.Plan.StartNodeId) ?? run.Plan.Nodes[0];
ActivateNode(run, startNode.Id);
}
}
Persist();
}
catch (Exception ex)
{
_client.Options.Logger?.Log(RudderLogLevel.Error, "[Rudder] Failed to restore scenario state. Clearing persisted state. " + ex.Message);
Clear();
}
}
/// <summary>Drops all runs and the persisted state.</summary>
public void Clear()
{
_runs.Clear();
Persist();
}
/// <summary>Completes wait nodes whose deadline passed; call every frame.</summary>
public void Update(float deltaTime)
{
var now = _client.Clock.UtcNow;
foreach (var run in _runs.Values.ToList())
{
foreach (var node in run.ActiveNodes.Values.ToList())
{
if (node.WaitDeadlineUtc.HasValue && now >= node.WaitDeadlineUtc.Value)
_ = CompleteNodeAsync(run.RunId, node.NodeId, "onComplete");
}
}
}
/// <summary>Completes the first active node with the given handle.</summary>
public Task RespondAsync(string handle, CancellationToken cancellationToken = default)
{
var run = _runs.Values.FirstOrDefault();
var node = run?.ActiveNodes.Values.FirstOrDefault();
return run == null || node == null
? Task.CompletedTask
: CompleteNodeAsync(run.RunId, node.NodeId, handle, cancellationToken);
}
/// <summary>Completes the first active node with the given handle (fire-and-forget).</summary>
public void Respond(string handle)
{
_ = RespondAsync(handle);
}
/// <summary>Adds progress to a counter of the first active node.</summary>
public Task UpdateProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
{
var run = _runs.Values.FirstOrDefault();
var node = run?.ActiveNodes.Values.FirstOrDefault();
return run == null || node == null
? Task.CompletedTask
: UpdateProgressAsync(run.RunId, node.NodeId, counterKey, amount, cancellationToken);
}
internal async Task UpdateProgressAsync(
string runId,
string nodeId,
string counterKey,
long amount,
CancellationToken cancellationToken = default)
{
if (!_runs.TryGetValue(runId, out var run) || !run.ActiveNodes.ContainsKey(nodeId))
return;
try
{
var response = await _client.SendAsync<UpdateScenarioCounterRequest, UpdateScenarioCounterResponse>(
"POST",
"/sdk/v1/scenarios/counter",
new UpdateScenarioCounterRequest
{
ScenarioId = run.Plan.ScenarioId,
NodeId = nodeId,
CounterKey = counterKey,
Amount = amount,
RunId = run.RunId
},
cancellationToken).ConfigureAwait(false);
// The server reports objective completion; it no longer returns a plan from the
// counter endpoint. On completion, cross the node's onComplete handle (which
// advances the run) — idempotent if the consumer also completes the session.
if (response != null && response.Completed == true)
await CompleteNodeAsync(runId, nodeId, "onComplete", cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Counter update failure does not fail the run.
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Scenario counter update failed at node {nodeId}: {ex.Message}");
}
_client.Effects.Ingest(response?.Effects);
}
}
-42
View File
@@ -1,42 +0,0 @@
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
/// <summary>Snapshot of one running scenario plan.</summary>
public sealed class PlanRun
{
internal PlanRun(
string runId,
string planId,
string scenarioId,
string userId,
IReadOnlyList<string> activeNodeIds,
ExecutionPlan plan)
{
RunId = runId;
PlanId = planId;
ScenarioId = scenarioId;
UserId = userId;
ActiveNodeIds = activeNodeIds;
Plan = plan;
}
/// <summary>Server-issued run id.</summary>
public string RunId { get; }
/// <summary>Plan id.</summary>
public string PlanId { get; }
/// <summary>Scenario id.</summary>
public string ScenarioId { get; }
/// <summary>Player the run belongs to.</summary>
public string UserId { get; }
/// <summary>Ids of the currently active nodes.</summary>
public IReadOnlyList<string> ActiveNodeIds { get; }
/// <summary>The execution plan being run.</summary>
public ExecutionPlan Plan { get; }
}
@@ -1,23 +0,0 @@
using System;
namespace RudderSdk.Core;
/// <summary>Payload of <see cref="ScenarioService.OnScenarioFailed"/>.</summary>
public sealed class ScenarioFailedEvent
{
internal ScenarioFailedEvent(PlanRun run, string nodeId, Exception exception)
{
Run = run;
NodeId = nodeId;
Exception = exception;
}
/// <summary>The failed run.</summary>
public PlanRun Run { get; }
/// <summary>Node the failure happened at.</summary>
public string NodeId { get; }
/// <summary>The error that failed the run.</summary>
public Exception Exception { get; }
}
@@ -1,78 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
/// <summary>Context of the scenario node a session was created for.</summary>
public class ScenarioNodeContext
{
private readonly ScenarioService _service;
internal ScenarioNodeContext(ScenarioService service, PlanRun run, ExecutionPlanNode node)
{
_service = service;
Run = run;
Node = node;
Data = node?.Data as JObject ?? new JObject();
}
/// <summary>The run this node belongs to.</summary>
public PlanRun Run { get; }
/// <summary>The plan node.</summary>
public ExecutionPlanNode Node { get; }
/// <summary>Run id.</summary>
public string RunId => Run.RunId;
/// <summary>Plan id.</summary>
public string PlanId => Run.PlanId;
/// <summary>Scenario id.</summary>
public string ScenarioId => Run.ScenarioId;
/// <summary>Node id.</summary>
public string NodeId => Node.Id;
/// <summary>Node type.</summary>
public string Type => Node.Type;
/// <summary>Node data payload.</summary>
public JObject Data { get; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!)
{
if (Data == null || !Data.TryGetValue(key, out var value))
return defaultValue;
try { return value.ToObject<T>() ?? defaultValue; }
catch { return defaultValue; }
}
/// <summary>Deserializes the whole node data payload.</summary>
public T Get<T>()
{
try { return Data == null ? default! : Data.ToObject<T>() ?? default!; }
catch { return default!; }
}
/// <summary>Returns the node data as a plain dictionary.</summary>
public Dictionary<string, object> AsObjectDictionary()
{
return Data?.ToObject<Dictionary<string, object>>() ?? new Dictionary<string, object>();
}
internal Task CompleteAsync(string handle, CancellationToken cancellationToken = default)
=> _service.CompleteNodeAsync(RunId, NodeId, handle, cancellationToken);
internal void Complete(string handle)
{
_ = CompleteAsync(handle);
}
internal Task AddProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
=> _service.UpdateProgressAsync(RunId, NodeId, counterKey, amount, cancellationToken);
}
@@ -1,193 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Core;
public sealed partial class ScenarioService
{
private void DispatchActiveNode(RuntimeRun run, ActiveNodeState state, bool restored)
{
var node = FindNode(run.Plan, state.NodeId);
if (node == null)
{
run.ActiveNodes.Remove(state.NodeId);
CheckRunCompleted(run);
return;
}
var context = new ScenarioNodeContext(this, ToPlanRun(run), node);
switch (node.Type ?? string.Empty)
{
case WaitNode:
DispatchWait(run, state, context);
break;
case RemoteConfigOverrideNode:
DispatchRemoteConfigOverride(run, state, context);
break;
case NotificationNode:
EmitNotification(context);
break;
case StoreNode:
EmitStoreOffer(context);
break;
case QuestNode:
EmitQuest(context);
break;
case LeaderboardNode:
EmitLeaderboard(context);
break;
case BattlePassNode:
EmitBattlePass(context);
break;
case BattlePassLevelNode:
EmitBattlePassLevel(context);
break;
default:
// Unsupported node type — fail the run (surfaced via OnScenarioFailed)
// instead of leaving it stalled on a node no handler will complete.
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Unsupported scenario node type '{node.Type}' ({node.Id}).");
FailRun(run, state.NodeId, new Exception($"Unsupported scenario node type '{node.Type}'"));
break;
}
}
private void DispatchWait(RuntimeRun run, ActiveNodeState state, ScenarioNodeContext context)
{
if (!state.WaitDeadlineUtc.HasValue)
{
// Prefer server-provided WaitDeadline from the plan boundary over local calculation.
// The server stamps WaitDeadline on server-enforced wait boundaries (see StampBoundaries).
var boundary = run.Plan.BoundaryNodes?.FirstOrDefault(
b => b.SourceNodeId == state.NodeId && b.WaitDeadline.HasValue
);
if (boundary?.WaitDeadline is { } parsed)
{
state.WaitDeadlineUtc = parsed;
}
else
{
var delay = GetWaitDelay(context.Data);
state.WaitDeadlineUtc = _client.Clock.UtcNow.Add(delay);
}
Persist();
}
var session = new WaitSession(context, state.WaitDeadlineUtc.Value);
OnWait?.Invoke(session);
if (_client.Clock.UtcNow >= state.WaitDeadlineUtc.Value)
_ = CompleteNodeAsync(run.RunId, state.NodeId, "onComplete");
}
private void DispatchRemoteConfigOverride(RuntimeRun run, ActiveNodeState state, ScenarioNodeContext context)
{
var patches = context.Data["patches"] as JArray;
if (patches != null)
{
foreach (var patchToken in patches.OfType<JObject>())
{
var key = patchToken.Value<string>("path");
if (string.IsNullOrEmpty(key))
continue;
var valueType = patchToken.Value<string>("valueType") ?? "json";
var value = SerializeRemoteConfigValue(patchToken["value"], valueType);
_client.RemoteConfig.ApplyOverride(key, value, valueType);
}
}
var session = new ConfigChangedSession(context);
OnConfigChanged?.Invoke(session);
_ = CompleteNodeAsync(run.RunId, state.NodeId, "output");
}
private void EmitNotification(ScenarioNodeContext context)
{
OnNotification?.Invoke(new NotificationSession(context));
}
private void EmitStoreOffer(ScenarioNodeContext context)
{
OnStoreOffer?.Invoke(new StoreOfferSession(context));
}
private void EmitQuest(ScenarioNodeContext context)
{
OnQuest?.Invoke(new QuestSession(context));
}
private void EmitLeaderboard(ScenarioNodeContext context)
{
OnLeaderboard?.Invoke(new LeaderboardSession(context));
}
private void EmitBattlePass(ScenarioNodeContext context)
{
OnBattlePass?.Invoke(new BattlePassSession(context, _client.BattlePass));
}
private void EmitBattlePassLevel(ScenarioNodeContext context)
{
OnBattlePassLevel?.Invoke(new BattlePassLevelSession(context));
}
private static TimeSpan GetWaitDelay(JObject data)
{
var duration = data.Value<double?>("duration") ?? 0;
var unit = data.Value<string>("unit") ?? "seconds";
if (duration <= 0)
return TimeSpan.Zero;
switch (unit)
{
case "days":
case "day":
case "d":
return TimeSpan.FromDays(duration);
case "hours":
case "hour":
case "hr":
case "h":
return TimeSpan.FromHours(duration);
case "minutes":
case "minute":
case "min":
case "m":
return TimeSpan.FromMinutes(duration);
case "seconds":
case "second":
case "sec":
case "s":
return TimeSpan.FromSeconds(duration);
default:
return TimeSpan.FromSeconds(duration);
}
}
private static string? SerializeRemoteConfigValue(JToken? token, string valueType)
{
if (token == null || token.Type == JTokenType.Null)
return null;
switch ((valueType ?? string.Empty).ToLowerInvariant())
{
case "string":
return token.Type == JTokenType.String ? token.Value<string>() : token.ToString(Formatting.None);
case "bool":
case "boolean":
return token.Value<bool>().ToString().ToLowerInvariant();
case "int":
case "integer":
return token.Value<long>().ToString(CultureInfo.InvariantCulture);
case "float":
case "double":
return token.Value<double>().ToString(CultureInfo.InvariantCulture);
default:
return token.Type == JTokenType.String ? token.Value<string>() : token.ToString(Formatting.None);
}
}
}
@@ -1,13 +0,0 @@
namespace RudderSdk.Core;
public sealed partial class ScenarioService
{
private const string NotificationNode = "notification";
private const string StoreNode = "store";
private const string WaitNode = "wait";
private const string RemoteConfigOverrideNode = "remote_config_override";
private const string QuestNode = "quest";
private const string LeaderboardNode = "leaderboard";
private const string BattlePassNode = "battlepass";
private const string BattlePassLevelNode = "battlepass_level";
}
@@ -1,92 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
public sealed partial class ScenarioService
{
private void Persist()
{
var store = _client.Options.PlanStateStore;
if (store == null)
return;
if (_runs.Count == 0)
{
store.State = null;
return;
}
var state = new PersistedScenarioState
{
Runs = _runs.Values.Select(run => run.ToPersisted()).ToList()
};
store.State = JsonConvert.SerializeObject(state);
}
private sealed class RuntimeRun
{
public RuntimeRun(string runId, ExecutionPlan plan)
{
RunId = runId;
Plan = plan;
}
public string RunId { get; }
public ExecutionPlan Plan { get; }
public Dictionary<string, ActiveNodeState> ActiveNodes { get; } = new();
public HashSet<string> CompletedHandles { get; } = new(StringComparer.Ordinal);
public PersistedRun ToPersisted()
{
return new PersistedRun
{
RunId = RunId,
Plan = Plan,
ActiveNodes = ActiveNodes.Values.ToList(),
CompletedHandles = CompletedHandles.ToList()
};
}
public static RuntimeRun FromPersisted(PersistedRun saved)
{
var run = new RuntimeRun(saved.RunId, saved.Plan);
if (saved.ActiveNodes != null)
{
foreach (var node in saved.ActiveNodes)
{
if (!string.IsNullOrEmpty(node?.NodeId))
run.ActiveNodes[node.NodeId] = node;
}
}
if (saved.CompletedHandles != null)
{
foreach (var handle in saved.CompletedHandles)
run.CompletedHandles.Add(handle);
}
return run;
}
}
private sealed class PersistedScenarioState
{
public List<PersistedRun>? Runs { get; set; }
}
private sealed class PersistedRun
{
public string RunId { get; set; } = string.Empty;
public ExecutionPlan Plan { get; set; } = null!;
public List<ActiveNodeState>? ActiveNodes { get; set; }
public List<string>? CompletedHandles { get; set; }
}
private sealed class ActiveNodeState
{
public string NodeId { get; set; } = string.Empty;
public DateTimeOffset? WaitDeadlineUtc { get; set; }
}
}
@@ -1,348 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core;
/// <summary>
/// Thrown when a boundary HTTP call fails with a transient error
/// (network failure, timeout, or server 5xx). The caller should NOT advance
/// the run; the node stays active and the handle stays pending for retry.
/// </summary>
public sealed class TransientBoundaryException : Exception
{
/// <summary>Creates the exception.</summary>
public TransientBoundaryException(string message, Exception inner)
: base(message, inner)
{
}
/// <summary>Creates the exception wrapping the transport error.</summary>
public TransientBoundaryException(Exception inner)
: base($"Transient boundary error: {inner.Message}", inner)
{
}
}
public sealed partial class ScenarioService
{
/// <summary>
/// Returns true for exceptions that may succeed on retry
/// (timeout / network failures).
/// </summary>
private static bool IsTransientException(Exception ex)
{
return ex is OperationCanceledException || ex is RudderNetworkException;
}
private static bool IsRankNotEligible(Exception ex)
{
return ex is RudderApiException api && api.Code == "rank_not_eligible";
}
private IReadOnlyList<PlanRun> StartPlans(IEnumerable<ExecutionPlan>? plans)
{
var started = new List<PlanRun>();
if (plans == null)
return started;
foreach (var plan in plans)
{
var run = StartPlan(plan);
if (run != null)
started.Add(ToPlanRun(run));
}
return started;
}
private RuntimeRun? StartPlan(ExecutionPlan? plan)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return null;
// Dedup: server returned same runId — skip without restarting.
if (!string.IsNullOrEmpty(plan.RunId) && _runs.ContainsKey(plan.RunId))
return null;
if (plan.BoundaryNodes?.Count > 0 && string.IsNullOrEmpty(plan.RunId))
throw new InvalidOperationException("ExecutionPlan has boundaryNodes but missing RunId.");
var runId = plan.RunId ?? Guid.NewGuid().ToString("N");
var startNode = FindNode(plan, plan.StartNodeId) ?? plan.Nodes[0];
var run = new RuntimeRun(runId, plan);
_runs[run.RunId] = run;
ActivateNode(run, startNode.Id);
Persist();
return run;
}
/// <summary>
/// Replaces an existing run's plan with a server-provided continuation (same RunId):
/// the previous segment is done, the new segment's start node becomes active.
/// Idempotent: if the continuation was already applied, nothing is re-dispatched.
/// </summary>
private void ReplaceRun(ExecutionPlan? plan, string? fallbackRunId = null)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return;
var runId = plan.RunId ?? fallbackRunId;
if (string.IsNullOrEmpty(runId))
return;
var startNode = FindNode(plan, plan.StartNodeId) ?? plan.Nodes[0];
if (startNode == null)
return;
if (_runs.TryGetValue(runId, out var existing) && existing.ActiveNodes.ContainsKey(startNode.Id))
return; // continuation already applied (idempotent callback echo)
var run = new RuntimeRun(runId, plan);
_runs[runId] = run;
ActivateNode(run, startNode.Id);
Persist();
}
private void ActivateNode(RuntimeRun run, string nodeId, ActiveNodeState? restoredState = null)
{
var node = FindNode(run.Plan, nodeId);
if (node == null)
return;
var state = restoredState ?? new ActiveNodeState { NodeId = nodeId };
run.ActiveNodes[nodeId] = state;
DispatchActiveNode(run, state, restored: restoredState != null);
}
internal Task CompleteNodeAsync(
string runId,
string nodeId,
string handle,
CancellationToken cancellationToken = default)
{
return CompleteNodeAsync(runId, nodeId, handle, continueOnBoundary: true, cancellationToken);
}
private async Task CompleteNodeAsync(
string runId,
string nodeId,
string handle,
bool continueOnBoundary,
CancellationToken cancellationToken = default)
{
if (!_runs.TryGetValue(runId, out var run))
return;
var key = CompletedHandleKey(nodeId, handle);
if (run.CompletedHandles.Contains(key))
return; // idempotent
try
{
var continuedOnBoundary = false;
if (continueOnBoundary)
{
try
{
continuedOnBoundary = await ContinueBoundaryAsync(
run, nodeId, handle, cancellationToken).ConfigureAwait(false);
}
catch (TransientBoundaryException)
{
// Transient error — don't advance the run.
// Node stays active, handle stays pending for retry on reconnect.
return;
}
}
if (continuedOnBoundary)
{
// The boundary may have replaced or removed the run (continuation plan
// or reconcile). The transferred run owns the state — touching the stale
// object here would complete or delete the new run.
if (!_runs.TryGetValue(runId, out var currentRun) || !ReferenceEquals(currentRun, run))
return;
}
// Only now — after the server has confirmed — mark the handle and node.
run.CompletedHandles.Add(key);
run.ActiveNodes.Remove(nodeId);
Persist();
if (!continuedOnBoundary)
{
foreach (var edge in MatchingEdges(run.Plan, nodeId, handle))
ActivateNode(run, edge.Target);
}
CheckRunCompleted(run);
Persist();
}
catch (Exception ex)
{
if (IsRankNotEligible(ex))
throw;
FailRun(run, nodeId, ex);
}
}
private async Task<bool> ContinueBoundaryAsync(
RuntimeRun run,
string nodeId,
string handle,
CancellationToken cancellationToken)
{
var boundaries = MatchingBoundaryNodes(run.Plan, nodeId, handle).ToList();
if (boundaries.Count == 0)
return false;
foreach (var boundary in boundaries)
{
try
{
var response = await _client.SendAsync<HandleScenarioCallbackRequest, HandleScenarioCallbackResponse>(
"POST",
"/sdk/v1/scenarios/callback",
new HandleScenarioCallbackRequest
{
ScenarioId = run.Plan.ScenarioId,
NodeId = boundary.SourceNodeId,
Handle = boundary.SourceHandle,
RunId = run.RunId
},
cancellationToken).ConfigureAwait(false);
if (response?.Plan != null)
{
// Continuation of the current run (server keeps the RunId) replaces
// the run's plan; fresh/terminal plans start as new runs.
if (_runs.ContainsKey(response.Plan.RunId ?? string.Empty))
ReplaceRun(response.Plan);
else
StartPlan(response.Plan);
}
}
catch (Exception ex)
{
// Boundary call failed — try to reconcile with server.
var reconciled = false;
try
{
var reconcile = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
"POST",
"/sdk/v1/scenarios/run",
new GetScenarioRunRequest { RunId = run.RunId },
cancellationToken).ConfigureAwait(false);
if (reconcile?.Status == "unknown_run" || reconcile?.Status == "expired")
{
_runs.Remove(run.RunId);
Persist();
reconciled = true;
}
if (reconcile?.Plan != null)
{
ReplaceRun(reconcile.Plan, run.RunId);
reconciled = true;
}
}
catch
{
// Reconciliation also failed.
}
if (!reconciled)
{
// Reconcile did not resolve — distinguish transient from terminal.
if (IsTransientException(ex))
throw new TransientBoundaryException(ex);
// Terminal error — let the caller fail the run.
throw;
}
// If reconciled, the boundary was handled (run corrected or removed).
// Fall through to continue to the next boundary.
}
}
return true;
}
private void CheckRunCompleted(RuntimeRun run)
{
if (run.ActiveNodes.Count > 0)
return;
_runs.Remove(run.RunId);
Persist();
OnScenarioCompleted?.Invoke(ToPlanRun(run));
}
private void FailRun(RuntimeRun run, string nodeId, Exception ex)
{
_client.Options.Logger?.Log(RudderLogLevel.Error, $"[Rudder] Scenario run {run.RunId} failed at node {nodeId}: {ex.Message}");
_runs.Remove(run.RunId);
Persist();
OnScenarioFailed?.Invoke(new ScenarioFailedEvent(ToPlanRun(run), nodeId, ex));
}
private static PlanRun ToPlanRun(RuntimeRun run)
{
return new PlanRun(
run.RunId,
run.Plan.PlanId,
run.Plan.ScenarioId,
run.Plan.UserId,
run.ActiveNodes.Keys.ToList(),
run.Plan);
}
private static ExecutionPlanNode? FindNode(ExecutionPlan? plan, string? nodeId)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return null;
if (!string.IsNullOrEmpty(nodeId))
{
foreach (var node in plan.Nodes)
{
if (node.Id == nodeId)
return node;
}
}
return null;
}
private static IEnumerable<PlanEdge> MatchingEdges(ExecutionPlan plan, string sourceNodeId, string sourceHandle)
{
if (plan?.Edges == null)
yield break;
foreach (var edge in plan.Edges)
{
if (edge.Source == sourceNodeId && string.Equals(edge.SourceHandle ?? string.Empty, sourceHandle ?? string.Empty, StringComparison.Ordinal))
yield return edge;
}
}
private static IEnumerable<BoundaryNode> MatchingBoundaryNodes(ExecutionPlan plan, string sourceNodeId, string sourceHandle)
{
if (plan?.BoundaryNodes == null)
yield break;
foreach (var boundary in plan.BoundaryNodes)
{
if (boundary.SourceNodeId == sourceNodeId && string.Equals(boundary.SourceHandle ?? string.Empty, sourceHandle ?? string.Empty, StringComparison.Ordinal))
yield return boundary;
}
}
private static string CompletedHandleKey(string nodeId, string? handle) => nodeId + ":" + (handle ?? string.Empty);
}
@@ -1,32 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>
/// Session of a scenario battle-pass-level node — a single claimable tier.
/// <see cref="ClaimAsync"/> crosses onComplete, which the server accepts only
/// once the player has reached the node's configured level.
/// </summary>
public sealed class BattlePassLevelSession
{
internal BattlePassLevelSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>The tier level this node claims.</summary>
public int Level => Context.Get("levelNumber", 0);
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Claims this tier; crosses onComplete (the server checks the level was reached).</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
/// <summary>Claims this tier (fire-and-forget).</summary>
public void Claim() => Context.Complete("onComplete");
}
@@ -1,95 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Models.BattlePass;
namespace RudderSdk.Core;
/// <summary>
/// Session of a scenario battle-pass node. Exposes the battle pass operations
/// bound to this node's scenario/node/run ids, plus explicit boundary crossings
/// the game drives from its UI; the server validates each crossing.
/// </summary>
public sealed class BattlePassSession
{
private readonly BattlePassService _battlePass;
internal BattlePassSession(ScenarioNodeContext context, BattlePassService battlePass)
{
Context = context;
_battlePass = battlePass;
}
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Reads current progress (xp, level, premium ownership, claimed tiers) for this node.</summary>
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken cancellationToken = default)
=> _battlePass.GetProgressAsync(Context.ScenarioId, Context.NodeId, cancellationToken);
/// <summary>Credits xp from a configured source.</summary>
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken cancellationToken = default)
=> _battlePass.AddXpAsync(new AddBattlePassXpRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
Source = source,
Amount = amount
}, cancellationToken);
/// <summary>
/// Claims a tier reward at a reached level.
/// <paramref name="track"/> is <see cref="BattlePassService.TrackFree"/> or <see cref="BattlePassService.TrackPremium"/>.
/// </summary>
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken cancellationToken = default)
=> _battlePass.ClaimRewardAsync(new ClaimBattlePassRewardRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
Level = level,
Track = track
}, cancellationToken);
/// <summary>Purchases the premium track, then crosses onPremiumPurchase on success.</summary>
public async Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken cancellationToken = default)
{
var response = await _battlePass.PurchasePremiumAsync(new PurchaseBattlePassPremiumRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
IdempotencyKey = Guid.NewGuid().ToString()
}, cancellationToken).ConfigureAwait(false);
if (response != null && response.Success == true)
await Context.CompleteAsync("onPremiumPurchase", cancellationToken).ConfigureAwait(false);
return response!;
}
/// <summary>Advances the run through the onLevelUp handle.</summary>
public Task LevelUpAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onLevelUp", cancellationToken);
/// <summary>Advances the run through the onLevelUp handle (fire-and-forget).</summary>
public void LevelUp() => Context.Complete("onLevelUp");
/// <summary>Advances the run through the onMaxLevel handle.</summary>
public Task MaxLevelAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onMaxLevel", cancellationToken);
/// <summary>Advances the run through the onMaxLevel handle (fire-and-forget).</summary>
public void MaxLevel() => Context.Complete("onMaxLevel");
/// <summary>Advances the run through the onComplete handle.</summary>
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
/// <summary>Advances the run through the onComplete handle (fire-and-forget).</summary>
public void Complete() => Context.Complete("onComplete");
}
@@ -1,13 +0,0 @@
namespace RudderSdk.Core;
/// <summary>
/// Session of a scenario remote-config-override node. The patches are already
/// applied to <see cref="RemoteConfigService"/> when the event fires.
/// </summary>
public sealed class ConfigChangedSession
{
internal ConfigChangedSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
}
@@ -1,40 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>Session of a scenario leaderboard node.</summary>
public sealed class LeaderboardSession
{
internal LeaderboardSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Advances the run through the onEnd handle.</summary>
public Task EndAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onEnd", cancellationToken);
/// <summary>Advances the run through the onEnd handle (fire-and-forget).</summary>
public void End() => Context.Complete("onEnd");
/// <summary>Advances the run through the onClaim handle. The server matches live rank to a place.</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onClaim", cancellationToken);
/// <summary>Advances the run through the onClaim handle (fire-and-forget).</summary>
public void Claim() => Context.Complete("onClaim");
/// <summary>Advances the run through the onClaim handle.</summary>
[Obsolete("Use ClaimAsync. Removed in the next SDK version.")]
public Task RewardClaimedAsync(CancellationToken cancellationToken = default) => ClaimAsync(cancellationToken);
/// <summary>Advances the run through the onClaim handle (fire-and-forget).</summary>
[Obsolete("Use Claim. Removed in the next SDK version.")]
public void RewardClaimed() => Claim();
}
@@ -1,25 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>Session of a scenario notification node.</summary>
public sealed class NotificationSession
{
internal NotificationSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Advances the run through the output handle.</summary>
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("output", cancellationToken);
/// <summary>Advances the run through the output handle (fire-and-forget).</summary>
public void Complete() => Context.Complete("output");
}
@@ -1,37 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>Session of a scenario quest node.</summary>
public sealed class QuestSession
{
internal QuestSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Adds progress to one of the node's counters.</summary>
public Task AddProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default) => Context.AddProgressAsync(counterKey, amount, cancellationToken);
/// <summary>Adds progress to one of the node's counters (fire-and-forget).</summary>
public void AddProgress(string counterKey, long amount) => _ = AddProgressAsync(counterKey, amount);
/// <summary>Advances the run through the onComplete handle.</summary>
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
/// <summary>Advances the run through the onComplete handle (fire-and-forget).</summary>
public void Complete() => Context.Complete("onComplete");
/// <summary>Advances the run through the onFail handle.</summary>
public Task FailAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onFail", cancellationToken);
/// <summary>Advances the run through the onFail handle (fire-and-forget).</summary>
public void Fail() => Context.Complete("onFail");
}
@@ -1,56 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>Session of a scenario store-offer node; resolve it with a purchase or a decline.</summary>
public sealed class StoreOfferSession
{
internal StoreOfferSession(ScenarioNodeContext context)
{
Context = context;
Data = context.AsObjectDictionary();
}
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Node data payload.</summary>
public IReadOnlyDictionary<string, object> Data { get; }
/// <summary>True after the session was resolved once.</summary>
public bool IsResolved { get; private set; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Resolves the offer as purchased.</summary>
public Task PurchaseAsync(CancellationToken cancellationToken = default) => ResolveAsync("onPurchase", cancellationToken);
/// <summary>Resolves the offer as purchased (fire-and-forget).</summary>
public void Purchase() => Resolve("onPurchase");
/// <summary>Resolves the offer as declined.</summary>
public Task DeclineAsync(CancellationToken cancellationToken = default) => ResolveAsync("onDecline", cancellationToken);
/// <summary>Resolves the offer as declined (fire-and-forget).</summary>
public void Decline() => Resolve("onDecline");
private async Task ResolveAsync(string handle, CancellationToken cancellationToken)
{
if (IsResolved) return;
IsResolved = true;
await Context.CompleteAsync(handle, cancellationToken).ConfigureAwait(false);
}
private void Resolve(string handle)
{
if (IsResolved) return;
IsResolved = true;
Context.Complete(handle);
}
}
@@ -1,19 +0,0 @@
using System;
namespace RudderSdk.Core;
/// <summary>Session of a scenario wait node; the run continues automatically at the deadline.</summary>
public sealed class WaitSession
{
internal WaitSession(ScenarioNodeContext context, DateTimeOffset deadlineUtc)
{
Context = context;
DeadlineUtc = deadlineUtc;
}
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>When the wait ends (UTC).</summary>
public DateTimeOffset DeadlineUtc { get; }
}