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) 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}"); } } }