239 lines
8.6 KiB
C#
239 lines
8.6 KiB
C#
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.
|
|
/// </summary>
|
|
public sealed partial 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.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<PlanRun>> TriggerAsync(string eventName, CancellationToken cancellationToken = default)
|
|
{
|
|
var response = await _client.SendAsync<TriggerScenarioRequest, TriggerScenarioResponse>(
|
|
"POST",
|
|
"/sdk/v1/scenarios/trigger",
|
|
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);
|
|
|
|
if (response?.Plan != null)
|
|
StartPlan(response.Plan);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
FailRun(run, nodeId, ex);
|
|
}
|
|
}
|
|
}
|