Initial commit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RudderSdk.Core.Models.Auth;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication: device login, explicit token refresh and logout.
|
||||
/// Token refresh also happens automatically — see <see cref="RudderClient"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
|
||||
internal AuthService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the session state flips: after a successful login
|
||||
/// (<see cref="RudderAuthState.SignedIn"/>) and after logout or a failed
|
||||
/// token refresh (<see cref="RudderAuthState.SignedOut"/>).
|
||||
/// </summary>
|
||||
public event Action<RudderAuthState>? AuthStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Signs the player in with the device identifier. The returned token pair
|
||||
/// is stored in the configured token store.
|
||||
/// </summary>
|
||||
public async Task<LoginViaDeviceResponse> LoginWithDeviceAsync(
|
||||
string region,
|
||||
string language,
|
||||
string? nickname = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _client.SendAsync<LoginViaDeviceRequest, LoginViaDeviceResponse>(
|
||||
"POST",
|
||||
"/sdk/v1/authorization/device",
|
||||
new LoginViaDeviceRequest
|
||||
{
|
||||
Key = _client.ProjectKey,
|
||||
DeviceId = _client.DeviceIdProvider.DeviceId,
|
||||
Region = region,
|
||||
Language = language,
|
||||
Nickname = nickname!
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response == null || string.IsNullOrEmpty(response.AccessToken) || string.IsNullOrEmpty(response.RefreshToken))
|
||||
throw new InvalidOperationException("The server returned an incomplete session.");
|
||||
|
||||
_client.TokenStore.SaveTokens(response.AccessToken, response.RefreshToken);
|
||||
NotifySignedIn();
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges the stored refresh token for a new token pair. Concurrent
|
||||
/// callers share one refresh request. Returns false when the session could
|
||||
/// not be renewed; the stored tokens are cleared in that case.
|
||||
/// </summary>
|
||||
public Task<bool> RefreshAsync() => _client.RefreshTokensAsync();
|
||||
|
||||
/// <summary>Drops the stored session.</summary>
|
||||
public void Logout()
|
||||
{
|
||||
_client.TokenStore.Clear();
|
||||
NotifySignedOut();
|
||||
}
|
||||
|
||||
internal void NotifySignedIn() => AuthStateChanged?.Invoke(RudderAuthState.SignedIn);
|
||||
|
||||
internal void NotifySignedOut() => AuthStateChanged?.Invoke(RudderAuthState.SignedOut);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RudderSdk.Core.Models.BattlePass;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Battle pass progress and rewards. The simple overloads target the project's
|
||||
/// default battle pass; the request-based overloads carry the scenario node
|
||||
/// context and are used by the scenario runtime.
|
||||
/// </summary>
|
||||
public sealed class BattlePassService
|
||||
{
|
||||
/// <summary>Free reward track, see <see cref="ClaimRewardAsync(int, string, CancellationToken)"/>.</summary>
|
||||
public const string TrackFree = "free";
|
||||
|
||||
/// <summary>Premium reward track, see <see cref="ClaimRewardAsync(int, string, CancellationToken)"/>.</summary>
|
||||
public const string TrackPremium = "premium";
|
||||
|
||||
private readonly RudderClient _client;
|
||||
|
||||
internal BattlePassService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>Reads current progress: xp, level, premium ownership, claimed tiers.</summary>
|
||||
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken cancellationToken = default)
|
||||
=> GetProgressAsync(new GetBattlePassProgressRequest(), cancellationToken);
|
||||
|
||||
/// <summary>Credits xp and returns the new xp/level and level-up flags.</summary>
|
||||
public Task<AddBattlePassXpResponse> AddXpAsync(long amount, CancellationToken cancellationToken = default)
|
||||
=> AddXpAsync(new AddBattlePassXpRequest { Amount = amount }, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Claims a tier reward at a reached level (idempotent server-side).
|
||||
/// <paramref name="track"/> is <see cref="TrackFree"/> or <see cref="TrackPremium"/>.
|
||||
/// </summary>
|
||||
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken cancellationToken = default)
|
||||
=> ClaimRewardAsync(new ClaimBattlePassRewardRequest { Level = level, Track = track }, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Purchases the premium track (charges the wallet). When
|
||||
/// <paramref name="idempotencyKey"/> is null, a random one is generated.
|
||||
/// </summary>
|
||||
public Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(string? idempotencyKey = null, CancellationToken cancellationToken = default)
|
||||
=> PurchasePremiumAsync(new PurchaseBattlePassPremiumRequest
|
||||
{
|
||||
IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString()
|
||||
}, cancellationToken);
|
||||
|
||||
internal Task<GetBattlePassProgressResponse> GetProgressAsync(GetBattlePassProgressRequest request, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<GetBattlePassProgressRequest, GetBattlePassProgressResponse>(
|
||||
"POST",
|
||||
"/sdk/v1/battlepass/progress",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
internal Task<AddBattlePassXpResponse> AddXpAsync(AddBattlePassXpRequest request, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<AddBattlePassXpRequest, AddBattlePassXpResponse>(
|
||||
"POST",
|
||||
"/sdk/v1/battlepass/xp",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
internal Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(ClaimBattlePassRewardRequest request, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse>(
|
||||
"POST",
|
||||
"/sdk/v1/battlepass/claim",
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
internal Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(PurchaseBattlePassPremiumRequest request, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse>(
|
||||
"POST",
|
||||
"/sdk/v1/battlepass/premium",
|
||||
request,
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RudderSdk.Core.Models.Inventory;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>Player inventory.</summary>
|
||||
public sealed class InventoryService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
|
||||
internal InventoryService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>Lists the items the player owns.</summary>
|
||||
public async Task<IReadOnlyList<PlayerInventoryItem>> GetAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _client.SendAsync<GetInventoryResponse>(
|
||||
"GET",
|
||||
"/sdk/v1/inventory",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response?.Items ?? new List<PlayerInventoryItem>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RudderSdk.Core.Models.Leaderboards;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Leaderboards. Resolve a board with <see cref="FindBySlug"/> and work through
|
||||
/// the returned <see cref="LeaderboardHandle"/>.
|
||||
/// </summary>
|
||||
public sealed class LeaderboardsService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
private readonly Dictionary<string, LeaderboardHandle> _cache = new();
|
||||
|
||||
internal LeaderboardsService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>Returns the (cached) handle for the leaderboard with the given slug.</summary>
|
||||
public LeaderboardHandle FindBySlug(string slug)
|
||||
{
|
||||
if (!_cache.TryGetValue(slug, out var leaderboard))
|
||||
{
|
||||
leaderboard = new LeaderboardHandle(slug, this);
|
||||
_cache[slug] = leaderboard;
|
||||
}
|
||||
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
internal Task<GetRankingResponse> GetRankingAsync(string slug, int limit, CancellationToken cancellationToken)
|
||||
=> _client.SendAsync<GetRankingResponse>(
|
||||
"GET",
|
||||
"/sdk/v1/leaderboards/" + Url.Encode(slug) + "/ranking" + Url.Query(("limit", limit > 0 ? limit.ToString() : null)),
|
||||
cancellationToken);
|
||||
|
||||
internal Task SubmitScoreAsync(string slug, double score, CancellationToken cancellationToken)
|
||||
=> _client.SendAsync(
|
||||
"POST",
|
||||
"/sdk/v1/leaderboards/" + Url.Encode(slug) + "/submit-score",
|
||||
new SubmitScoreRequest { Slug = slug, Score = score },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Operations on one leaderboard.</summary>
|
||||
public sealed class LeaderboardHandle
|
||||
{
|
||||
private readonly LeaderboardsService _service;
|
||||
|
||||
internal LeaderboardHandle(string slug, LeaderboardsService service)
|
||||
{
|
||||
Slug = slug;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>Leaderboard slug.</summary>
|
||||
public string Slug { get; }
|
||||
|
||||
/// <summary>Entries fetched by the last <see cref="ListAsync"/> call.</summary>
|
||||
public IReadOnlyList<RankEntry> Entries { get; private set; } = new List<RankEntry>();
|
||||
|
||||
/// <summary>Submits a score for the current player.</summary>
|
||||
public Task SubmitAsync(double score, CancellationToken cancellationToken = default)
|
||||
=> _service.SubmitScoreAsync(Slug, score, cancellationToken);
|
||||
|
||||
/// <summary>Fetches the top entries and caches them in <see cref="Entries"/>.</summary>
|
||||
public async Task<IReadOnlyList<RankEntry>> ListAsync(int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _service.GetRankingAsync(Slug, limit, cancellationToken).ConfigureAwait(false);
|
||||
Entries = response?.Entries ?? new List<RankEntry>();
|
||||
return Entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RudderSdk.Core.Models.Player;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>Player profile data.</summary>
|
||||
public sealed class PlayerService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
|
||||
internal PlayerService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>Returns the player profile with wallets.</summary>
|
||||
public Task<PlayerProfile> GetProfileAsync(CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<PlayerProfile>("GET", "/sdk/v1/player/information", cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RudderSdk.Core.Models.Quests;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Global quests (list + claim), distinct from scenario quest nodes which
|
||||
/// advance through <see cref="QuestSession"/>.
|
||||
/// </summary>
|
||||
public sealed class QuestsService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
|
||||
internal QuestsService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>Lists the player's quests with per-objective progress and rewards.</summary>
|
||||
public async Task<IReadOnlyList<Quest>> ListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _client.SendAsync<ListQuestsRequest, ListQuestsResponse>(
|
||||
"POST",
|
||||
"/sdk/v1/quests/list",
|
||||
new ListQuestsRequest(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response?.Quests ?? new List<Quest>();
|
||||
}
|
||||
|
||||
/// <summary>Claims a completed quest's rewards (idempotent server-side).</summary>
|
||||
public Task<ClaimQuestResponse> ClaimAsync(string questId, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<ClaimQuestRequest, ClaimQuestResponse>(
|
||||
"POST",
|
||||
"/sdk/v1/quests/claim",
|
||||
new ClaimQuestRequest { QuestId = questId },
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using RudderSdk.Core.Models.RemoteConfig;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Remote configuration values. <see cref="LoadAsync"/> caches every config
|
||||
/// whose "active" flag is not explicitly false; <see cref="Get{T}"/> reads
|
||||
/// typed values from the cache.
|
||||
/// </summary>
|
||||
public sealed class RemoteConfigService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
private readonly Dictionary<string, RemoteConfig> _cache = new();
|
||||
private bool _isLoaded;
|
||||
|
||||
internal RemoteConfigService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>True after the first successful <see cref="LoadAsync"/>.</summary>
|
||||
public bool IsLoaded => _isLoaded;
|
||||
|
||||
/// <summary>The cached configs keyed by config key.</summary>
|
||||
public IReadOnlyDictionary<string, RemoteConfig> Configs => _cache;
|
||||
|
||||
/// <summary>Fetches all configs and rebuilds the cache.</summary>
|
||||
public async Task<IReadOnlyDictionary<string, RemoteConfig>> LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _client.SendAsync<RawListRemoteConfigsResponse>(
|
||||
"GET",
|
||||
"/sdk/v1/remote-configs",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_cache.Clear();
|
||||
if (response?.Configs != null)
|
||||
{
|
||||
foreach (var entry in response.Configs)
|
||||
{
|
||||
// Include everything except an explicit "active": false — a missing
|
||||
// flag means active, matching the other SDKs.
|
||||
if (entry.Value?["active"]?.Value<bool?>() == false)
|
||||
continue;
|
||||
|
||||
var config = entry.Value?.ToObject<RemoteConfig>();
|
||||
if (config != null)
|
||||
_cache[config.Key ?? entry.Key] = config;
|
||||
}
|
||||
}
|
||||
|
||||
_isLoaded = true;
|
||||
return _cache;
|
||||
}
|
||||
|
||||
/// <summary>Fetches one config straight from the server, bypassing the cache.</summary>
|
||||
public async Task<RemoteConfig> GetConfigAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _client.SendAsync<RemoteConfig>(
|
||||
"GET",
|
||||
"/sdk/v1/remote-configs/" + Url.Encode(key),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Reads a typed value from the cache; returns the default when missing or unparsable.</summary>
|
||||
public T Get<T>(string key, T defaultValue = default!)
|
||||
{
|
||||
if (!_isLoaded || string.IsNullOrEmpty(key) || !_cache.TryGetValue(key, out var config))
|
||||
return defaultValue;
|
||||
|
||||
return ParseValue(config.Value, config.ValueType, defaultValue);
|
||||
}
|
||||
|
||||
/// <summary>Loads the cache on first use, then reads a typed value.</summary>
|
||||
public async Task<T> GetAsync<T>(string key, T defaultValue = default!, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_isLoaded)
|
||||
await LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Get(key, defaultValue);
|
||||
}
|
||||
|
||||
internal void ApplyOverride(string key, string? value, string valueType = "json")
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return;
|
||||
_cache[key] = new RemoteConfig
|
||||
{
|
||||
Key = key,
|
||||
// The generated model declares Value non-nullable; a JSON null patch
|
||||
// value is still legal and ParseValue maps it to the caller's default.
|
||||
Value = value!,
|
||||
ValueType = valueType,
|
||||
Active = true
|
||||
};
|
||||
_isLoaded = true;
|
||||
}
|
||||
|
||||
private static T ParseValue<T>(string value, string valueType, T defaultValue)
|
||||
{
|
||||
if (value == null) return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
if (typeof(T) == typeof(string))
|
||||
return (T)(object)value;
|
||||
|
||||
switch ((valueType ?? string.Empty).ToLowerInvariant())
|
||||
{
|
||||
case "number":
|
||||
case "int":
|
||||
case "integer":
|
||||
case "float":
|
||||
case "double":
|
||||
return (T)ConvertNumber(value, typeof(T));
|
||||
case "bool":
|
||||
case "boolean":
|
||||
return (T)(object)bool.Parse(value);
|
||||
case "json":
|
||||
case "object":
|
||||
return JsonConvert.DeserializeObject<T>(value) ?? defaultValue;
|
||||
default:
|
||||
return (T)System.Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static object ConvertNumber(string value, Type target)
|
||||
{
|
||||
if (target == typeof(float))
|
||||
return float.Parse(value, CultureInfo.InvariantCulture);
|
||||
if (target == typeof(double))
|
||||
return double.Parse(value, CultureInfo.InvariantCulture);
|
||||
if (target == typeof(long))
|
||||
return long.Parse(value, CultureInfo.InvariantCulture);
|
||||
if (target == typeof(decimal))
|
||||
return decimal.Parse(value, CultureInfo.InvariantCulture);
|
||||
return int.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// Raw shape of the list payload: the generated RemoteConfig.Active is a
|
||||
// non-nullable bool and cannot distinguish "active": false from a missing
|
||||
// flag, so the active check runs on the raw JSON before mapping.
|
||||
private sealed class RawListRemoteConfigsResponse
|
||||
{
|
||||
[JsonProperty("configs")]
|
||||
public Dictionary<string, JObject>? Configs { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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:
|
||||
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Unsupported scenario node type '{node.Type}' ({node.Id}).");
|
||||
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 && !string.IsNullOrEmpty(b.WaitDeadline)
|
||||
);
|
||||
if (boundary != null && DateTimeOffset.TryParse(boundary.WaitDeadline, null, DateTimeStyles.RoundtripKind, out var 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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
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 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>Session of a scenario battle-pass-level node.</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>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 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");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>Session of a scenario battle-pass node.</summary>
|
||||
public sealed class BattlePassSession
|
||||
{
|
||||
internal BattlePassSession(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 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 onPremiumPurchase handle.</summary>
|
||||
public Task PremiumPurchaseAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onPremiumPurchase", cancellationToken);
|
||||
|
||||
/// <summary>Advances the run through the onPremiumPurchase handle (fire-and-forget).</summary>
|
||||
public void PremiumPurchase() => Context.Complete("onPremiumPurchase");
|
||||
|
||||
/// <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");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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 onRewardClaimed handle.</summary>
|
||||
public Task RewardClaimedAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onRewardClaimed", cancellationToken);
|
||||
|
||||
/// <summary>Advances the run through the onRewardClaimed handle (fire-and-forget).</summary>
|
||||
public void RewardClaimed() => Context.Complete("onRewardClaimed");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RudderSdk.Core.Models.Storage;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>Player key-value storage.</summary>
|
||||
public sealed class StorageService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
|
||||
internal StorageService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>Fetches one page of storage items, optionally filtered by type.</summary>
|
||||
public Task<GetStorageResponse> GetAsync(string? type = null, int limit = 100, string? cursor = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = Url.Query(
|
||||
("types", type),
|
||||
("limit", limit > 0 ? limit.ToString() : null),
|
||||
("cursor", cursor));
|
||||
|
||||
return _client.SendAsync<GetStorageResponse>("GET", "/sdk/v1/storage" + query, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Iterates over all storage items, following the cursor pagination.</summary>
|
||||
public async IAsyncEnumerable<StorageItem> ListAllAsync(
|
||||
string? type = null,
|
||||
int limit = 100,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? cursor = null;
|
||||
do
|
||||
{
|
||||
var page = await GetAsync(type, limit, cursor, cancellationToken).ConfigureAwait(false);
|
||||
if (page?.Items != null)
|
||||
{
|
||||
foreach (var item in page.Items)
|
||||
yield return item;
|
||||
}
|
||||
|
||||
cursor = page?.NextCursor;
|
||||
}
|
||||
while (!string.IsNullOrEmpty(cursor));
|
||||
}
|
||||
|
||||
/// <summary>Saves (upserts) storage items.</summary>
|
||||
public Task SaveAsync(IEnumerable<StorageItem> items, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync(
|
||||
"PUT",
|
||||
"/sdk/v1/storage",
|
||||
new UpdateStorageRequest { Items = items?.ToList() ?? new List<StorageItem>() },
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>Deletes all items of the given type.</summary>
|
||||
public Task DeleteAsync(string type, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync("DELETE", "/sdk/v1/storage" + Url.Query(("type", type)), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RudderSdk.Core.Models.Stores;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>In-game stores and offer purchases.</summary>
|
||||
public sealed class StoresService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
|
||||
internal StoresService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>Lists the available stores with their offers.</summary>
|
||||
public async Task<IReadOnlyList<Store>> ListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _client.SendAsync<ListStoresResponse>(
|
||||
"GET",
|
||||
"/sdk/v1/stores",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response?.Stores ?? new List<Store>();
|
||||
}
|
||||
|
||||
/// <summary>Resolves one store by slug.</summary>
|
||||
public Task<Store> GetAsync(string slug, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<Store>("GET", "/sdk/v1/stores/" + Url.Encode(slug), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Purchases an offer (charges the wallet). When
|
||||
/// <paramref name="idempotencyKey"/> is null, a random one is generated;
|
||||
/// pass a stable key to make retries safe.
|
||||
/// </summary>
|
||||
public Task<PurchaseOfferResponse> PurchaseAsync(
|
||||
string storeSlug,
|
||||
string offerId,
|
||||
string? idempotencyKey = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _client.SendAsync<PurchaseOfferRequest, PurchaseOfferResponse>(
|
||||
"POST",
|
||||
"/sdk/v1/stores/" + Url.Encode(storeSlug) + "/offers/" + Url.Encode(offerId) + "/purchase",
|
||||
new PurchaseOfferRequest
|
||||
{
|
||||
StoreSlug = storeSlug,
|
||||
OfferId = offerId,
|
||||
IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString()
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using RudderSdk.Core.Models.Ugc;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
/// <summary>User-generated content: upload, list, download URLs, deletion.</summary>
|
||||
public sealed class UgcService
|
||||
{
|
||||
private readonly RudderClient _client;
|
||||
|
||||
internal UgcService(RudderClient client) => _client = client;
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file in one call: pre-signed URL, PUT of the bytes, submission.
|
||||
/// Requires an upload transport in <see cref="RudderClientOptions.UploadTransport"/>.
|
||||
/// </summary>
|
||||
public async Task<UgcSubmission> UploadAsync(
|
||||
string name,
|
||||
byte[] data,
|
||||
string? description = null,
|
||||
JToken? metadata = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var uploadTransport = _client.Options.UploadTransport
|
||||
?? throw new InvalidOperationException("UploadTransport is not configured.");
|
||||
|
||||
var upload = await GetUploadUrlAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
await uploadTransport.PutAsync(
|
||||
upload.UploadUrl,
|
||||
data ?? Array.Empty<byte>(),
|
||||
GetContentType(name),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await SubmitAsync(
|
||||
name,
|
||||
upload.FileKey,
|
||||
data?.LongLength ?? 0,
|
||||
description,
|
||||
metadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Fetches one page of the player's submissions.</summary>
|
||||
public Task<ListUgcResponse> ListAsync(string? status = null, int limit = 20, string? cursor = null, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<ListUgcResponse>(
|
||||
"GET",
|
||||
"/sdk/v1/ugc" + Url.Query(
|
||||
("status", status),
|
||||
("limit", limit > 0 ? limit.ToString() : null),
|
||||
("cursor", cursor)),
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>Iterates over all of the player's submissions, following the cursor pagination.</summary>
|
||||
public async IAsyncEnumerable<UgcSubmission> ListAllAsync(
|
||||
string? status = null,
|
||||
int limit = 20,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? cursor = null;
|
||||
do
|
||||
{
|
||||
var page = await ListAsync(status, limit, cursor, cancellationToken).ConfigureAwait(false);
|
||||
if (page?.Items != null)
|
||||
{
|
||||
foreach (var item in page.Items)
|
||||
yield return item;
|
||||
}
|
||||
|
||||
cursor = page?.NextCursor;
|
||||
}
|
||||
while (!string.IsNullOrEmpty(cursor));
|
||||
}
|
||||
|
||||
/// <summary>Returns one submission.</summary>
|
||||
public Task<UgcSubmission> GetAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<UgcSubmission>("GET", "/sdk/v1/ugc/" + Url.Encode(id), cancellationToken);
|
||||
|
||||
/// <summary>Deletes a submission; returns false when the server refused.</summary>
|
||||
public async Task<bool> DeleteAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _client.SendAsync<DeleteUgcResponse>(
|
||||
"DELETE",
|
||||
"/sdk/v1/ugc/" + Url.Encode(id),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response == null || response.Success;
|
||||
}
|
||||
|
||||
/// <summary>Requests a pre-signed upload URL.</summary>
|
||||
public Task<GetUploadUrlResponse> GetUploadUrlAsync(string filename, CancellationToken cancellationToken = default)
|
||||
=> _client.SendAsync<GetUploadUrlResponse>(
|
||||
"GET",
|
||||
"/sdk/v1/ugc/upload-url" + Url.Query(("filename", filename)),
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the download URL of a submission.
|
||||
/// Throws <see cref="InvalidOperationException"/> when the server returned none.
|
||||
/// </summary>
|
||||
public async Task<string> GetDownloadUrlAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _client.SendAsync<GetDownloadUrlResponse>(
|
||||
"GET",
|
||||
"/sdk/v1/ugc/" + Url.Encode(id) + "/download",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response == null || string.IsNullOrEmpty(response.DownloadUrl))
|
||||
throw new InvalidOperationException("The server returned no download URL.");
|
||||
|
||||
return response.DownloadUrl;
|
||||
}
|
||||
|
||||
/// <summary>Registers an uploaded file as a submission.</summary>
|
||||
public Task<UgcSubmission> SubmitAsync(
|
||||
string name,
|
||||
string fileKey,
|
||||
long fileSize,
|
||||
string? description = null,
|
||||
JToken? metadata = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _client.SendAsync<SubmitUgcRequest, UgcSubmission>(
|
||||
"POST",
|
||||
"/sdk/v1/ugc",
|
||||
new SubmitUgcRequest
|
||||
{
|
||||
Name = name,
|
||||
FileKey = fileKey,
|
||||
FileSize = fileSize,
|
||||
Description = description!,
|
||||
Metadata = metadata!
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string GetContentType(string filename)
|
||||
{
|
||||
var ext = System.IO.Path.GetExtension(filename)?.ToLowerInvariant();
|
||||
switch (ext)
|
||||
{
|
||||
case ".png": return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg": return "image/jpeg";
|
||||
case ".gif": return "image/gif";
|
||||
case ".webp": return "image/webp";
|
||||
case ".txt": return "text/plain";
|
||||
default: return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace RudderSdk.Core;
|
||||
|
||||
internal static class Url
|
||||
{
|
||||
public static string Encode(string? value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||
|
||||
public static string Query(params (string Key, string? Value)[] values)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var item in values)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Value))
|
||||
parts.Add(Encode(item.Key) + "=" + Encode(item.Value));
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user