Initial commit

This commit is contained in:
rudder
2026-08-12 14:04:55 +03:00
commit 8cf738d9f0
124 changed files with 5433 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
root = true
[*.cs]
charset = utf-8
# Generated models (apigen, "DO NOT EDIT") carry no nullable annotations and no
# XML docs. The warnings are silenced here instead of editing generated files;
# the proper fix belongs in apigen (emit nullable annotations / docs).
[{Auth,BattlePass,Catalog,Inventory,Leaderboards,Player,Quests,RemoteConfig,Scenarios,Storage,Stores,Ugc}/**.cs]
dotnet_diagnostic.CS8618.severity = none
dotnet_diagnostic.CS1591.severity = none
[{BoundaryNode,ErrorResponse,ExecutionPlan,ExecutionPlanNode,PlanEdge,RudderErrorCodes}.cs]
dotnet_diagnostic.CS8618.severity = none
dotnet_diagnostic.CS1591.severity = none
+5
View File
@@ -0,0 +1,5 @@
bin/
obj/
.vs/
.idea/
TestResults/
+10
View File
@@ -0,0 +1,10 @@
using System;
namespace RudderSdk.Core.Abstractions;
/// <summary>Time source used by the scenario runtime; override in tests.</summary>
public interface IClock
{
/// <summary>Current UTC time.</summary>
DateTimeOffset UtcNow { get; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Provides a stable device identifier for device login.
/// The default is <see cref="GuidDeviceIdProvider"/>; games should provide a
/// persistent one (Unity uses SystemInfo.deviceUniqueIdentifier).
/// </summary>
public interface IDeviceIdProvider
{
/// <summary>Stable identifier of this device/install.</summary>
string DeviceId { get; }
}
+12
View File
@@ -0,0 +1,12 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core.Abstractions;
/// <summary>Optional scheduler abstraction for delayed plan work.</summary>
public interface IPlanScheduler
{
/// <summary>Completes after the given delay.</summary>
Task ScheduleAsync(TimeSpan delay, CancellationToken cancellationToken = default);
}
+11
View File
@@ -0,0 +1,11 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Persists serialized scenario-run state between app launches.
/// Null by default (no persistence).
/// </summary>
public interface IPlanStateStore
{
/// <summary>Serialized state blob, or null when empty.</summary>
string? State { get; set; }
}
+36
View File
@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Low-level realtime (websocket) transport. There is no default implementation;
/// provide one through <see cref="IRealtimeTransportFactory"/> (Unity ships its own).
/// </summary>
public interface IRealtimeTransport
{
/// <summary>Raised when the connection closes.</summary>
event Action Closed;
/// <summary>Raised for every incoming message.</summary>
event Action<ArraySegment<byte>> Received;
/// <summary>Raised on transport errors.</summary>
event Action<Exception> Error;
/// <summary>True while the connection is open.</summary>
bool IsConnected { get; }
/// <summary>Opens the connection, giving up after <paramref name="timeout"/>.</summary>
Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default);
/// <summary>Sends one message.</summary>
Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default);
/// <summary>Closes the connection.</summary>
Task CloseAsync(CancellationToken cancellationToken = default);
/// <summary>Pumps time-dependent logic; call every frame.</summary>
void Update(float deltaTime);
}
@@ -0,0 +1,8 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>Creates realtime transports on demand (one per connection).</summary>
public interface IRealtimeTransportFactory
{
/// <summary>Creates a new, unconnected transport.</summary>
IRealtimeTransport Create();
}
+24
View File
@@ -0,0 +1,24 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>Severity of an SDK log message.</summary>
public enum RudderLogLevel
{
/// <summary>Verbose diagnostics.</summary>
Debug,
/// <summary>Normal operational messages.</summary>
Info,
/// <summary>Recoverable problems worth investigating.</summary>
Warning,
/// <summary>Failures that broke an operation.</summary>
Error
}
/// <summary>Optional sink for SDK diagnostics. Null by default (silent).</summary>
public interface IRudderLogger
{
/// <summary>Writes one message at the given severity.</summary>
void Log(RudderLogLevel level, string message);
}
+28
View File
@@ -0,0 +1,28 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// HTTP transport used by the SDK for every API call. Implementations serialize
/// the request as JSON, attach the bearer token, and map non-success HTTP
/// statuses to <see cref="RudderApiException"/> subclasses
/// (401 → <see cref="RudderAuthException"/>).
/// </summary>
public interface IRudderTransport
{
/// <summary>
/// Sends one API request and deserializes the JSON response.
/// </summary>
/// <param name="method">HTTP method (GET, POST, PUT, DELETE).</param>
/// <param name="path">Absolute path starting at the API root, e.g. /sdk/v1/player/information.</param>
/// <param name="request">Request body DTO, or null for bodyless requests.</param>
/// <param name="accessToken">Current access token to send as a Bearer header, or null.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<TResponse> SendAsync<TRequest, TResponse>(
string method,
string path,
TRequest? request,
string? accessToken,
CancellationToken cancellationToken = default);
}
+20
View File
@@ -0,0 +1,20 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Persists the session token pair. The default is <see cref="InMemoryTokenStore"/>;
/// games should provide a durable implementation (player prefs, keychain, ...).
/// </summary>
public interface ITokenStore
{
/// <summary>Returns the current access token, or null when signed out.</summary>
string? GetAccessToken();
/// <summary>Returns the current refresh token, or null when signed out.</summary>
string? GetRefreshToken();
/// <summary>Stores a fresh token pair.</summary>
void SaveTokens(string accessToken, string refreshToken);
/// <summary>Drops both tokens (logout / session expiry).</summary>
void Clear();
}
+14
View File
@@ -0,0 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Uploads raw bytes to pre-signed UGC URLs. Optional; required only by
/// <see cref="UgcService.UploadAsync"/>.
/// </summary>
public interface IUploadTransport
{
/// <summary>PUTs the payload to the pre-signed URL with the given content type.</summary>
Task PutAsync(string url, byte[] data, string contentType, CancellationToken cancellationToken = default);
}
+25
View File
@@ -0,0 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Auth;
public class LoginViaDeviceRequest
{
[JsonProperty("deviceId")]
public string DeviceId { get; set; }
[JsonProperty("key")]
public string Key { get; set; }
[JsonProperty("language")]
public string Language { get; set; }
[JsonProperty("nickname")]
public string Nickname { get; set; }
[JsonProperty("region")]
public string Region { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Auth;
public class LoginViaDeviceResponse
{
[JsonProperty("accessToken")]
public string AccessToken { get; set; }
[JsonProperty("refreshToken")]
public string RefreshToken { get; set; }
}
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Auth;
public class RefreshAccessTokenRequest
{
[JsonProperty("refreshToken")]
public string RefreshToken { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Auth;
public class RefreshAccessTokenResponse
{
[JsonProperty("accessToken")]
public string AccessToken { get; set; }
[JsonProperty("refreshToken")]
public string RefreshToken { get; set; }
}
+25
View File
@@ -0,0 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
public class AddBattlePassXpRequest
{
[JsonProperty("amount")]
public long Amount { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("source")]
public string Source { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.BattlePass;
public class AddBattlePassXpResponse
{
[JsonProperty("level")]
public int Level { get; set; }
[JsonProperty("leveledUp")]
public bool LeveledUp { get; set; }
[JsonProperty("maxLevel")]
public bool MaxLevel { get; set; }
[JsonProperty("plan")]
public ExecutionPlan Plan { get; set; }
[JsonProperty("xp")]
public long Xp { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
public class BattlePassReward
{
[JsonProperty("amount")]
public long Amount { get; set; }
[JsonProperty("currency")]
public string Currency { get; set; }
[JsonProperty("itemId")]
public string ItemId { get; set; }
}
@@ -0,0 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
public class ClaimBattlePassRewardRequest
{
[JsonProperty("level")]
public int Level { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("track")]
public string Track { get; set; }
}
@@ -0,0 +1,22 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
public class ClaimBattlePassRewardResponse
{
[JsonProperty("alreadyClaimed")]
public bool AlreadyClaimed { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
[JsonProperty("granted")]
public List<BattlePassReward> Granted { get; set; }
[JsonProperty("success")]
public bool Success { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
public class ClaimedTier
{
[JsonProperty("level")]
public int Level { get; set; }
[JsonProperty("track")]
public string Track { get; set; }
}
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
public class GetBattlePassProgressRequest
{
[JsonProperty("nodeId")]
public string NodeId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
}
@@ -0,0 +1,22 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
public class GetBattlePassProgressResponse
{
[JsonProperty("claimedTiers")]
public List<ClaimedTier> ClaimedTiers { get; set; }
[JsonProperty("level")]
public int Level { get; set; }
[JsonProperty("premiumOwned")]
public bool PremiumOwned { get; set; }
[JsonProperty("xp")]
public long Xp { get; set; }
}
@@ -0,0 +1,22 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
public class PurchaseBattlePassPremiumRequest
{
[JsonProperty("idempotencyKey")]
public string IdempotencyKey { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
}
@@ -0,0 +1,20 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.BattlePass;
public class PurchaseBattlePassPremiumResponse
{
[JsonProperty("error")]
public string Error { get; set; }
[JsonProperty("plan")]
public ExecutionPlan Plan { get; set; }
[JsonProperty("success")]
public bool Success { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models;
public class BoundaryNode
{
[JsonProperty("callbackUrl")]
public string CallbackUrl { get; set; }
[JsonProperty("enforcement")]
public string Enforcement { get; set; }
[JsonProperty("enteredAt")]
public string EnteredAt { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
[JsonProperty("sourceHandle")]
public string SourceHandle { get; set; }
[JsonProperty("sourceNodeId")]
public string SourceNodeId { get; set; }
[JsonProperty("waitDeadline")]
public string WaitDeadline { get; set; }
}
+76
View File
@@ -0,0 +1,76 @@
# Changelog
## 0.2.0
### Added
- `QuestsService` (`client.Quests`) with `ListAsync` / `ClaimAsync`.
- `AuthService.LoginWithDeviceAsync(region, language, nickname?)` and
`AuthService.RefreshAsync()`; `AuthStateChanged` event
(`RudderAuthState.SignedIn` / `SignedOut`).
- Session pipeline: single-flight token refresh plus one transparent
retry-on-401 for every API call; failed refresh clears tokens and reports
`SignedOut`.
- `HttpClientTransport` — default `System.Net.Http.HttpClient`-based
transport. `RudderClientOptions.Transport` is now optional, as are
`TokenStore` (`InMemoryTokenStore`) and `DeviceIdProvider`
(`GuidDeviceIdProvider`).
- Typed exceptions: `RudderAuthException` (401), `RudderNotFoundException`
(404), `RudderRateLimitException` (429), `RudderNetworkException`
(network/timeout). All carry `RequestId` from the server error payload.
- `StorageService.ListAllAsync` and `UgcService.ListAllAsync`
(`IAsyncEnumerable<T>` cursor pagination helpers).
- `RudderLogLevel` enum; `IRudderLogger.Log` now takes a level.
- Package metadata (`Rudder.Core`, MIT) and XML documentation.
### Changed (breaking)
- Generated models moved to domain folders/namespaces
(`RudderSdk.Core.Models.<Domain>`); the flat `RudderSdk.Core.DTO`
namespace is gone. `Battlepass*``BattlePass*`, `SdkQuest``Quest`.
- `BattlepassService``BattlePassService`, `client.Battlepass`
`client.BattlePass`.
- `client.Scenarios``client.Scenario`.
- `ScenarioService.SendAsync``TriggerAsync`, now returning the started
`PlanRun`s.
- Scenario events reduced to the canonical set: `OnNotification`,
`OnStoreOffer`, `OnLeaderboard`, `OnConfigChanged`, `OnWait`, `OnQuest`,
`OnBattlePass`, `OnBattlePassLevel`, `OnScenarioCompleted`,
`OnScenarioFailed`. Legacy events (`OnShowNotification`, `OnShowStore`,
`OnQuestEvent`, `OnBattlepassEvent`, `OnLeaderboardEvent`, `OnNodeEvent`,
`OnNode`, `OnCompleted`, `OnStore`, `OnRemoteConfigOverride`,
`OnRunCompleted`, `OnRunFailed`) are removed, along with the
`Services/Scenarios/Legacy` folder.
- Session types renamed: `StoreSession``StoreOfferSession`
(`BuyAsync`/`Buy``PurchaseAsync`/`Purchase`),
`RemoteConfigOverrideSession``ConfigChangedSession`,
`BattlepassSession``BattlePassSession`,
`BattlepassLevelSession``BattlePassLevelSession`,
`ScenarioRunFailedEvent``ScenarioFailedEvent`.
- `StoresService.BuyAsync` → `PurchaseAsync(storeSlug, offerId,
idempotencyKey = null)`; a null key is replaced with a generated GUID.
- `InventoryService.GetAsync` returns `IReadOnlyList<PlayerInventoryItem>`.
- `StoresService.ListAsync` returns `IReadOnlyList<Store>`.
- `QuestsService.ListAsync` returns `IReadOnlyList<Quest>`.
- `UgcService.GetDownloadUrlAsync` returns non-null `string` and throws
`InvalidOperationException` when the server returns no URL.
- `BattlePassService` simple overloads (`AddXpAsync(long amount)`,
`ClaimRewardAsync(level, track)`, `GetProgressAsync()`,
`PurchasePremiumAsync(idempotencyKey?)`); the request-DTO overloads are
internal for the scenario runtime.
- `LeaderboardsService.GetRankingAsync`/`SubmitScoreAsync` are internal;
use the `FindBySlug(slug)` handle (`SubmitAsync`, `ListAsync`).
- `AuthService.LoginViaDeviceAsync` removed; use `LoginWithDeviceAsync`.
- `ScenarioService.Restore()` (sync-over-async) removed; use `RestoreAsync`.
- `RealtimeService.ConnectAsync` and `IRealtimeTransport.ConnectAsync` take
`TimeSpan` timeouts instead of `int timeoutSeconds`.
- `IRudderTransport.SendAsync` takes a `string? accessToken` instead of
`RequestOptions`; `RequestOptions` is removed and `EmptyResponse` is
internal.
- `RudderClient` getters `Options`, `Transport`, `TokenStore`,
`DeviceIdProvider` are internal.
- `RudderApiException.StatusCode` is `int` (was `long`).
- `IRudderLogger.Log(string)` → `Log(RudderLogLevel level, string message)`.
- Nullable reference types enabled (`<Nullable>enable</Nullable>`).
- Remote config cache now includes every config except an explicit
`"active": false` (a missing flag means active).
+22
View File
@@ -0,0 +1,22 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Catalog;
public class CatalogItem
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("properties")]
public JToken Properties { get; set; }
[JsonProperty("slug")]
public string Slug { get; set; }
[JsonProperty("tags")]
public List<string> Tags { get; set; }
}
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Catalog;
public class ListCatalogItemsResponse
{
[JsonProperty("items")]
public List<CatalogItem> Items { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models;
public class ErrorResponse
{
[JsonProperty("code")]
public string Code { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
[JsonProperty("requestId")]
public string RequestId { get; set; }
}
+37
View File
@@ -0,0 +1,37 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models;
public class ExecutionPlan
{
[JsonProperty("boundaryNodes")]
public List<BoundaryNode> BoundaryNodes { get; set; }
[JsonProperty("context")]
public JToken Context { get; set; }
[JsonProperty("edges")]
public List<PlanEdge> Edges { get; set; }
[JsonProperty("nodes")]
public List<ExecutionPlanNode> Nodes { get; set; }
[JsonProperty("planId")]
public string PlanId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("startNodeId")]
public string StartNodeId { get; set; }
[JsonProperty("userId")]
public string UserId { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models;
public class ExecutionPlanNode
{
[JsonProperty("data")]
public JToken Data { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
}
+15
View File
@@ -0,0 +1,15 @@
using System;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Core;
/// <summary>
/// Default <see cref="IDeviceIdProvider"/> generating a random GUID per app run.
/// Every run looks like a fresh device to the backend; provide a persistent
/// implementation for production.
/// </summary>
public sealed class GuidDeviceIdProvider : IDeviceIdProvider
{
/// <inheritdoc />
public string DeviceId { get; } = Guid.NewGuid().ToString("N");
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
/// <summary>
/// Default <see cref="IRudderTransport"/> built on <see cref="HttpClient"/>.
/// Serializes bodies with Newtonsoft.Json, attaches the bearer token, maps
/// non-success statuses to <see cref="RudderApiException"/> subclasses and
/// network failures to <see cref="RudderNetworkException"/>.
/// </summary>
public sealed class HttpClientTransport : IRudderTransport
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private readonly string _baseUrl;
private readonly HttpClient _httpClient;
/// <summary>
/// Creates the transport for the given API base URL. When no
/// <paramref name="httpClient"/> is supplied, an owned instance with a
/// 10-second request timeout is created.
/// </summary>
public HttpClientTransport(string baseUrl, HttpClient? httpClient = null)
{
if (string.IsNullOrEmpty(baseUrl))
throw new ArgumentException("Base URL is required.", nameof(baseUrl));
_baseUrl = baseUrl.TrimEnd('/');
_httpClient = httpClient ?? new HttpClient { Timeout = DefaultTimeout };
}
/// <inheritdoc />
public async Task<TResponse> SendAsync<TRequest, TResponse>(
string method,
string path,
TRequest? request,
string? accessToken,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(new HttpMethod(method), _baseUrl + path);
if (!string.IsNullOrEmpty(accessToken))
httpRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
if (request is not null)
httpRequest.Content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
HttpResponseMessage response;
try
{
response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new RudderNetworkException("The request timed out.", ex);
}
catch (HttpRequestException ex)
{
throw new RudderNetworkException(ex.Message, ex);
}
using (response)
{
var body = response.Content is null
? null
: await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
throw MapError((int)response.StatusCode, response.ReasonPhrase, body);
if (string.IsNullOrEmpty(body))
return default!;
return JsonConvert.DeserializeObject<TResponse>(body)!;
}
}
private static RudderApiException MapError(int statusCode, string? reason, string? body)
{
string? code = null;
string? message = null;
string? requestId = null;
if (!string.IsNullOrEmpty(body))
{
try
{
var error = JsonConvert.DeserializeObject<ErrorResponse>(body);
code = error?.Code;
message = error?.Error;
requestId = error?.RequestId;
}
catch (JsonException)
{
// Non-JSON error body — fall back to the status line.
}
}
if (string.IsNullOrEmpty(message))
message = string.IsNullOrEmpty(reason) ? "HTTP " + statusCode : reason;
return statusCode switch
{
401 => new RudderAuthException(statusCode, code, message, requestId),
404 => new RudderNotFoundException(statusCode, code, message, requestId),
429 => new RudderRateLimitException(statusCode, code, message, requestId),
_ => new RudderApiException(statusCode, code, message, requestId),
};
}
}
+33
View File
@@ -0,0 +1,33 @@
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Core;
/// <summary>
/// Default <see cref="ITokenStore"/> keeping tokens in memory only. Sessions do
/// not survive an app restart; provide a durable implementation for production.
/// </summary>
public sealed class InMemoryTokenStore : ITokenStore
{
private string? _accessToken;
private string? _refreshToken;
/// <inheritdoc />
public string? GetAccessToken() => _accessToken;
/// <inheritdoc />
public string? GetRefreshToken() => _refreshToken;
/// <inheritdoc />
public void SaveTokens(string accessToken, string refreshToken)
{
_accessToken = accessToken;
_refreshToken = refreshToken;
}
/// <inheritdoc />
public void Clear()
{
_accessToken = null;
_refreshToken = null;
}
}
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Inventory;
public class GetInventoryResponse
{
[JsonProperty("items")]
public List<PlayerInventoryItem> Items { get; set; }
}
+25
View File
@@ -0,0 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Inventory;
public class PlayerInventoryItem
{
[JsonProperty("amount")]
public long Amount { get; set; }
[JsonProperty("nameOverride")]
public string NameOverride { get; set; }
[JsonProperty("propertiesOverride")]
public JToken PropertiesOverride { get; set; }
[JsonProperty("slug")]
public string Slug { get; set; }
[JsonProperty("updatedAt")]
public string UpdatedAt { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Leaderboards;
public class GetRankingResponse
{
[JsonProperty("entries")]
public List<RankEntry> Entries { get; set; }
[JsonProperty("total")]
public long Total { get; set; }
}
+22
View File
@@ -0,0 +1,22 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Leaderboards;
public class RankEntry
{
[JsonProperty("playerId")]
public string PlayerId { get; set; }
[JsonProperty("playerName")]
public string PlayerName { get; set; }
[JsonProperty("rank")]
public long Rank { get; set; }
[JsonProperty("score")]
public double Score { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Leaderboards;
public class SubmitScoreRequest
{
[JsonProperty("score")]
public double Score { get; set; }
[JsonProperty("slug")]
public string Slug { get; set; }
}
+25
View File
@@ -0,0 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models;
public class PlanEdge
{
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("source")]
public string Source { get; set; }
[JsonProperty("sourceHandle")]
public string SourceHandle { get; set; }
[JsonProperty("target")]
public string Target { get; set; }
[JsonProperty("targetHandle")]
public string TargetHandle { get; set; }
}
+28
View File
@@ -0,0 +1,28 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Player;
public class Player
{
[JsonProperty("createdAt")]
public string CreatedAt { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("language")]
public string Language { get; set; }
[JsonProperty("nickname")]
public string Nickname { get; set; }
[JsonProperty("projectId")]
public string ProjectId { get; set; }
[JsonProperty("region")]
public string Region { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Player;
public class PlayerProfile
{
[JsonProperty("player")]
public Player Player { get; set; }
[JsonProperty("wallets")]
public List<Wallet> Wallets { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Player;
public class Wallet
{
[JsonProperty("balance")]
public long Balance { get; set; }
[JsonProperty("currency")]
public string Currency { get; set; }
}
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Quests;
public class ClaimQuestRequest
{
[JsonProperty("questId")]
public string QuestId { get; set; }
}
+22
View File
@@ -0,0 +1,22 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Quests;
public class ClaimQuestResponse
{
[JsonProperty("alreadyClaimed")]
public bool AlreadyClaimed { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
[JsonProperty("granted")]
public List<QuestReward> Granted { get; set; }
[JsonProperty("success")]
public bool Success { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Quests;
public class ListQuestsRequest
{
}
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Quests;
public class ListQuestsResponse
{
[JsonProperty("quests")]
public List<Quest> Quests { get; set; }
}
+25
View File
@@ -0,0 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Quests;
public class Quest
{
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("objectives")]
public List<QuestObjectiveProgress> Objectives { get; set; }
[JsonProperty("rewards")]
public List<QuestReward> Rewards { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
}
+25
View File
@@ -0,0 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Quests;
public class QuestObjectiveProgress
{
[JsonProperty("completed")]
public bool Completed { get; set; }
[JsonProperty("current")]
public long Current { get; set; }
[JsonProperty("metric")]
public string Metric { get; set; }
[JsonProperty("objectiveId")]
public string ObjectiveId { get; set; }
[JsonProperty("target")]
public long Target { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Quests;
public class QuestReward
{
[JsonProperty("amount")]
public long Amount { get; set; }
[JsonProperty("currency")]
public string Currency { get; set; }
[JsonProperty("itemId")]
public string ItemId { get; set; }
}
+98
View File
@@ -0,0 +1,98 @@
# Rudder.Core
.NET client SDK for the Rudder LiveOps platform: authentication, player
profile, stores, battle pass, quests, leaderboards, inventory, remote config,
scenarios, storage, UGC and realtime.
- Target framework: `netstandard2.1` (works in Unity, .NET, Xamarin).
- JSON: Newtonsoft.Json.
- Naming follows the cross-SDK glossary: `.spec/sdk-glossary.md` in the
monorepo root.
## Install
```
dotnet add package Rudder.Core
```
## Quickstart
```csharp
using RudderSdk.Core;
var client = new RudderClient(new RudderClientOptions
{
BaseUrl = "https://api.example.com",
ProjectKey = "your-project-key"
});
// Sign in with the device id.
await client.Auth.LoginWithDeviceAsync(region: "en", language: "en");
// First call: read the player profile.
var profile = await client.Player.GetProfileAsync();
Console.WriteLine(profile.Player.Id);
// Buy an offer (idempotency key is generated when omitted).
var purchase = await client.Stores.PurchaseAsync("main-store", "offer-1");
// Pump time-dependent services (scenarios, realtime) every frame.
client.Update(deltaTime);
```
Only `BaseUrl` and `ProjectKey` are required. `Transport`,
`TokenStore` and `DeviceIdProvider` default to `HttpClientTransport`,
`InMemoryTokenStore` and `GuidDeviceIdProvider`; inject your own
implementations via `RudderClientOptions` for persistence or a custom HTTP
stack.
## Services
| Property | Service | Main operations |
|---|---|---|
| `Auth` | `AuthService` | `LoginWithDeviceAsync`, `RefreshAsync`, `Logout`, `AuthStateChanged` |
| `Player` | `PlayerService` | `GetProfileAsync` |
| `BattlePass` | `BattlePassService` | `GetProgressAsync`, `AddXpAsync`, `ClaimRewardAsync`, `PurchasePremiumAsync` |
| `Quests` | `QuestsService` | `ListAsync`, `ClaimAsync` |
| `Stores` | `StoresService` | `ListAsync`, `GetAsync`, `PurchaseAsync` |
| `Inventory` | `InventoryService` | `GetAsync` |
| `Leaderboards` | `LeaderboardsService` | `FindBySlug(slug)` → handle: `SubmitAsync`, `ListAsync` |
| `RemoteConfig` | `RemoteConfigService` | `LoadAsync`, `Get<T>`, `GetAsync<T>` |
| `Scenario` | `ScenarioService` | `TriggerAsync`, `RestoreAsync`, `On*` effect events |
| `Storage` | `StorageService` | `GetAsync`, `ListAllAsync`, `SaveAsync`, `DeleteAsync` |
| `Ugc` | `UgcService` | `UploadAsync`, `ListAsync`, `ListAllAsync`, `GetDownloadUrlAsync` |
| `Realtime` | `RealtimeService` | `ConnectAsync`, `DisconnectAsync` |
## Sessions
Every API call goes through the client's session pipeline: a 401 triggers a
single-flight token refresh and one transparent retry. When the refresh fails,
tokens are cleared and `Auth.AuthStateChanged` fires with
`RudderAuthState.SignedOut`.
## Errors
Transport maps failures to typed exceptions:
- `RudderAuthException` — HTTP 401
- `RudderNotFoundException` — HTTP 404
- `RudderRateLimitException` — HTTP 429
- `RudderNetworkException` — no response (connectivity, timeout)
- `RudderApiException` — base class, any other status
All of them carry `StatusCode` (`int`), the machine-readable `Code`
(see `RudderErrorCodes`) and the server-issued `RequestId` when available.
## Scenario effects
Subscribe to typed sessions on `client.Scenario`:
`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnConfigChanged`,
`OnWait`, `OnQuest`, `OnBattlePass`, `OnBattlePassLevel`,
`OnScenarioCompleted`, `OnScenarioFailed`.
## Tests
```
dotnet test tests/Rudder.Core.Tests
```
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.RemoteConfig;
public class ListRemoteConfigsResponse
{
[JsonProperty("configs")]
public Dictionary<string, RemoteConfig> Configs { get; set; }
}
+40
View File
@@ -0,0 +1,40 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.RemoteConfig;
public class RemoteConfig
{
[JsonProperty("active")]
public bool Active { get; set; }
[JsonProperty("createdAt")]
public string CreatedAt { get; set; }
[JsonProperty("description")]
public string Description { get; set; }
[JsonProperty("environment")]
public string Environment { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("key")]
public string Key { get; set; }
[JsonProperty("projectId")]
public string ProjectId { get; set; }
[JsonProperty("updatedAt")]
public string UpdatedAt { get; set; }
[JsonProperty("value")]
public string Value { get; set; }
[JsonProperty("valueType")]
public string ValueType { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<AssemblyName>Rudder.Core</AssemblyName>
<RootNamespace>RudderSdk.Core</RootNamespace>
<PackageId>Rudder.Core</PackageId>
<Version>0.2.0</Version>
<Authors>Rudder</Authors>
<Description>Rudder LiveOps client SDK for .NET: auth, player, stores, battle pass, quests, leaderboards, inventory, remote config, scenarios, storage, UGC and realtime.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<Compile Remove="tests/**" />
</ItemGroup>
</Project>
+39
View File
@@ -0,0 +1,39 @@
using System;
namespace RudderSdk.Core;
/// <summary>
/// Base exception for every API failure. Carries the HTTP status code
/// (0 for network failures, see <see cref="RudderNetworkException"/>), the
/// machine-readable API error code (see <see cref="RudderErrorCodes"/>) and the
/// server-issued request id when available.
/// </summary>
public class RudderApiException : Exception
{
/// <summary>HTTP status code, or 0 when the request never reached the server.</summary>
public int StatusCode { get; }
/// <summary>Machine-readable API error code, or an empty string.</summary>
public string Code { get; }
/// <summary>Server-issued request id for support tickets, or null.</summary>
public string? RequestId { get; }
/// <summary>Creates the exception.</summary>
public RudderApiException(int statusCode, string? code, string message, string? requestId = null)
: base(message)
{
StatusCode = statusCode;
Code = code ?? string.Empty;
RequestId = requestId;
}
/// <summary>Creates the exception wrapping an inner transport error.</summary>
public RudderApiException(int statusCode, string? code, string message, string? requestId, Exception? innerException)
: base(message, innerException)
{
StatusCode = statusCode;
Code = code ?? string.Empty;
RequestId = requestId;
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace RudderSdk.Core;
/// <summary>
/// The server rejected the request as unauthenticated (HTTP 401). The SDK
/// attempts a token refresh before surfacing this; seeing it means the session
/// is over and the player must sign in again.
/// </summary>
public sealed class RudderAuthException : RudderApiException
{
/// <summary>Creates the exception.</summary>
public RudderAuthException(int statusCode, string? code, string message, string? requestId = null)
: base(statusCode, code, message, requestId)
{
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace RudderSdk.Core;
/// <summary>Session state reported by <see cref="AuthService.AuthStateChanged"/>.</summary>
public enum RudderAuthState
{
/// <summary>A token pair is stored; API calls carry it.</summary>
SignedIn,
/// <summary>No valid session: logged out or the refresh token was rejected.</summary>
SignedOut
}
+239
View File
@@ -0,0 +1,239 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models.Auth;
namespace RudderSdk.Core;
/// <summary>
/// Entry point of the Rudder SDK. Composes all feature services around one
/// configuration (<see cref="RudderClientOptions"/>) and owns the session
/// lifecycle: every API call goes through here, a 401 triggers a single-flight
/// token refresh and one transparent retry.
/// </summary>
public sealed class RudderClient
{
private readonly object _refreshGate = new();
private Task<bool>? _pendingRefresh;
/// <summary>Authentication: device login, token refresh, logout.</summary>
public AuthService Auth { get; }
/// <summary>Player profile and wallets.</summary>
public PlayerService Player { get; }
/// <summary>Remote configuration values.</summary>
public RemoteConfigService RemoteConfig { get; }
/// <summary>Player key-value storage.</summary>
public StorageService Storage { get; }
/// <summary>In-game stores and offer purchases.</summary>
public StoresService Stores { get; }
/// <summary>Leaderboards.</summary>
public LeaderboardsService Leaderboards { get; }
/// <summary>Player inventory.</summary>
public InventoryService Inventory { get; }
/// <summary>Battle pass progress and rewards.</summary>
public BattlePassService BattlePass { get; }
/// <summary>Global quests.</summary>
public QuestsService Quests { get; }
/// <summary>User-generated content.</summary>
public UgcService Ugc { get; }
/// <summary>Scenario runtime: triggers, node sessions, persistence.</summary>
public ScenarioService Scenario { get; }
/// <summary>Realtime websocket channel.</summary>
public RealtimeService Realtime { get; }
internal RudderClientOptions Options { get; }
internal IRudderTransport Transport => Options.Transport!;
internal ITokenStore TokenStore => Options.TokenStore!;
internal IDeviceIdProvider DeviceIdProvider => Options.DeviceIdProvider!;
/// <summary>Project key from <see cref="RudderClientOptions.ProjectKey"/>.</summary>
public string ProjectKey => Options.ProjectKey!;
/// <summary>Realtime URL from <see cref="RudderClientOptions.RealtimeUrl"/>.</summary>
public string? RealtimeUrl => Options.RealtimeUrl;
/// <summary>Time source used by the scenario runtime.</summary>
public IClock Clock => Options.Clock ?? SystemClock.Instance;
/// <summary>
/// Creates the client. <see cref="RudderClientOptions.BaseUrl"/> and
/// <see cref="RudderClientOptions.ProjectKey"/> are required; transport,
/// token store and device id provider fall back to defaults.
/// </summary>
public RudderClient(RudderClientOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
if (string.IsNullOrEmpty(Options.BaseUrl)) throw new ArgumentException("BaseUrl is required.", nameof(options));
if (string.IsNullOrEmpty(Options.ProjectKey)) throw new ArgumentException("ProjectKey is required.", nameof(options));
Options.Transport ??= new HttpClientTransport(Options.BaseUrl);
Options.TokenStore ??= new InMemoryTokenStore();
Options.DeviceIdProvider ??= new GuidDeviceIdProvider();
Auth = new AuthService(this);
Player = new PlayerService(this);
RemoteConfig = new RemoteConfigService(this);
Storage = new StorageService(this);
Stores = new StoresService(this);
Leaderboards = new LeaderboardsService(this);
Inventory = new InventoryService(this);
Ugc = new UgcService(this);
Scenario = new ScenarioService(this);
BattlePass = new BattlePassService(this);
Quests = new QuestsService(this);
Realtime = new RealtimeService(this);
}
/// <summary>Pumps time-dependent services; call every frame.</summary>
public void Update(float deltaTime)
{
Scenario.Update(deltaTime);
Realtime.Update(deltaTime);
}
internal Task<TResponse> SendAsync<TRequest, TResponse>(
string method,
string path,
TRequest? request,
CancellationToken cancellationToken = default)
{
return SendWithRetryAsync<TRequest, TResponse>(method, path, request, cancellationToken);
}
internal Task<TResponse> SendAsync<TResponse>(
string method,
string path,
CancellationToken cancellationToken = default)
{
return SendWithRetryAsync<object, TResponse>(method, path, null, cancellationToken);
}
internal Task SendAsync<TRequest>(
string method,
string path,
TRequest? request,
CancellationToken cancellationToken = default)
{
return SendWithRetryAsync<TRequest, EmptyResponse>(method, path, request, cancellationToken);
}
internal Task SendAsync(
string method,
string path,
CancellationToken cancellationToken = default)
{
return SendWithRetryAsync<object, EmptyResponse>(method, path, null, cancellationToken);
}
private async Task<TResponse> SendWithRetryAsync<TRequest, TResponse>(
string method,
string path,
TRequest? request,
CancellationToken cancellationToken)
{
try
{
return await Transport
.SendAsync<TRequest, TResponse>(method, path, request, TokenStore.GetAccessToken(), cancellationToken)
.ConfigureAwait(false);
}
catch (RudderAuthException)
{
// Session rejected — refresh once (single-flight) and retry the call once.
if (!await RefreshTokensAsync().ConfigureAwait(false))
throw;
return await Transport
.SendAsync<TRequest, TResponse>(method, path, request, TokenStore.GetAccessToken(), cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>
/// Single-flight refresh: concurrent 401s share one refresh request.
/// Resolves to true when a new token pair was stored. On failure the tokens
/// are cleared and subscribers are notified via
/// <see cref="AuthService.AuthStateChanged"/>.
/// </summary>
internal Task<bool> RefreshTokensAsync()
{
lock (_refreshGate)
{
if (_pendingRefresh != null)
return _pendingRefresh;
var task = DoRefreshTokensAsync();
_pendingRefresh = task;
task.ContinueWith(
_ =>
{
lock (_refreshGate)
{
if (ReferenceEquals(_pendingRefresh, task))
_pendingRefresh = null;
}
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return task;
}
}
private async Task<bool> DoRefreshTokensAsync()
{
var refreshToken = TokenStore.GetRefreshToken();
if (!string.IsNullOrEmpty(refreshToken))
{
try
{
var response = await Transport
.SendAsync<RefreshAccessTokenRequest, RefreshAccessTokenResponse>(
"POST",
"/sdk/v1/authorization/refresh",
new RefreshAccessTokenRequest { RefreshToken = refreshToken },
null,
CancellationToken.None)
.ConfigureAwait(false);
if (response != null
&& !string.IsNullOrEmpty(response.AccessToken)
&& !string.IsNullOrEmpty(response.RefreshToken))
{
TokenStore.SaveTokens(response.AccessToken, response.RefreshToken);
return true;
}
}
catch
{
// Transport-level failure during refresh — session is over.
}
}
TokenStore.Clear();
Auth.NotifySignedOut();
return false;
}
private sealed class SystemClock : IClock
{
public static readonly SystemClock Instance = new();
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
}
internal sealed class EmptyResponse
{
}
+47
View File
@@ -0,0 +1,47 @@
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Core;
/// <summary>
/// Configuration for <see cref="RudderClient"/>. Only <see cref="BaseUrl"/> and
/// <see cref="ProjectKey"/> are required; everything else falls back to a
/// sensible default.
/// </summary>
public sealed class RudderClientOptions
{
/// <summary>API base URL, e.g. https://api.example.com. Required.</summary>
public string? BaseUrl { get; set; }
/// <summary>Realtime websocket URL. Required only for <see cref="RealtimeService"/>.</summary>
public string? RealtimeUrl { get; set; }
/// <summary>Project key issued in the admin panel. Required.</summary>
public string? ProjectKey { get; set; }
/// <summary>HTTP transport. Defaults to <see cref="HttpClientTransport"/>.</summary>
public IRudderTransport? Transport { get; set; }
/// <summary>Upload transport for UGC file uploads. Optional.</summary>
public IUploadTransport? UploadTransport { get; set; }
/// <summary>Session token storage. Defaults to <see cref="InMemoryTokenStore"/>.</summary>
public ITokenStore? TokenStore { get; set; }
/// <summary>Device identity for login. Defaults to <see cref="GuidDeviceIdProvider"/>.</summary>
public IDeviceIdProvider? DeviceIdProvider { get; set; }
/// <summary>Diagnostic sink. Null by default (silent).</summary>
public IRudderLogger? Logger { get; set; }
/// <summary>Time source for the scenario runtime; override in tests.</summary>
public IClock? Clock { get; set; }
/// <summary>Scenario-run persistence between app launches. Optional.</summary>
public IPlanStateStore? PlanStateStore { get; set; }
/// <summary>Optional scheduler for delayed plan work.</summary>
public IPlanScheduler? Scheduler { get; set; }
/// <summary>Realtime transport factory. Required only for <see cref="RealtimeService"/>.</summary>
public IRealtimeTransportFactory? RealtimeTransportFactory { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
// Code generated by apigen. DO NOT EDIT.
namespace RudderSdk.Core;
public static class RudderErrorCodes
{
public const string EarlyCompletion = "early_completion";
public const string Forbidden = "forbidden";
public const string LevelNotReached = "level_not_reached";
public const string NodeNotActive = "node_not_active";
public const string ObjectivesIncomplete = "objectives_incomplete";
public const string RunExpired = "run_expired";
public const string RunNotActive = "run_not_active";
public const string ScenarioNotActive = "scenario_not_active";
public const string UnknownRun = "unknown_run";
}
+17
View File
@@ -0,0 +1,17 @@
using System;
namespace RudderSdk.Core;
/// <summary>
/// The request never got a response: DNS/connectivity failure or client-side
/// timeout. <see cref="RudderApiException.StatusCode"/> is 0. Retrying is safe
/// for idempotent operations.
/// </summary>
public sealed class RudderNetworkException : RudderApiException
{
/// <summary>Creates the exception.</summary>
public RudderNetworkException(string message, Exception? innerException = null)
: base(0, string.Empty, message, null, innerException)
{
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace RudderSdk.Core;
/// <summary>The requested resource does not exist (HTTP 404).</summary>
public sealed class RudderNotFoundException : RudderApiException
{
/// <summary>Creates the exception.</summary>
public RudderNotFoundException(int statusCode, string? code, string message, string? requestId = null)
: base(statusCode, code, message, requestId)
{
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace RudderSdk.Core;
/// <summary>The client hit a server rate limit (HTTP 429); back off and retry later.</summary>
public sealed class RudderRateLimitException : RudderApiException
{
/// <summary>Creates the exception.</summary>
public RudderRateLimitException(int statusCode, string? code, string message, string? requestId = null)
: base(statusCode, code, message, requestId)
{
}
}
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Scenarios;
public class GetScenarioRunRequest
{
[JsonProperty("runId")]
public string RunId { get; set; }
}
+20
View File
@@ -0,0 +1,20 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.Scenarios;
public class GetScenarioRunResponse
{
[JsonProperty("plan")]
public ExecutionPlan Plan { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
}
@@ -0,0 +1,22 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Scenarios;
public class HandleScenarioCallbackRequest
{
[JsonProperty("handle")]
public string Handle { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
}
@@ -0,0 +1,14 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.Scenarios;
public class HandleScenarioCallbackResponse
{
[JsonProperty("plan")]
public ExecutionPlan Plan { get; set; }
}
+13
View File
@@ -0,0 +1,13 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Scenarios;
public class TriggerScenarioRequest
{
[JsonProperty("event")]
public string Event { get; set; }
}
+14
View File
@@ -0,0 +1,14 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.Scenarios;
public class TriggerScenarioResponse
{
[JsonProperty("plans")]
public List<ExecutionPlan> Plans { get; set; }
}
+25
View File
@@ -0,0 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Scenarios;
public class UpdateScenarioCounterRequest
{
[JsonProperty("amount")]
public long Amount { get; set; }
[JsonProperty("counterKey")]
public string CounterKey { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
}
@@ -0,0 +1,17 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.Scenarios;
public class UpdateScenarioCounterResponse
{
[JsonProperty("completed")]
public bool Completed { get; set; }
[JsonProperty("plan")]
public ExecutionPlan Plan { get; set; }
}
+73
View File
@@ -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);
}
+77
View File
@@ -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);
}
+25
View File
@@ -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>();
}
}
+73
View File
@@ -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;
}
}
+17
View File
@@ -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);
}
+37
View File
@@ -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);
}
+105
View File
@@ -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);
}
}
+155
View File
@@ -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; }
}
}
+238
View File
@@ -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);
}
}
}
+42
View File
@@ -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; }
}
+60
View File
@@ -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);
}
+53
View File
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More