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