Initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
TestResults/
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Models.Auth;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authentication: device login, explicit token refresh and logout.
|
||||||
|
/// Token refresh also happens automatically — see <see cref="RudderClient"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
|
||||||
|
internal AuthService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the session state flips: after a successful login
|
||||||
|
/// (<see cref="RudderAuthState.SignedIn"/>) and after logout or a failed
|
||||||
|
/// token refresh (<see cref="RudderAuthState.SignedOut"/>).
|
||||||
|
/// </summary>
|
||||||
|
public event Action<RudderAuthState>? AuthStateChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signs the player in with the device identifier. The returned token pair
|
||||||
|
/// is stored in the configured token store.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<LoginViaDeviceResponse> LoginWithDeviceAsync(
|
||||||
|
string region,
|
||||||
|
string language,
|
||||||
|
string? nickname = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<LoginViaDeviceRequest, LoginViaDeviceResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/authorization/device",
|
||||||
|
new LoginViaDeviceRequest
|
||||||
|
{
|
||||||
|
Key = _client.ProjectKey,
|
||||||
|
DeviceId = _client.DeviceIdProvider.DeviceId,
|
||||||
|
Region = region,
|
||||||
|
Language = language,
|
||||||
|
Nickname = nickname!
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response == null || string.IsNullOrEmpty(response.AccessToken) || string.IsNullOrEmpty(response.RefreshToken))
|
||||||
|
throw new InvalidOperationException("The server returned an incomplete session.");
|
||||||
|
|
||||||
|
_client.TokenStore.SaveTokens(response.AccessToken, response.RefreshToken);
|
||||||
|
NotifySignedIn();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges the stored refresh token for a new token pair. Concurrent
|
||||||
|
/// callers share one refresh request. Returns false when the session could
|
||||||
|
/// not be renewed; the stored tokens are cleared in that case.
|
||||||
|
/// </summary>
|
||||||
|
public Task<bool> RefreshAsync() => _client.RefreshTokensAsync();
|
||||||
|
|
||||||
|
/// <summary>Drops the stored session.</summary>
|
||||||
|
public void Logout()
|
||||||
|
{
|
||||||
|
_client.TokenStore.Clear();
|
||||||
|
NotifySignedOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void NotifySignedIn() => AuthStateChanged?.Invoke(RudderAuthState.SignedIn);
|
||||||
|
|
||||||
|
internal void NotifySignedOut() => AuthStateChanged?.Invoke(RudderAuthState.SignedOut);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Models.BattlePass;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Battle pass progress and rewards. The simple overloads target the project's
|
||||||
|
/// default battle pass; the request-based overloads carry the scenario node
|
||||||
|
/// context and are used by the scenario runtime.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BattlePassService
|
||||||
|
{
|
||||||
|
/// <summary>Free reward track, see <see cref="ClaimRewardAsync(int, string, CancellationToken)"/>.</summary>
|
||||||
|
public const string TrackFree = "free";
|
||||||
|
|
||||||
|
/// <summary>Premium reward track, see <see cref="ClaimRewardAsync(int, string, CancellationToken)"/>.</summary>
|
||||||
|
public const string TrackPremium = "premium";
|
||||||
|
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
|
||||||
|
internal BattlePassService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>Reads current progress: xp, level, premium ownership, claimed tiers.</summary>
|
||||||
|
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> GetProgressAsync(new GetBattlePassProgressRequest(), cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Credits xp and returns the new xp/level and level-up flags.</summary>
|
||||||
|
public Task<AddBattlePassXpResponse> AddXpAsync(long amount, CancellationToken cancellationToken = default)
|
||||||
|
=> AddXpAsync(new AddBattlePassXpRequest { Amount = amount }, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Claims a tier reward at a reached level (idempotent server-side).
|
||||||
|
/// <paramref name="track"/> is <see cref="TrackFree"/> or <see cref="TrackPremium"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken cancellationToken = default)
|
||||||
|
=> ClaimRewardAsync(new ClaimBattlePassRewardRequest { Level = level, Track = track }, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purchases the premium track (charges the wallet). When
|
||||||
|
/// <paramref name="idempotencyKey"/> is null, a random one is generated.
|
||||||
|
/// </summary>
|
||||||
|
public Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(string? idempotencyKey = null, CancellationToken cancellationToken = default)
|
||||||
|
=> PurchasePremiumAsync(new PurchaseBattlePassPremiumRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString()
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
internal Task<GetBattlePassProgressResponse> GetProgressAsync(GetBattlePassProgressRequest request, CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync<GetBattlePassProgressRequest, GetBattlePassProgressResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/battlepass/progress",
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
internal Task<AddBattlePassXpResponse> AddXpAsync(AddBattlePassXpRequest request, CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync<AddBattlePassXpRequest, AddBattlePassXpResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/battlepass/xp",
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
internal Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(ClaimBattlePassRewardRequest request, CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync<ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/battlepass/claim",
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
internal Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(PurchaseBattlePassPremiumRequest request, CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync<PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/battlepass/premium",
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Models.Inventory;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Player inventory.</summary>
|
||||||
|
public sealed class InventoryService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
|
||||||
|
internal InventoryService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>Lists the items the player owns.</summary>
|
||||||
|
public async Task<IReadOnlyList<PlayerInventoryItem>> GetAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<GetInventoryResponse>(
|
||||||
|
"GET",
|
||||||
|
"/sdk/v1/inventory",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return response?.Items ?? new List<PlayerInventoryItem>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Models.Leaderboards;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Leaderboards. Resolve a board with <see cref="FindBySlug"/> and work through
|
||||||
|
/// the returned <see cref="LeaderboardHandle"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LeaderboardsService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
private readonly Dictionary<string, LeaderboardHandle> _cache = new();
|
||||||
|
|
||||||
|
internal LeaderboardsService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>Returns the (cached) handle for the leaderboard with the given slug.</summary>
|
||||||
|
public LeaderboardHandle FindBySlug(string slug)
|
||||||
|
{
|
||||||
|
if (!_cache.TryGetValue(slug, out var leaderboard))
|
||||||
|
{
|
||||||
|
leaderboard = new LeaderboardHandle(slug, this);
|
||||||
|
_cache[slug] = leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task<GetRankingResponse> GetRankingAsync(string slug, int limit, CancellationToken cancellationToken)
|
||||||
|
=> _client.SendAsync<GetRankingResponse>(
|
||||||
|
"GET",
|
||||||
|
"/sdk/v1/leaderboards/" + Url.Encode(slug) + "/ranking" + Url.Query(("limit", limit > 0 ? limit.ToString() : null)),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
internal Task SubmitScoreAsync(string slug, double score, CancellationToken cancellationToken)
|
||||||
|
=> _client.SendAsync(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/leaderboards/" + Url.Encode(slug) + "/submit-score",
|
||||||
|
new SubmitScoreRequest { Slug = slug, Score = score },
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Operations on one leaderboard.</summary>
|
||||||
|
public sealed class LeaderboardHandle
|
||||||
|
{
|
||||||
|
private readonly LeaderboardsService _service;
|
||||||
|
|
||||||
|
internal LeaderboardHandle(string slug, LeaderboardsService service)
|
||||||
|
{
|
||||||
|
Slug = slug;
|
||||||
|
_service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Leaderboard slug.</summary>
|
||||||
|
public string Slug { get; }
|
||||||
|
|
||||||
|
/// <summary>Entries fetched by the last <see cref="ListAsync"/> call.</summary>
|
||||||
|
public IReadOnlyList<RankEntry> Entries { get; private set; } = new List<RankEntry>();
|
||||||
|
|
||||||
|
/// <summary>Submits a score for the current player.</summary>
|
||||||
|
public Task SubmitAsync(double score, CancellationToken cancellationToken = default)
|
||||||
|
=> _service.SubmitScoreAsync(Slug, score, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Fetches the top entries and caches them in <see cref="Entries"/>.</summary>
|
||||||
|
public async Task<IReadOnlyList<RankEntry>> ListAsync(int limit = 100, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _service.GetRankingAsync(Slug, limit, cancellationToken).ConfigureAwait(false);
|
||||||
|
Entries = response?.Entries ?? new List<RankEntry>();
|
||||||
|
return Entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Models.Player;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Player profile data.</summary>
|
||||||
|
public sealed class PlayerService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
|
||||||
|
internal PlayerService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>Returns the player profile with wallets.</summary>
|
||||||
|
public Task<PlayerProfile> GetProfileAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync<PlayerProfile>("GET", "/sdk/v1/player/information", cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Models.Quests;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Global quests (list + claim), distinct from scenario quest nodes which
|
||||||
|
/// advance through <see cref="QuestSession"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QuestsService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
|
||||||
|
internal QuestsService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>Lists the player's quests with per-objective progress and rewards.</summary>
|
||||||
|
public async Task<IReadOnlyList<Quest>> ListAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<ListQuestsRequest, ListQuestsResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/quests/list",
|
||||||
|
new ListQuestsRequest(),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return response?.Quests ?? new List<Quest>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Claims a completed quest's rewards (idempotent server-side).</summary>
|
||||||
|
public Task<ClaimQuestResponse> ClaimAsync(string questId, CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync<ClaimQuestRequest, ClaimQuestResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/quests/claim",
|
||||||
|
new ClaimQuestRequest { QuestId = questId },
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Realtime websocket channel. Requires
|
||||||
|
/// <see cref="RudderClientOptions.RealtimeUrl"/> and
|
||||||
|
/// <see cref="RudderClientOptions.RealtimeTransportFactory"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RealtimeService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
private RealtimeSession? _session;
|
||||||
|
|
||||||
|
internal RealtimeService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>The current session, or null when not connected.</summary>
|
||||||
|
public RealtimeSession? Session => _session;
|
||||||
|
|
||||||
|
/// <summary>True while a session is connected.</summary>
|
||||||
|
public bool IsConnected => _session?.IsConnected == true;
|
||||||
|
|
||||||
|
/// <summary>Connects to the configured realtime URL.</summary>
|
||||||
|
public Task<RealtimeSession> ConnectAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_client.RealtimeUrl))
|
||||||
|
throw new InvalidOperationException("RealtimeUrl is not configured.");
|
||||||
|
|
||||||
|
return ConnectAsync(new Uri(_client.RealtimeUrl), timeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Connects to an explicit realtime URL.</summary>
|
||||||
|
public async Task<RealtimeSession> ConnectAsync(Uri uri, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var factory = _client.Options.RealtimeTransportFactory
|
||||||
|
?? throw new InvalidOperationException("RealtimeTransportFactory is not configured.");
|
||||||
|
|
||||||
|
var token = _client.TokenStore.GetAccessToken();
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
throw new InvalidOperationException("LiveOps access token is required for realtime authorization.");
|
||||||
|
|
||||||
|
var transport = factory.Create();
|
||||||
|
await transport.ConnectAsync(uri, timeout ?? DefaultTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
_session = new RealtimeSession(transport, token);
|
||||||
|
return _session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes the current session, if any.</summary>
|
||||||
|
public Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> _session?.DisconnectAsync(cancellationToken) ?? Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>Pumps the underlying transport; call every frame.</summary>
|
||||||
|
public void Update(float deltaTime)
|
||||||
|
{
|
||||||
|
_session?.Update(deltaTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An open realtime connection.</summary>
|
||||||
|
public sealed class RealtimeSession
|
||||||
|
{
|
||||||
|
private readonly IRealtimeTransport _transport;
|
||||||
|
|
||||||
|
internal RealtimeSession(IRealtimeTransport transport, string accessToken)
|
||||||
|
{
|
||||||
|
_transport = transport;
|
||||||
|
AccessToken = accessToken;
|
||||||
|
_transport.Closed += () => Closed?.Invoke();
|
||||||
|
_transport.Error += ex => Error?.Invoke(ex);
|
||||||
|
_transport.Received += data => MessageReceived?.Invoke(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Access token the connection was authorized with.</summary>
|
||||||
|
public string AccessToken { get; }
|
||||||
|
|
||||||
|
/// <summary>True while the connection is open.</summary>
|
||||||
|
public bool IsConnected => _transport.IsConnected;
|
||||||
|
|
||||||
|
/// <summary>Raised when the connection closes.</summary>
|
||||||
|
public event Action? Closed;
|
||||||
|
|
||||||
|
/// <summary>Raised on transport errors.</summary>
|
||||||
|
public event Action<Exception>? Error;
|
||||||
|
|
||||||
|
/// <summary>Raised for every incoming message.</summary>
|
||||||
|
public event Action<ArraySegment<byte>>? MessageReceived;
|
||||||
|
|
||||||
|
/// <summary>Sends one message.</summary>
|
||||||
|
public Task SendAsync(byte[] payload, CancellationToken cancellationToken = default)
|
||||||
|
=> _transport.SendAsync(new ArraySegment<byte>(payload ?? Array.Empty<byte>()), cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Closes the connection.</summary>
|
||||||
|
public Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> _transport.CloseAsync(cancellationToken);
|
||||||
|
|
||||||
|
internal void Update(float deltaTime)
|
||||||
|
{
|
||||||
|
_transport.Update(deltaTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using RudderSdk.Core.Models.RemoteConfig;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remote configuration values. <see cref="LoadAsync"/> caches every config
|
||||||
|
/// whose "active" flag is not explicitly false; <see cref="Get{T}"/> reads
|
||||||
|
/// typed values from the cache.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RemoteConfigService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
private readonly Dictionary<string, RemoteConfig> _cache = new();
|
||||||
|
private bool _isLoaded;
|
||||||
|
|
||||||
|
internal RemoteConfigService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>True after the first successful <see cref="LoadAsync"/>.</summary>
|
||||||
|
public bool IsLoaded => _isLoaded;
|
||||||
|
|
||||||
|
/// <summary>The cached configs keyed by config key.</summary>
|
||||||
|
public IReadOnlyDictionary<string, RemoteConfig> Configs => _cache;
|
||||||
|
|
||||||
|
/// <summary>Fetches all configs and rebuilds the cache.</summary>
|
||||||
|
public async Task<IReadOnlyDictionary<string, RemoteConfig>> LoadAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<RawListRemoteConfigsResponse>(
|
||||||
|
"GET",
|
||||||
|
"/sdk/v1/remote-configs",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_cache.Clear();
|
||||||
|
if (response?.Configs != null)
|
||||||
|
{
|
||||||
|
foreach (var entry in response.Configs)
|
||||||
|
{
|
||||||
|
// Include everything except an explicit "active": false — a missing
|
||||||
|
// flag means active, matching the other SDKs.
|
||||||
|
if (entry.Value?["active"]?.Value<bool?>() == false)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var config = entry.Value?.ToObject<RemoteConfig>();
|
||||||
|
if (config != null)
|
||||||
|
_cache[config.Key ?? entry.Key] = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoaded = true;
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fetches one config straight from the server, bypassing the cache.</summary>
|
||||||
|
public async Task<RemoteConfig> GetConfigAsync(string key, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _client.SendAsync<RemoteConfig>(
|
||||||
|
"GET",
|
||||||
|
"/sdk/v1/remote-configs/" + Url.Encode(key),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a typed value from the cache; returns the default when missing or unparsable.</summary>
|
||||||
|
public T Get<T>(string key, T defaultValue = default!)
|
||||||
|
{
|
||||||
|
if (!_isLoaded || string.IsNullOrEmpty(key) || !_cache.TryGetValue(key, out var config))
|
||||||
|
return defaultValue;
|
||||||
|
|
||||||
|
return ParseValue(config.Value, config.ValueType, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads the cache on first use, then reads a typed value.</summary>
|
||||||
|
public async Task<T> GetAsync<T>(string key, T defaultValue = default!, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_isLoaded)
|
||||||
|
await LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Get(key, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ApplyOverride(string key, string? value, string valueType = "json")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(key)) return;
|
||||||
|
_cache[key] = new RemoteConfig
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
// The generated model declares Value non-nullable; a JSON null patch
|
||||||
|
// value is still legal and ParseValue maps it to the caller's default.
|
||||||
|
Value = value!,
|
||||||
|
ValueType = valueType,
|
||||||
|
Active = true
|
||||||
|
};
|
||||||
|
_isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T ParseValue<T>(string value, string valueType, T defaultValue)
|
||||||
|
{
|
||||||
|
if (value == null) return defaultValue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (typeof(T) == typeof(string))
|
||||||
|
return (T)(object)value;
|
||||||
|
|
||||||
|
switch ((valueType ?? string.Empty).ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "number":
|
||||||
|
case "int":
|
||||||
|
case "integer":
|
||||||
|
case "float":
|
||||||
|
case "double":
|
||||||
|
return (T)ConvertNumber(value, typeof(T));
|
||||||
|
case "bool":
|
||||||
|
case "boolean":
|
||||||
|
return (T)(object)bool.Parse(value);
|
||||||
|
case "json":
|
||||||
|
case "object":
|
||||||
|
return JsonConvert.DeserializeObject<T>(value) ?? defaultValue;
|
||||||
|
default:
|
||||||
|
return (T)System.Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ConvertNumber(string value, Type target)
|
||||||
|
{
|
||||||
|
if (target == typeof(float))
|
||||||
|
return float.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
if (target == typeof(double))
|
||||||
|
return double.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
if (target == typeof(long))
|
||||||
|
return long.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
if (target == typeof(decimal))
|
||||||
|
return decimal.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
return int.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw shape of the list payload: the generated RemoteConfig.Active is a
|
||||||
|
// non-nullable bool and cannot distinguish "active": false from a missing
|
||||||
|
// flag, so the active check runs on the raw JSON before mapping.
|
||||||
|
private sealed class RawListRemoteConfigsResponse
|
||||||
|
{
|
||||||
|
[JsonProperty("configs")]
|
||||||
|
public Dictionary<string, JObject>? Configs { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using RudderSdk.Core.Abstractions;
|
||||||
|
using RudderSdk.Core.Models;
|
||||||
|
using RudderSdk.Core.Models.Scenarios;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scenario runtime. <see cref="TriggerAsync"/> starts server-issued plans;
|
||||||
|
/// active nodes surface as typed sessions through the On* events and are
|
||||||
|
/// advanced by completing those sessions.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ScenarioService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
private readonly Dictionary<string, RuntimeRun> _runs = new();
|
||||||
|
|
||||||
|
/// <summary>Raised for a notification node.</summary>
|
||||||
|
public event Action<NotificationSession>? OnNotification;
|
||||||
|
|
||||||
|
/// <summary>Raised for a store-offer node.</summary>
|
||||||
|
public event Action<StoreOfferSession>? OnStoreOffer;
|
||||||
|
|
||||||
|
/// <summary>Raised for a leaderboard node.</summary>
|
||||||
|
public event Action<LeaderboardSession>? OnLeaderboard;
|
||||||
|
|
||||||
|
/// <summary>Raised for a remote-config-override node, after the patches were applied.</summary>
|
||||||
|
public event Action<ConfigChangedSession>? OnConfigChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised for a wait node.</summary>
|
||||||
|
public event Action<WaitSession>? OnWait;
|
||||||
|
|
||||||
|
/// <summary>Raised for a quest node.</summary>
|
||||||
|
public event Action<QuestSession>? OnQuest;
|
||||||
|
|
||||||
|
/// <summary>Raised for a battle-pass node.</summary>
|
||||||
|
public event Action<BattlePassSession>? OnBattlePass;
|
||||||
|
|
||||||
|
/// <summary>Raised for a battle-pass-level node.</summary>
|
||||||
|
public event Action<BattlePassLevelSession>? OnBattlePassLevel;
|
||||||
|
|
||||||
|
/// <summary>Raised when a run finishes all its nodes.</summary>
|
||||||
|
public event Action<PlanRun>? OnScenarioCompleted;
|
||||||
|
|
||||||
|
/// <summary>Raised when a run dies on an unrecoverable error.</summary>
|
||||||
|
public event Action<ScenarioFailedEvent>? OnScenarioFailed;
|
||||||
|
|
||||||
|
internal ScenarioService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>True while at least one run is active.</summary>
|
||||||
|
public bool IsRunning => _runs.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>First active node id across the runs, or null.</summary>
|
||||||
|
public string? CurrentNodeId => _runs.Values.FirstOrDefault()?.ActiveNodes.Keys.FirstOrDefault();
|
||||||
|
|
||||||
|
/// <summary>Snapshots of the active runs.</summary>
|
||||||
|
public IReadOnlyList<PlanRun> ActiveRuns => _runs.Values.Select(ToPlanRun).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Triggers scenarios by event name and starts the plans the server
|
||||||
|
/// returns. Returns the runs this call started.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<PlanRun>> TriggerAsync(string eventName, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<TriggerScenarioRequest, TriggerScenarioResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/scenarios/trigger",
|
||||||
|
new TriggerScenarioRequest { Event = eventName },
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return StartPlans(response?.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores persisted runs, reconciles them with the server and re-dispatches
|
||||||
|
/// active nodes. Call once after startup, after login.
|
||||||
|
/// </summary>
|
||||||
|
public Task RestoreAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return RestoreCoreAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RestoreCoreAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var store = _client.Options.PlanStateStore;
|
||||||
|
if (store == null || string.IsNullOrEmpty(store.State))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var state = JsonConvert.DeserializeObject<PersistedScenarioState>(store.State);
|
||||||
|
_runs.Clear();
|
||||||
|
if (state?.Runs != null)
|
||||||
|
{
|
||||||
|
foreach (var savedRun in state.Runs)
|
||||||
|
{
|
||||||
|
if (savedRun?.Plan == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Reconcile with server
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/scenarios/run",
|
||||||
|
new GetScenarioRunRequest { RunId = savedRun.RunId },
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (response?.Status == "unknown_run" || response?.Status == "expired")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (response?.Plan != null)
|
||||||
|
{
|
||||||
|
savedRun.Plan = response.Plan;
|
||||||
|
savedRun.ActiveNodes = null;
|
||||||
|
savedRun.CompletedHandles = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Network error — keep local state as fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
var run = RuntimeRun.FromPersisted(savedRun);
|
||||||
|
_runs[run.RunId] = run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var run in _runs.Values.ToList())
|
||||||
|
{
|
||||||
|
if (run.ActiveNodes.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var nodeState in run.ActiveNodes.Values.ToList())
|
||||||
|
DispatchActiveNode(run, nodeState, restored: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Rebuilt from server — activate start node
|
||||||
|
var startNode = FindNode(run.Plan, run.Plan.StartNodeId) ?? run.Plan.Nodes[0];
|
||||||
|
ActivateNode(run, startNode.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Persist();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_client.Options.Logger?.Log(RudderLogLevel.Error, "[Rudder] Failed to restore scenario state. Clearing persisted state. " + ex.Message);
|
||||||
|
Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops all runs and the persisted state.</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_runs.Clear();
|
||||||
|
Persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Completes wait nodes whose deadline passed; call every frame.</summary>
|
||||||
|
public void Update(float deltaTime)
|
||||||
|
{
|
||||||
|
var now = _client.Clock.UtcNow;
|
||||||
|
foreach (var run in _runs.Values.ToList())
|
||||||
|
{
|
||||||
|
foreach (var node in run.ActiveNodes.Values.ToList())
|
||||||
|
{
|
||||||
|
if (node.WaitDeadlineUtc.HasValue && now >= node.WaitDeadlineUtc.Value)
|
||||||
|
_ = CompleteNodeAsync(run.RunId, node.NodeId, "onComplete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Completes the first active node with the given handle.</summary>
|
||||||
|
public Task RespondAsync(string handle, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var run = _runs.Values.FirstOrDefault();
|
||||||
|
var node = run?.ActiveNodes.Values.FirstOrDefault();
|
||||||
|
return run == null || node == null
|
||||||
|
? Task.CompletedTask
|
||||||
|
: CompleteNodeAsync(run.RunId, node.NodeId, handle, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Completes the first active node with the given handle (fire-and-forget).</summary>
|
||||||
|
public void Respond(string handle)
|
||||||
|
{
|
||||||
|
_ = RespondAsync(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds progress to a counter of the first active node.</summary>
|
||||||
|
public Task UpdateProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var run = _runs.Values.FirstOrDefault();
|
||||||
|
var node = run?.ActiveNodes.Values.FirstOrDefault();
|
||||||
|
return run == null || node == null
|
||||||
|
? Task.CompletedTask
|
||||||
|
: UpdateProgressAsync(run.RunId, node.NodeId, counterKey, amount, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task UpdateProgressAsync(
|
||||||
|
string runId,
|
||||||
|
string nodeId,
|
||||||
|
string counterKey,
|
||||||
|
long amount,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_runs.TryGetValue(runId, out var run) || !run.ActiveNodes.ContainsKey(nodeId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<UpdateScenarioCounterRequest, UpdateScenarioCounterResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/scenarios/counter",
|
||||||
|
new UpdateScenarioCounterRequest
|
||||||
|
{
|
||||||
|
ScenarioId = run.Plan.ScenarioId,
|
||||||
|
NodeId = nodeId,
|
||||||
|
CounterKey = counterKey,
|
||||||
|
Amount = amount,
|
||||||
|
RunId = run.RunId
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response?.Plan != null)
|
||||||
|
StartPlan(response.Plan);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FailRun(run, nodeId, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using RudderSdk.Core.Models;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Snapshot of one running scenario plan.</summary>
|
||||||
|
public sealed class PlanRun
|
||||||
|
{
|
||||||
|
internal PlanRun(
|
||||||
|
string runId,
|
||||||
|
string planId,
|
||||||
|
string scenarioId,
|
||||||
|
string userId,
|
||||||
|
IReadOnlyList<string> activeNodeIds,
|
||||||
|
ExecutionPlan plan)
|
||||||
|
{
|
||||||
|
RunId = runId;
|
||||||
|
PlanId = planId;
|
||||||
|
ScenarioId = scenarioId;
|
||||||
|
UserId = userId;
|
||||||
|
ActiveNodeIds = activeNodeIds;
|
||||||
|
Plan = plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-issued run id.</summary>
|
||||||
|
public string RunId { get; }
|
||||||
|
|
||||||
|
/// <summary>Plan id.</summary>
|
||||||
|
public string PlanId { get; }
|
||||||
|
|
||||||
|
/// <summary>Scenario id.</summary>
|
||||||
|
public string ScenarioId { get; }
|
||||||
|
|
||||||
|
/// <summary>Player the run belongs to.</summary>
|
||||||
|
public string UserId { get; }
|
||||||
|
|
||||||
|
/// <summary>Ids of the currently active nodes.</summary>
|
||||||
|
public IReadOnlyList<string> ActiveNodeIds { get; }
|
||||||
|
|
||||||
|
/// <summary>The execution plan being run.</summary>
|
||||||
|
public ExecutionPlan Plan { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Payload of <see cref="ScenarioService.OnScenarioFailed"/>.</summary>
|
||||||
|
public sealed class ScenarioFailedEvent
|
||||||
|
{
|
||||||
|
internal ScenarioFailedEvent(PlanRun run, string nodeId, Exception exception)
|
||||||
|
{
|
||||||
|
Run = run;
|
||||||
|
NodeId = nodeId;
|
||||||
|
Exception = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The failed run.</summary>
|
||||||
|
public PlanRun Run { get; }
|
||||||
|
|
||||||
|
/// <summary>Node the failure happened at.</summary>
|
||||||
|
public string NodeId { get; }
|
||||||
|
|
||||||
|
/// <summary>The error that failed the run.</summary>
|
||||||
|
public Exception Exception { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using RudderSdk.Core.Models;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Context of the scenario node a session was created for.</summary>
|
||||||
|
public class ScenarioNodeContext
|
||||||
|
{
|
||||||
|
private readonly ScenarioService _service;
|
||||||
|
|
||||||
|
internal ScenarioNodeContext(ScenarioService service, PlanRun run, ExecutionPlanNode node)
|
||||||
|
{
|
||||||
|
_service = service;
|
||||||
|
Run = run;
|
||||||
|
Node = node;
|
||||||
|
Data = node?.Data as JObject ?? new JObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The run this node belongs to.</summary>
|
||||||
|
public PlanRun Run { get; }
|
||||||
|
|
||||||
|
/// <summary>The plan node.</summary>
|
||||||
|
public ExecutionPlanNode Node { get; }
|
||||||
|
|
||||||
|
/// <summary>Run id.</summary>
|
||||||
|
public string RunId => Run.RunId;
|
||||||
|
|
||||||
|
/// <summary>Plan id.</summary>
|
||||||
|
public string PlanId => Run.PlanId;
|
||||||
|
|
||||||
|
/// <summary>Scenario id.</summary>
|
||||||
|
public string ScenarioId => Run.ScenarioId;
|
||||||
|
|
||||||
|
/// <summary>Node id.</summary>
|
||||||
|
public string NodeId => Node.Id;
|
||||||
|
|
||||||
|
/// <summary>Node type.</summary>
|
||||||
|
public string Type => Node.Type;
|
||||||
|
|
||||||
|
/// <summary>Node data payload.</summary>
|
||||||
|
public JObject Data { get; }
|
||||||
|
|
||||||
|
/// <summary>Reads a typed value from the node data.</summary>
|
||||||
|
public T Get<T>(string key, T defaultValue = default!)
|
||||||
|
{
|
||||||
|
if (Data == null || !Data.TryGetValue(key, out var value))
|
||||||
|
return defaultValue;
|
||||||
|
try { return value.ToObject<T>() ?? defaultValue; }
|
||||||
|
catch { return defaultValue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deserializes the whole node data payload.</summary>
|
||||||
|
public T Get<T>()
|
||||||
|
{
|
||||||
|
try { return Data == null ? default! : Data.ToObject<T>() ?? default!; }
|
||||||
|
catch { return default!; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the node data as a plain dictionary.</summary>
|
||||||
|
public Dictionary<string, object> AsObjectDictionary()
|
||||||
|
{
|
||||||
|
return Data?.ToObject<Dictionary<string, object>>() ?? new Dictionary<string, object>();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task CompleteAsync(string handle, CancellationToken cancellationToken = default)
|
||||||
|
=> _service.CompleteNodeAsync(RunId, NodeId, handle, cancellationToken);
|
||||||
|
|
||||||
|
internal void Complete(string handle)
|
||||||
|
{
|
||||||
|
_ = CompleteAsync(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task AddProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
|
||||||
|
=> _service.UpdateProgressAsync(RunId, NodeId, counterKey, amount, cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using RudderSdk.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
public sealed partial class ScenarioService
|
||||||
|
{
|
||||||
|
private void DispatchActiveNode(RuntimeRun run, ActiveNodeState state, bool restored)
|
||||||
|
{
|
||||||
|
var node = FindNode(run.Plan, state.NodeId);
|
||||||
|
if (node == null)
|
||||||
|
{
|
||||||
|
run.ActiveNodes.Remove(state.NodeId);
|
||||||
|
CheckRunCompleted(run);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = new ScenarioNodeContext(this, ToPlanRun(run), node);
|
||||||
|
switch (node.Type ?? string.Empty)
|
||||||
|
{
|
||||||
|
case WaitNode:
|
||||||
|
DispatchWait(run, state, context);
|
||||||
|
break;
|
||||||
|
case RemoteConfigOverrideNode:
|
||||||
|
DispatchRemoteConfigOverride(run, state, context);
|
||||||
|
break;
|
||||||
|
case NotificationNode:
|
||||||
|
EmitNotification(context);
|
||||||
|
break;
|
||||||
|
case StoreNode:
|
||||||
|
EmitStoreOffer(context);
|
||||||
|
break;
|
||||||
|
case QuestNode:
|
||||||
|
EmitQuest(context);
|
||||||
|
break;
|
||||||
|
case LeaderboardNode:
|
||||||
|
EmitLeaderboard(context);
|
||||||
|
break;
|
||||||
|
case BattlePassNode:
|
||||||
|
EmitBattlePass(context);
|
||||||
|
break;
|
||||||
|
case BattlePassLevelNode:
|
||||||
|
EmitBattlePassLevel(context);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Unsupported scenario node type '{node.Type}' ({node.Id}).");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchWait(RuntimeRun run, ActiveNodeState state, ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
if (!state.WaitDeadlineUtc.HasValue)
|
||||||
|
{
|
||||||
|
// Prefer server-provided WaitDeadline from the plan boundary over local calculation.
|
||||||
|
// The server stamps WaitDeadline on server-enforced wait boundaries (see StampBoundaries).
|
||||||
|
var boundary = run.Plan.BoundaryNodes?.FirstOrDefault(
|
||||||
|
b => b.SourceNodeId == state.NodeId && !string.IsNullOrEmpty(b.WaitDeadline)
|
||||||
|
);
|
||||||
|
if (boundary != null && DateTimeOffset.TryParse(boundary.WaitDeadline, null, DateTimeStyles.RoundtripKind, out var parsed))
|
||||||
|
{
|
||||||
|
state.WaitDeadlineUtc = parsed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var delay = GetWaitDelay(context.Data);
|
||||||
|
state.WaitDeadlineUtc = _client.Clock.UtcNow.Add(delay);
|
||||||
|
}
|
||||||
|
Persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = new WaitSession(context, state.WaitDeadlineUtc.Value);
|
||||||
|
OnWait?.Invoke(session);
|
||||||
|
|
||||||
|
if (_client.Clock.UtcNow >= state.WaitDeadlineUtc.Value)
|
||||||
|
_ = CompleteNodeAsync(run.RunId, state.NodeId, "onComplete");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchRemoteConfigOverride(RuntimeRun run, ActiveNodeState state, ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
var patches = context.Data["patches"] as JArray;
|
||||||
|
if (patches != null)
|
||||||
|
{
|
||||||
|
foreach (var patchToken in patches.OfType<JObject>())
|
||||||
|
{
|
||||||
|
var key = patchToken.Value<string>("path");
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var valueType = patchToken.Value<string>("valueType") ?? "json";
|
||||||
|
var value = SerializeRemoteConfigValue(patchToken["value"], valueType);
|
||||||
|
_client.RemoteConfig.ApplyOverride(key, value, valueType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = new ConfigChangedSession(context);
|
||||||
|
OnConfigChanged?.Invoke(session);
|
||||||
|
_ = CompleteNodeAsync(run.RunId, state.NodeId, "output");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitNotification(ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
OnNotification?.Invoke(new NotificationSession(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitStoreOffer(ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
OnStoreOffer?.Invoke(new StoreOfferSession(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitQuest(ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
OnQuest?.Invoke(new QuestSession(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitLeaderboard(ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
OnLeaderboard?.Invoke(new LeaderboardSession(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitBattlePass(ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
OnBattlePass?.Invoke(new BattlePassSession(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitBattlePassLevel(ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
OnBattlePassLevel?.Invoke(new BattlePassLevelSession(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetWaitDelay(JObject data)
|
||||||
|
{
|
||||||
|
var duration = data.Value<double?>("duration") ?? 0;
|
||||||
|
var unit = data.Value<string>("unit") ?? "seconds";
|
||||||
|
if (duration <= 0)
|
||||||
|
return TimeSpan.Zero;
|
||||||
|
|
||||||
|
switch (unit)
|
||||||
|
{
|
||||||
|
case "days":
|
||||||
|
case "day":
|
||||||
|
case "d":
|
||||||
|
return TimeSpan.FromDays(duration);
|
||||||
|
case "hours":
|
||||||
|
case "hour":
|
||||||
|
case "hr":
|
||||||
|
case "h":
|
||||||
|
return TimeSpan.FromHours(duration);
|
||||||
|
case "minutes":
|
||||||
|
case "minute":
|
||||||
|
case "min":
|
||||||
|
case "m":
|
||||||
|
return TimeSpan.FromMinutes(duration);
|
||||||
|
case "seconds":
|
||||||
|
case "second":
|
||||||
|
case "sec":
|
||||||
|
case "s":
|
||||||
|
return TimeSpan.FromSeconds(duration);
|
||||||
|
default:
|
||||||
|
return TimeSpan.FromSeconds(duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? SerializeRemoteConfigValue(JToken? token, string valueType)
|
||||||
|
{
|
||||||
|
if (token == null || token.Type == JTokenType.Null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
switch ((valueType ?? string.Empty).ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "string":
|
||||||
|
return token.Type == JTokenType.String ? token.Value<string>() : token.ToString(Formatting.None);
|
||||||
|
case "bool":
|
||||||
|
case "boolean":
|
||||||
|
return token.Value<bool>().ToString().ToLowerInvariant();
|
||||||
|
case "int":
|
||||||
|
case "integer":
|
||||||
|
return token.Value<long>().ToString(CultureInfo.InvariantCulture);
|
||||||
|
case "float":
|
||||||
|
case "double":
|
||||||
|
return token.Value<double>().ToString(CultureInfo.InvariantCulture);
|
||||||
|
default:
|
||||||
|
return token.Type == JTokenType.String ? token.Value<string>() : token.ToString(Formatting.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
public sealed partial class ScenarioService
|
||||||
|
{
|
||||||
|
private const string NotificationNode = "notification";
|
||||||
|
private const string StoreNode = "store";
|
||||||
|
private const string WaitNode = "wait";
|
||||||
|
private const string RemoteConfigOverrideNode = "remote_config_override";
|
||||||
|
private const string QuestNode = "quest";
|
||||||
|
private const string LeaderboardNode = "leaderboard";
|
||||||
|
private const string BattlePassNode = "battlepass";
|
||||||
|
private const string BattlePassLevelNode = "battlepass_level";
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using RudderSdk.Core.Models;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
public sealed partial class ScenarioService
|
||||||
|
{
|
||||||
|
private void Persist()
|
||||||
|
{
|
||||||
|
var store = _client.Options.PlanStateStore;
|
||||||
|
if (store == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_runs.Count == 0)
|
||||||
|
{
|
||||||
|
store.State = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = new PersistedScenarioState
|
||||||
|
{
|
||||||
|
Runs = _runs.Values.Select(run => run.ToPersisted()).ToList()
|
||||||
|
};
|
||||||
|
store.State = JsonConvert.SerializeObject(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RuntimeRun
|
||||||
|
{
|
||||||
|
public RuntimeRun(string runId, ExecutionPlan plan)
|
||||||
|
{
|
||||||
|
RunId = runId;
|
||||||
|
Plan = plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RunId { get; }
|
||||||
|
public ExecutionPlan Plan { get; }
|
||||||
|
public Dictionary<string, ActiveNodeState> ActiveNodes { get; } = new();
|
||||||
|
public HashSet<string> CompletedHandles { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public PersistedRun ToPersisted()
|
||||||
|
{
|
||||||
|
return new PersistedRun
|
||||||
|
{
|
||||||
|
RunId = RunId,
|
||||||
|
Plan = Plan,
|
||||||
|
ActiveNodes = ActiveNodes.Values.ToList(),
|
||||||
|
CompletedHandles = CompletedHandles.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RuntimeRun FromPersisted(PersistedRun saved)
|
||||||
|
{
|
||||||
|
var run = new RuntimeRun(saved.RunId, saved.Plan);
|
||||||
|
if (saved.ActiveNodes != null)
|
||||||
|
{
|
||||||
|
foreach (var node in saved.ActiveNodes)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(node?.NodeId))
|
||||||
|
run.ActiveNodes[node.NodeId] = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (saved.CompletedHandles != null)
|
||||||
|
{
|
||||||
|
foreach (var handle in saved.CompletedHandles)
|
||||||
|
run.CompletedHandles.Add(handle);
|
||||||
|
}
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PersistedScenarioState
|
||||||
|
{
|
||||||
|
public List<PersistedRun>? Runs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PersistedRun
|
||||||
|
{
|
||||||
|
public string RunId { get; set; } = string.Empty;
|
||||||
|
public ExecutionPlan Plan { get; set; } = null!;
|
||||||
|
public List<ActiveNodeState>? ActiveNodes { get; set; }
|
||||||
|
public List<string>? CompletedHandles { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ActiveNodeState
|
||||||
|
{
|
||||||
|
public string NodeId { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset? WaitDeadlineUtc { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Abstractions;
|
||||||
|
using RudderSdk.Core.Models;
|
||||||
|
using RudderSdk.Core.Models.Scenarios;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a boundary HTTP call fails with a transient error
|
||||||
|
/// (network failure, timeout, or server 5xx). The caller should NOT advance
|
||||||
|
/// the run; the node stays active and the handle stays pending for retry.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TransientBoundaryException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>Creates the exception.</summary>
|
||||||
|
public TransientBoundaryException(string message, Exception inner)
|
||||||
|
: base(message, inner)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates the exception wrapping the transport error.</summary>
|
||||||
|
public TransientBoundaryException(Exception inner)
|
||||||
|
: base($"Transient boundary error: {inner.Message}", inner)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class ScenarioService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true for exceptions that may succeed on retry
|
||||||
|
/// (timeout / network failures).
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsTransientException(Exception ex)
|
||||||
|
{
|
||||||
|
return ex is OperationCanceledException || ex is RudderNetworkException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<PlanRun> StartPlans(IEnumerable<ExecutionPlan>? plans)
|
||||||
|
{
|
||||||
|
var started = new List<PlanRun>();
|
||||||
|
if (plans == null)
|
||||||
|
return started;
|
||||||
|
|
||||||
|
foreach (var plan in plans)
|
||||||
|
{
|
||||||
|
var run = StartPlan(plan);
|
||||||
|
if (run != null)
|
||||||
|
started.Add(ToPlanRun(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RuntimeRun? StartPlan(ExecutionPlan? plan)
|
||||||
|
{
|
||||||
|
if (plan?.Nodes == null || plan.Nodes.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Dedup: server returned same runId — skip without restarting.
|
||||||
|
if (!string.IsNullOrEmpty(plan.RunId) && _runs.ContainsKey(plan.RunId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (plan.BoundaryNodes?.Count > 0 && string.IsNullOrEmpty(plan.RunId))
|
||||||
|
throw new InvalidOperationException("ExecutionPlan has boundaryNodes but missing RunId.");
|
||||||
|
|
||||||
|
var runId = plan.RunId ?? Guid.NewGuid().ToString("N");
|
||||||
|
var startNode = FindNode(plan, plan.StartNodeId) ?? plan.Nodes[0];
|
||||||
|
var run = new RuntimeRun(runId, plan);
|
||||||
|
_runs[run.RunId] = run;
|
||||||
|
ActivateNode(run, startNode.Id);
|
||||||
|
Persist();
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces an existing run's plan with a server-provided continuation (same RunId):
|
||||||
|
/// the previous segment is done, the new segment's start node becomes active.
|
||||||
|
/// Idempotent: if the continuation was already applied, nothing is re-dispatched.
|
||||||
|
/// </summary>
|
||||||
|
private void ReplaceRun(ExecutionPlan? plan, string? fallbackRunId = null)
|
||||||
|
{
|
||||||
|
if (plan?.Nodes == null || plan.Nodes.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var runId = plan.RunId ?? fallbackRunId;
|
||||||
|
if (string.IsNullOrEmpty(runId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var startNode = FindNode(plan, plan.StartNodeId) ?? plan.Nodes[0];
|
||||||
|
if (startNode == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_runs.TryGetValue(runId, out var existing) && existing.ActiveNodes.ContainsKey(startNode.Id))
|
||||||
|
return; // continuation already applied (idempotent callback echo)
|
||||||
|
|
||||||
|
var run = new RuntimeRun(runId, plan);
|
||||||
|
_runs[runId] = run;
|
||||||
|
ActivateNode(run, startNode.Id);
|
||||||
|
Persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ActivateNode(RuntimeRun run, string nodeId, ActiveNodeState? restoredState = null)
|
||||||
|
{
|
||||||
|
var node = FindNode(run.Plan, nodeId);
|
||||||
|
if (node == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var state = restoredState ?? new ActiveNodeState { NodeId = nodeId };
|
||||||
|
run.ActiveNodes[nodeId] = state;
|
||||||
|
DispatchActiveNode(run, state, restored: restoredState != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task CompleteNodeAsync(
|
||||||
|
string runId,
|
||||||
|
string nodeId,
|
||||||
|
string handle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return CompleteNodeAsync(runId, nodeId, handle, continueOnBoundary: true, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteNodeAsync(
|
||||||
|
string runId,
|
||||||
|
string nodeId,
|
||||||
|
string handle,
|
||||||
|
bool continueOnBoundary,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_runs.TryGetValue(runId, out var run))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var key = CompletedHandleKey(nodeId, handle);
|
||||||
|
if (run.CompletedHandles.Contains(key))
|
||||||
|
return; // idempotent
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var continuedOnBoundary = false;
|
||||||
|
if (continueOnBoundary)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
continuedOnBoundary = await ContinueBoundaryAsync(
|
||||||
|
run, nodeId, handle, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (TransientBoundaryException)
|
||||||
|
{
|
||||||
|
// Transient error — don't advance the run.
|
||||||
|
// Node stays active, handle stays pending for retry on reconnect.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (continuedOnBoundary)
|
||||||
|
{
|
||||||
|
// The boundary may have replaced or removed the run (continuation plan
|
||||||
|
// or reconcile). The transferred run owns the state — touching the stale
|
||||||
|
// object here would complete or delete the new run.
|
||||||
|
if (!_runs.TryGetValue(runId, out var currentRun) || !ReferenceEquals(currentRun, run))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only now — after the server has confirmed — mark the handle and node.
|
||||||
|
run.CompletedHandles.Add(key);
|
||||||
|
run.ActiveNodes.Remove(nodeId);
|
||||||
|
Persist();
|
||||||
|
|
||||||
|
if (!continuedOnBoundary)
|
||||||
|
{
|
||||||
|
foreach (var edge in MatchingEdges(run.Plan, nodeId, handle))
|
||||||
|
ActivateNode(run, edge.Target);
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckRunCompleted(run);
|
||||||
|
Persist();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FailRun(run, nodeId, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ContinueBoundaryAsync(
|
||||||
|
RuntimeRun run,
|
||||||
|
string nodeId,
|
||||||
|
string handle,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var boundaries = MatchingBoundaryNodes(run.Plan, nodeId, handle).ToList();
|
||||||
|
if (boundaries.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var boundary in boundaries)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<HandleScenarioCallbackRequest, HandleScenarioCallbackResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/scenarios/callback",
|
||||||
|
new HandleScenarioCallbackRequest
|
||||||
|
{
|
||||||
|
ScenarioId = run.Plan.ScenarioId,
|
||||||
|
NodeId = boundary.SourceNodeId,
|
||||||
|
Handle = boundary.SourceHandle,
|
||||||
|
RunId = run.RunId
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response?.Plan != null)
|
||||||
|
{
|
||||||
|
// Continuation of the current run (server keeps the RunId) replaces
|
||||||
|
// the run's plan; fresh/terminal plans start as new runs.
|
||||||
|
if (_runs.ContainsKey(response.Plan.RunId ?? string.Empty))
|
||||||
|
ReplaceRun(response.Plan);
|
||||||
|
else
|
||||||
|
StartPlan(response.Plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Boundary call failed — try to reconcile with server.
|
||||||
|
var reconciled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var reconcile = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/scenarios/run",
|
||||||
|
new GetScenarioRunRequest { RunId = run.RunId },
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (reconcile?.Status == "unknown_run" || reconcile?.Status == "expired")
|
||||||
|
{
|
||||||
|
_runs.Remove(run.RunId);
|
||||||
|
Persist();
|
||||||
|
reconciled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconcile?.Plan != null)
|
||||||
|
{
|
||||||
|
ReplaceRun(reconcile.Plan, run.RunId);
|
||||||
|
reconciled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Reconciliation also failed.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reconciled)
|
||||||
|
{
|
||||||
|
// Reconcile did not resolve — distinguish transient from terminal.
|
||||||
|
if (IsTransientException(ex))
|
||||||
|
throw new TransientBoundaryException(ex);
|
||||||
|
// Terminal error — let the caller fail the run.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
// If reconciled, the boundary was handled (run corrected or removed).
|
||||||
|
// Fall through to continue to the next boundary.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckRunCompleted(RuntimeRun run)
|
||||||
|
{
|
||||||
|
if (run.ActiveNodes.Count > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_runs.Remove(run.RunId);
|
||||||
|
Persist();
|
||||||
|
OnScenarioCompleted?.Invoke(ToPlanRun(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FailRun(RuntimeRun run, string nodeId, Exception ex)
|
||||||
|
{
|
||||||
|
_client.Options.Logger?.Log(RudderLogLevel.Error, $"[Rudder] Scenario run {run.RunId} failed at node {nodeId}: {ex.Message}");
|
||||||
|
_runs.Remove(run.RunId);
|
||||||
|
Persist();
|
||||||
|
OnScenarioFailed?.Invoke(new ScenarioFailedEvent(ToPlanRun(run), nodeId, ex));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlanRun ToPlanRun(RuntimeRun run)
|
||||||
|
{
|
||||||
|
return new PlanRun(
|
||||||
|
run.RunId,
|
||||||
|
run.Plan.PlanId,
|
||||||
|
run.Plan.ScenarioId,
|
||||||
|
run.Plan.UserId,
|
||||||
|
run.ActiveNodes.Keys.ToList(),
|
||||||
|
run.Plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExecutionPlanNode? FindNode(ExecutionPlan? plan, string? nodeId)
|
||||||
|
{
|
||||||
|
if (plan?.Nodes == null || plan.Nodes.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(nodeId))
|
||||||
|
{
|
||||||
|
foreach (var node in plan.Nodes)
|
||||||
|
{
|
||||||
|
if (node.Id == nodeId)
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<PlanEdge> MatchingEdges(ExecutionPlan plan, string sourceNodeId, string sourceHandle)
|
||||||
|
{
|
||||||
|
if (plan?.Edges == null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (var edge in plan.Edges)
|
||||||
|
{
|
||||||
|
if (edge.Source == sourceNodeId && string.Equals(edge.SourceHandle ?? string.Empty, sourceHandle ?? string.Empty, StringComparison.Ordinal))
|
||||||
|
yield return edge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<BoundaryNode> MatchingBoundaryNodes(ExecutionPlan plan, string sourceNodeId, string sourceHandle)
|
||||||
|
{
|
||||||
|
if (plan?.BoundaryNodes == null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (var boundary in plan.BoundaryNodes)
|
||||||
|
{
|
||||||
|
if (boundary.SourceNodeId == sourceNodeId && string.Equals(boundary.SourceHandle ?? string.Empty, sourceHandle ?? string.Empty, StringComparison.Ordinal))
|
||||||
|
yield return boundary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CompletedHandleKey(string nodeId, string? handle) => nodeId + ":" + (handle ?? string.Empty);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Session of a scenario battle-pass-level node.</summary>
|
||||||
|
public sealed class BattlePassLevelSession
|
||||||
|
{
|
||||||
|
internal BattlePassLevelSession(ScenarioNodeContext context) => Context = context;
|
||||||
|
|
||||||
|
/// <summary>Underlying node context.</summary>
|
||||||
|
public ScenarioNodeContext Context { get; }
|
||||||
|
|
||||||
|
/// <summary>Node id.</summary>
|
||||||
|
public string Id => Context.NodeId;
|
||||||
|
|
||||||
|
/// <summary>Reads a typed value from the node data.</summary>
|
||||||
|
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onComplete handle.</summary>
|
||||||
|
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onComplete handle (fire-and-forget).</summary>
|
||||||
|
public void Complete() => Context.Complete("onComplete");
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Session of a scenario battle-pass node.</summary>
|
||||||
|
public sealed class BattlePassSession
|
||||||
|
{
|
||||||
|
internal BattlePassSession(ScenarioNodeContext context) => Context = context;
|
||||||
|
|
||||||
|
/// <summary>Underlying node context.</summary>
|
||||||
|
public ScenarioNodeContext Context { get; }
|
||||||
|
|
||||||
|
/// <summary>Node id.</summary>
|
||||||
|
public string Id => Context.NodeId;
|
||||||
|
|
||||||
|
/// <summary>Reads a typed value from the node data.</summary>
|
||||||
|
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onLevelUp handle.</summary>
|
||||||
|
public Task LevelUpAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onLevelUp", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onLevelUp handle (fire-and-forget).</summary>
|
||||||
|
public void LevelUp() => Context.Complete("onLevelUp");
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onMaxLevel handle.</summary>
|
||||||
|
public Task MaxLevelAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onMaxLevel", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onMaxLevel handle (fire-and-forget).</summary>
|
||||||
|
public void MaxLevel() => Context.Complete("onMaxLevel");
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onPremiumPurchase handle.</summary>
|
||||||
|
public Task PremiumPurchaseAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onPremiumPurchase", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onPremiumPurchase handle (fire-and-forget).</summary>
|
||||||
|
public void PremiumPurchase() => Context.Complete("onPremiumPurchase");
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onComplete handle.</summary>
|
||||||
|
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onComplete handle (fire-and-forget).</summary>
|
||||||
|
public void Complete() => Context.Complete("onComplete");
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session of a scenario remote-config-override node. The patches are already
|
||||||
|
/// applied to <see cref="RemoteConfigService"/> when the event fires.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfigChangedSession
|
||||||
|
{
|
||||||
|
internal ConfigChangedSession(ScenarioNodeContext context) => Context = context;
|
||||||
|
|
||||||
|
/// <summary>Underlying node context.</summary>
|
||||||
|
public ScenarioNodeContext Context { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Session of a scenario leaderboard node.</summary>
|
||||||
|
public sealed class LeaderboardSession
|
||||||
|
{
|
||||||
|
internal LeaderboardSession(ScenarioNodeContext context) => Context = context;
|
||||||
|
|
||||||
|
/// <summary>Underlying node context.</summary>
|
||||||
|
public ScenarioNodeContext Context { get; }
|
||||||
|
|
||||||
|
/// <summary>Node id.</summary>
|
||||||
|
public string Id => Context.NodeId;
|
||||||
|
|
||||||
|
/// <summary>Reads a typed value from the node data.</summary>
|
||||||
|
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onEnd handle.</summary>
|
||||||
|
public Task EndAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onEnd", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onEnd handle (fire-and-forget).</summary>
|
||||||
|
public void End() => Context.Complete("onEnd");
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onRewardClaimed handle.</summary>
|
||||||
|
public Task RewardClaimedAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onRewardClaimed", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onRewardClaimed handle (fire-and-forget).</summary>
|
||||||
|
public void RewardClaimed() => Context.Complete("onRewardClaimed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Session of a scenario notification node.</summary>
|
||||||
|
public sealed class NotificationSession
|
||||||
|
{
|
||||||
|
internal NotificationSession(ScenarioNodeContext context) => Context = context;
|
||||||
|
|
||||||
|
/// <summary>Underlying node context.</summary>
|
||||||
|
public ScenarioNodeContext Context { get; }
|
||||||
|
|
||||||
|
/// <summary>Node id.</summary>
|
||||||
|
public string Id => Context.NodeId;
|
||||||
|
|
||||||
|
/// <summary>Reads a typed value from the node data.</summary>
|
||||||
|
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the output handle.</summary>
|
||||||
|
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("output", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the output handle (fire-and-forget).</summary>
|
||||||
|
public void Complete() => Context.Complete("output");
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Session of a scenario quest node.</summary>
|
||||||
|
public sealed class QuestSession
|
||||||
|
{
|
||||||
|
internal QuestSession(ScenarioNodeContext context) => Context = context;
|
||||||
|
|
||||||
|
/// <summary>Underlying node context.</summary>
|
||||||
|
public ScenarioNodeContext Context { get; }
|
||||||
|
|
||||||
|
/// <summary>Node id.</summary>
|
||||||
|
public string Id => Context.NodeId;
|
||||||
|
|
||||||
|
/// <summary>Reads a typed value from the node data.</summary>
|
||||||
|
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
|
||||||
|
|
||||||
|
/// <summary>Adds progress to one of the node's counters.</summary>
|
||||||
|
public Task AddProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default) => Context.AddProgressAsync(counterKey, amount, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Adds progress to one of the node's counters (fire-and-forget).</summary>
|
||||||
|
public void AddProgress(string counterKey, long amount) => _ = AddProgressAsync(counterKey, amount);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onComplete handle.</summary>
|
||||||
|
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onComplete handle (fire-and-forget).</summary>
|
||||||
|
public void Complete() => Context.Complete("onComplete");
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onFail handle.</summary>
|
||||||
|
public Task FailAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onFail", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Advances the run through the onFail handle (fire-and-forget).</summary>
|
||||||
|
public void Fail() => Context.Complete("onFail");
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Session of a scenario store-offer node; resolve it with a purchase or a decline.</summary>
|
||||||
|
public sealed class StoreOfferSession
|
||||||
|
{
|
||||||
|
internal StoreOfferSession(ScenarioNodeContext context)
|
||||||
|
{
|
||||||
|
Context = context;
|
||||||
|
Data = context.AsObjectDictionary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Underlying node context.</summary>
|
||||||
|
public ScenarioNodeContext Context { get; }
|
||||||
|
|
||||||
|
/// <summary>Node id.</summary>
|
||||||
|
public string Id => Context.NodeId;
|
||||||
|
|
||||||
|
/// <summary>Node data payload.</summary>
|
||||||
|
public IReadOnlyDictionary<string, object> Data { get; }
|
||||||
|
|
||||||
|
/// <summary>True after the session was resolved once.</summary>
|
||||||
|
public bool IsResolved { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Reads a typed value from the node data.</summary>
|
||||||
|
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
|
||||||
|
|
||||||
|
/// <summary>Resolves the offer as purchased.</summary>
|
||||||
|
public Task PurchaseAsync(CancellationToken cancellationToken = default) => ResolveAsync("onPurchase", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Resolves the offer as purchased (fire-and-forget).</summary>
|
||||||
|
public void Purchase() => Resolve("onPurchase");
|
||||||
|
|
||||||
|
/// <summary>Resolves the offer as declined.</summary>
|
||||||
|
public Task DeclineAsync(CancellationToken cancellationToken = default) => ResolveAsync("onDecline", cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Resolves the offer as declined (fire-and-forget).</summary>
|
||||||
|
public void Decline() => Resolve("onDecline");
|
||||||
|
|
||||||
|
private async Task ResolveAsync(string handle, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (IsResolved) return;
|
||||||
|
IsResolved = true;
|
||||||
|
await Context.CompleteAsync(handle, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Resolve(string handle)
|
||||||
|
{
|
||||||
|
if (IsResolved) return;
|
||||||
|
IsResolved = true;
|
||||||
|
Context.Complete(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Session of a scenario wait node; the run continues automatically at the deadline.</summary>
|
||||||
|
public sealed class WaitSession
|
||||||
|
{
|
||||||
|
internal WaitSession(ScenarioNodeContext context, DateTimeOffset deadlineUtc)
|
||||||
|
{
|
||||||
|
Context = context;
|
||||||
|
DeadlineUtc = deadlineUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Underlying node context.</summary>
|
||||||
|
public ScenarioNodeContext Context { get; }
|
||||||
|
|
||||||
|
/// <summary>When the wait ends (UTC).</summary>
|
||||||
|
public DateTimeOffset DeadlineUtc { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Models.Storage;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>Player key-value storage.</summary>
|
||||||
|
public sealed class StorageService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
|
||||||
|
internal StorageService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>Fetches one page of storage items, optionally filtered by type.</summary>
|
||||||
|
public Task<GetStorageResponse> GetAsync(string? type = null, int limit = 100, string? cursor = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = Url.Query(
|
||||||
|
("types", type),
|
||||||
|
("limit", limit > 0 ? limit.ToString() : null),
|
||||||
|
("cursor", cursor));
|
||||||
|
|
||||||
|
return _client.SendAsync<GetStorageResponse>("GET", "/sdk/v1/storage" + query, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Iterates over all storage items, following the cursor pagination.</summary>
|
||||||
|
public async IAsyncEnumerable<StorageItem> ListAllAsync(
|
||||||
|
string? type = null,
|
||||||
|
int limit = 100,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string? cursor = null;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var page = await GetAsync(type, limit, cursor, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (page?.Items != null)
|
||||||
|
{
|
||||||
|
foreach (var item in page.Items)
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = page?.NextCursor;
|
||||||
|
}
|
||||||
|
while (!string.IsNullOrEmpty(cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Saves (upserts) storage items.</summary>
|
||||||
|
public Task SaveAsync(IEnumerable<StorageItem> items, CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync(
|
||||||
|
"PUT",
|
||||||
|
"/sdk/v1/storage",
|
||||||
|
new UpdateStorageRequest { Items = items?.ToList() ?? new List<StorageItem>() },
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Deletes all items of the given type.</summary>
|
||||||
|
public Task DeleteAsync(string type, CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync("DELETE", "/sdk/v1/storage" + Url.Query(("type", type)), cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RudderSdk.Core.Models.Stores;
|
||||||
|
|
||||||
|
namespace RudderSdk.Core;
|
||||||
|
|
||||||
|
/// <summary>In-game stores and offer purchases.</summary>
|
||||||
|
public sealed class StoresService
|
||||||
|
{
|
||||||
|
private readonly RudderClient _client;
|
||||||
|
|
||||||
|
internal StoresService(RudderClient client) => _client = client;
|
||||||
|
|
||||||
|
/// <summary>Lists the available stores with their offers.</summary>
|
||||||
|
public async Task<IReadOnlyList<Store>> ListAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync<ListStoresResponse>(
|
||||||
|
"GET",
|
||||||
|
"/sdk/v1/stores",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return response?.Stores ?? new List<Store>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves one store by slug.</summary>
|
||||||
|
public Task<Store> GetAsync(string slug, CancellationToken cancellationToken = default)
|
||||||
|
=> _client.SendAsync<Store>("GET", "/sdk/v1/stores/" + Url.Encode(slug), cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purchases an offer (charges the wallet). When
|
||||||
|
/// <paramref name="idempotencyKey"/> is null, a random one is generated;
|
||||||
|
/// pass a stable key to make retries safe.
|
||||||
|
/// </summary>
|
||||||
|
public Task<PurchaseOfferResponse> PurchaseAsync(
|
||||||
|
string storeSlug,
|
||||||
|
string offerId,
|
||||||
|
string? idempotencyKey = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _client.SendAsync<PurchaseOfferRequest, PurchaseOfferResponse>(
|
||||||
|
"POST",
|
||||||
|
"/sdk/v1/stores/" + Url.Encode(storeSlug) + "/offers/" + Url.Encode(offerId) + "/purchase",
|
||||||
|
new PurchaseOfferRequest
|
||||||
|
{
|
||||||
|
StoreSlug = storeSlug,
|
||||||
|
OfferId = offerId,
|
||||||
|
IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString()
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user