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;
///
/// Scenario runtime. starts server-issued plans;
/// active nodes surface as typed sessions through the On* events and are
/// advanced by completing those sessions.
///
public sealed partial class ScenarioService
{
private readonly RudderClient _client;
private readonly Dictionary _runs = new();
/// Raised for a notification node.
public event Action? OnNotification;
/// Raised for a store-offer node.
public event Action? OnStoreOffer;
/// Raised for a leaderboard node.
public event Action? OnLeaderboard;
/// Raised for a remote-config-override node, after the patches were applied.
public event Action? OnConfigChanged;
/// Raised for a wait node.
public event Action? OnWait;
/// Raised for a quest node.
public event Action? OnQuest;
/// Raised for a battle-pass node.
public event Action? OnBattlePass;
/// Raised for a battle-pass-level node.
public event Action? OnBattlePassLevel;
/// Raised when a run finishes all its nodes.
public event Action? OnScenarioCompleted;
/// Raised when a run dies on an unrecoverable error.
public event Action? OnScenarioFailed;
internal ScenarioService(RudderClient client) => _client = client;
/// True while at least one run is active.
public bool IsRunning => _runs.Count > 0;
/// First active node id across the runs, or null.
public string? CurrentNodeId => _runs.Values.FirstOrDefault()?.ActiveNodes.Keys.FirstOrDefault();
/// Snapshots of the active runs.
public IReadOnlyList ActiveRuns => _runs.Values.Select(ToPlanRun).ToList();
///
/// Triggers scenarios by event name and starts the plans the server
/// returns. Returns the runs this call started.
///
public async Task> TriggerAsync(string eventName, CancellationToken cancellationToken = default)
{
var response = await _client.SendAsync(
"POST",
"/sdk/v1/scenarios/trigger",
new TriggerScenarioRequest { Event = eventName },
cancellationToken).ConfigureAwait(false);
return StartPlans(response?.Plans);
}
///
/// Restores persisted runs, reconciles them with the server and re-dispatches
/// active nodes. Call once after startup, after login.
///
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(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(
"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();
}
}
/// Drops all runs and the persisted state.
public void Clear()
{
_runs.Clear();
Persist();
}
/// Completes wait nodes whose deadline passed; call every frame.
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");
}
}
}
/// Completes the first active node with the given handle.
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);
}
/// Completes the first active node with the given handle (fire-and-forget).
public void Respond(string handle)
{
_ = RespondAsync(handle);
}
/// Adds progress to a counter of the first active node.
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(
"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}");
}
}
}