commit 8cf738d9f09ce45774f9ee8512672024f4eee248 Author: rudder Date: Wed Aug 12 14:04:55 2026 +0300 Initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..26a5be2 Binary files /dev/null and b/.DS_Store differ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd65f35 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4bc275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +.vs/ +.idea/ +TestResults/ diff --git a/Abstractions/IClock.cs b/Abstractions/IClock.cs new file mode 100644 index 0000000..f1b6896 --- /dev/null +++ b/Abstractions/IClock.cs @@ -0,0 +1,10 @@ +using System; + +namespace RudderSdk.Core.Abstractions; + +/// Time source used by the scenario runtime; override in tests. +public interface IClock +{ + /// Current UTC time. + DateTimeOffset UtcNow { get; } +} diff --git a/Abstractions/IDeviceIdProvider.cs b/Abstractions/IDeviceIdProvider.cs new file mode 100644 index 0000000..c92c17e --- /dev/null +++ b/Abstractions/IDeviceIdProvider.cs @@ -0,0 +1,12 @@ +namespace RudderSdk.Core.Abstractions; + +/// +/// Provides a stable device identifier for device login. +/// The default is ; games should provide a +/// persistent one (Unity uses SystemInfo.deviceUniqueIdentifier). +/// +public interface IDeviceIdProvider +{ + /// Stable identifier of this device/install. + string DeviceId { get; } +} diff --git a/Abstractions/IPlanScheduler.cs b/Abstractions/IPlanScheduler.cs new file mode 100644 index 0000000..d1fad85 --- /dev/null +++ b/Abstractions/IPlanScheduler.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core.Abstractions; + +/// Optional scheduler abstraction for delayed plan work. +public interface IPlanScheduler +{ + /// Completes after the given delay. + Task ScheduleAsync(TimeSpan delay, CancellationToken cancellationToken = default); +} diff --git a/Abstractions/IPlanStateStore.cs b/Abstractions/IPlanStateStore.cs new file mode 100644 index 0000000..5c26c61 --- /dev/null +++ b/Abstractions/IPlanStateStore.cs @@ -0,0 +1,11 @@ +namespace RudderSdk.Core.Abstractions; + +/// +/// Persists serialized scenario-run state between app launches. +/// Null by default (no persistence). +/// +public interface IPlanStateStore +{ + /// Serialized state blob, or null when empty. + string? State { get; set; } +} diff --git a/Abstractions/IRealtimeTransport.cs b/Abstractions/IRealtimeTransport.cs new file mode 100644 index 0000000..0bfb160 --- /dev/null +++ b/Abstractions/IRealtimeTransport.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core.Abstractions; + +/// +/// Low-level realtime (websocket) transport. There is no default implementation; +/// provide one through (Unity ships its own). +/// +public interface IRealtimeTransport +{ + /// Raised when the connection closes. + event Action Closed; + + /// Raised for every incoming message. + event Action> Received; + + /// Raised on transport errors. + event Action Error; + + /// True while the connection is open. + bool IsConnected { get; } + + /// Opens the connection, giving up after . + Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// Sends one message. + Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default); + + /// Closes the connection. + Task CloseAsync(CancellationToken cancellationToken = default); + + /// Pumps time-dependent logic; call every frame. + void Update(float deltaTime); +} diff --git a/Abstractions/IRealtimeTransportFactory.cs b/Abstractions/IRealtimeTransportFactory.cs new file mode 100644 index 0000000..9b2e849 --- /dev/null +++ b/Abstractions/IRealtimeTransportFactory.cs @@ -0,0 +1,8 @@ +namespace RudderSdk.Core.Abstractions; + +/// Creates realtime transports on demand (one per connection). +public interface IRealtimeTransportFactory +{ + /// Creates a new, unconnected transport. + IRealtimeTransport Create(); +} diff --git a/Abstractions/IRudderLogger.cs b/Abstractions/IRudderLogger.cs new file mode 100644 index 0000000..c8ca531 --- /dev/null +++ b/Abstractions/IRudderLogger.cs @@ -0,0 +1,24 @@ +namespace RudderSdk.Core.Abstractions; + +/// Severity of an SDK log message. +public enum RudderLogLevel +{ + /// Verbose diagnostics. + Debug, + + /// Normal operational messages. + Info, + + /// Recoverable problems worth investigating. + Warning, + + /// Failures that broke an operation. + Error +} + +/// Optional sink for SDK diagnostics. Null by default (silent). +public interface IRudderLogger +{ + /// Writes one message at the given severity. + void Log(RudderLogLevel level, string message); +} diff --git a/Abstractions/IRudderTransport.cs b/Abstractions/IRudderTransport.cs new file mode 100644 index 0000000..7771cb8 --- /dev/null +++ b/Abstractions/IRudderTransport.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core.Abstractions; + +/// +/// 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 subclasses +/// (401 → ). +/// +public interface IRudderTransport +{ + /// + /// Sends one API request and deserializes the JSON response. + /// + /// HTTP method (GET, POST, PUT, DELETE). + /// Absolute path starting at the API root, e.g. /sdk/v1/player/information. + /// Request body DTO, or null for bodyless requests. + /// Current access token to send as a Bearer header, or null. + /// Cancellation token. + Task SendAsync( + string method, + string path, + TRequest? request, + string? accessToken, + CancellationToken cancellationToken = default); +} diff --git a/Abstractions/ITokenStore.cs b/Abstractions/ITokenStore.cs new file mode 100644 index 0000000..6ee6c5f --- /dev/null +++ b/Abstractions/ITokenStore.cs @@ -0,0 +1,20 @@ +namespace RudderSdk.Core.Abstractions; + +/// +/// Persists the session token pair. The default is ; +/// games should provide a durable implementation (player prefs, keychain, ...). +/// +public interface ITokenStore +{ + /// Returns the current access token, or null when signed out. + string? GetAccessToken(); + + /// Returns the current refresh token, or null when signed out. + string? GetRefreshToken(); + + /// Stores a fresh token pair. + void SaveTokens(string accessToken, string refreshToken); + + /// Drops both tokens (logout / session expiry). + void Clear(); +} diff --git a/Abstractions/IUploadTransport.cs b/Abstractions/IUploadTransport.cs new file mode 100644 index 0000000..b403c0a --- /dev/null +++ b/Abstractions/IUploadTransport.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core.Abstractions; + +/// +/// Uploads raw bytes to pre-signed UGC URLs. Optional; required only by +/// . +/// +public interface IUploadTransport +{ + /// PUTs the payload to the pre-signed URL with the given content type. + Task PutAsync(string url, byte[] data, string contentType, CancellationToken cancellationToken = default); +} diff --git a/Auth/LoginViaDeviceRequest.cs b/Auth/LoginViaDeviceRequest.cs new file mode 100644 index 0000000..e58e8b3 --- /dev/null +++ b/Auth/LoginViaDeviceRequest.cs @@ -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; } + +} diff --git a/Auth/LoginViaDeviceResponse.cs b/Auth/LoginViaDeviceResponse.cs new file mode 100644 index 0000000..15df7b7 --- /dev/null +++ b/Auth/LoginViaDeviceResponse.cs @@ -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; } + +} diff --git a/Auth/RefreshAccessTokenRequest.cs b/Auth/RefreshAccessTokenRequest.cs new file mode 100644 index 0000000..53193d0 --- /dev/null +++ b/Auth/RefreshAccessTokenRequest.cs @@ -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; } + +} diff --git a/Auth/RefreshAccessTokenResponse.cs b/Auth/RefreshAccessTokenResponse.cs new file mode 100644 index 0000000..8d0d4e4 --- /dev/null +++ b/Auth/RefreshAccessTokenResponse.cs @@ -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; } + +} diff --git a/BattlePass/AddBattlePassXpRequest.cs b/BattlePass/AddBattlePassXpRequest.cs new file mode 100644 index 0000000..b62701b --- /dev/null +++ b/BattlePass/AddBattlePassXpRequest.cs @@ -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; } + +} diff --git a/BattlePass/AddBattlePassXpResponse.cs b/BattlePass/AddBattlePassXpResponse.cs new file mode 100644 index 0000000..561e286 --- /dev/null +++ b/BattlePass/AddBattlePassXpResponse.cs @@ -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; } + +} diff --git a/BattlePass/BattlePassReward.cs b/BattlePass/BattlePassReward.cs new file mode 100644 index 0000000..9ef1aa8 --- /dev/null +++ b/BattlePass/BattlePassReward.cs @@ -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; } + +} diff --git a/BattlePass/ClaimBattlePassRewardRequest.cs b/BattlePass/ClaimBattlePassRewardRequest.cs new file mode 100644 index 0000000..ebd6752 --- /dev/null +++ b/BattlePass/ClaimBattlePassRewardRequest.cs @@ -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; } + +} diff --git a/BattlePass/ClaimBattlePassRewardResponse.cs b/BattlePass/ClaimBattlePassRewardResponse.cs new file mode 100644 index 0000000..0f45aa4 --- /dev/null +++ b/BattlePass/ClaimBattlePassRewardResponse.cs @@ -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 Granted { get; set; } + + [JsonProperty("success")] + public bool Success { get; set; } + +} diff --git a/BattlePass/ClaimedTier.cs b/BattlePass/ClaimedTier.cs new file mode 100644 index 0000000..2e39486 --- /dev/null +++ b/BattlePass/ClaimedTier.cs @@ -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; } + +} diff --git a/BattlePass/GetBattlePassProgressRequest.cs b/BattlePass/GetBattlePassProgressRequest.cs new file mode 100644 index 0000000..34dff9d --- /dev/null +++ b/BattlePass/GetBattlePassProgressRequest.cs @@ -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; } + +} diff --git a/BattlePass/GetBattlePassProgressResponse.cs b/BattlePass/GetBattlePassProgressResponse.cs new file mode 100644 index 0000000..14b77f8 --- /dev/null +++ b/BattlePass/GetBattlePassProgressResponse.cs @@ -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 ClaimedTiers { get; set; } + + [JsonProperty("level")] + public int Level { get; set; } + + [JsonProperty("premiumOwned")] + public bool PremiumOwned { get; set; } + + [JsonProperty("xp")] + public long Xp { get; set; } + +} diff --git a/BattlePass/PurchaseBattlePassPremiumRequest.cs b/BattlePass/PurchaseBattlePassPremiumRequest.cs new file mode 100644 index 0000000..f6f6393 --- /dev/null +++ b/BattlePass/PurchaseBattlePassPremiumRequest.cs @@ -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; } + +} diff --git a/BattlePass/PurchaseBattlePassPremiumResponse.cs b/BattlePass/PurchaseBattlePassPremiumResponse.cs new file mode 100644 index 0000000..79190fb --- /dev/null +++ b/BattlePass/PurchaseBattlePassPremiumResponse.cs @@ -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; } + +} diff --git a/BoundaryNode.cs b/BoundaryNode.cs new file mode 100644 index 0000000..1adfa56 --- /dev/null +++ b/BoundaryNode.cs @@ -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; } + +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6a07c8a --- /dev/null +++ b/CHANGELOG.md @@ -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` 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.`); 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`. +- `StoresService.ListAsync` returns `IReadOnlyList`. +- `QuestsService.ListAsync` returns `IReadOnlyList`. +- `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 (`enable`). +- Remote config cache now includes every config except an explicit + `"active": false` (a missing flag means active). diff --git a/Catalog/CatalogItem.cs b/Catalog/CatalogItem.cs new file mode 100644 index 0000000..a7ee673 --- /dev/null +++ b/Catalog/CatalogItem.cs @@ -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 Tags { get; set; } + +} diff --git a/Catalog/ListCatalogItemsResponse.cs b/Catalog/ListCatalogItemsResponse.cs new file mode 100644 index 0000000..cd69c0a --- /dev/null +++ b/Catalog/ListCatalogItemsResponse.cs @@ -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 Items { get; set; } + +} diff --git a/ErrorResponse.cs b/ErrorResponse.cs new file mode 100644 index 0000000..15c1cc6 --- /dev/null +++ b/ErrorResponse.cs @@ -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; } + +} diff --git a/ExecutionPlan.cs b/ExecutionPlan.cs new file mode 100644 index 0000000..0230c58 --- /dev/null +++ b/ExecutionPlan.cs @@ -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 BoundaryNodes { get; set; } + + [JsonProperty("context")] + public JToken Context { get; set; } + + [JsonProperty("edges")] + public List Edges { get; set; } + + [JsonProperty("nodes")] + public List 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; } + +} diff --git a/ExecutionPlanNode.cs b/ExecutionPlanNode.cs new file mode 100644 index 0000000..7a0dba9 --- /dev/null +++ b/ExecutionPlanNode.cs @@ -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; } + +} diff --git a/GuidDeviceIdProvider.cs b/GuidDeviceIdProvider.cs new file mode 100644 index 0000000..7cab791 --- /dev/null +++ b/GuidDeviceIdProvider.cs @@ -0,0 +1,15 @@ +using System; +using RudderSdk.Core.Abstractions; + +namespace RudderSdk.Core; + +/// +/// Default generating a random GUID per app run. +/// Every run looks like a fresh device to the backend; provide a persistent +/// implementation for production. +/// +public sealed class GuidDeviceIdProvider : IDeviceIdProvider +{ + /// + public string DeviceId { get; } = Guid.NewGuid().ToString("N"); +} diff --git a/HttpClientTransport.cs b/HttpClientTransport.cs new file mode 100644 index 0000000..8b05534 --- /dev/null +++ b/HttpClientTransport.cs @@ -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; + +/// +/// Default built on . +/// Serializes bodies with Newtonsoft.Json, attaches the bearer token, maps +/// non-success statuses to subclasses and +/// network failures to . +/// +public sealed class HttpClientTransport : IRudderTransport +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + + private readonly string _baseUrl; + private readonly HttpClient _httpClient; + + /// + /// Creates the transport for the given API base URL. When no + /// is supplied, an owned instance with a + /// 10-second request timeout is created. + /// + 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 }; + } + + /// + public async Task SendAsync( + 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(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(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), + }; + } +} diff --git a/InMemoryTokenStore.cs b/InMemoryTokenStore.cs new file mode 100644 index 0000000..24fd485 --- /dev/null +++ b/InMemoryTokenStore.cs @@ -0,0 +1,33 @@ +using RudderSdk.Core.Abstractions; + +namespace RudderSdk.Core; + +/// +/// Default keeping tokens in memory only. Sessions do +/// not survive an app restart; provide a durable implementation for production. +/// +public sealed class InMemoryTokenStore : ITokenStore +{ + private string? _accessToken; + private string? _refreshToken; + + /// + public string? GetAccessToken() => _accessToken; + + /// + public string? GetRefreshToken() => _refreshToken; + + /// + public void SaveTokens(string accessToken, string refreshToken) + { + _accessToken = accessToken; + _refreshToken = refreshToken; + } + + /// + public void Clear() + { + _accessToken = null; + _refreshToken = null; + } +} diff --git a/Inventory/GetInventoryResponse.cs b/Inventory/GetInventoryResponse.cs new file mode 100644 index 0000000..0d26b3a --- /dev/null +++ b/Inventory/GetInventoryResponse.cs @@ -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 Items { get; set; } + +} diff --git a/Inventory/PlayerInventoryItem.cs b/Inventory/PlayerInventoryItem.cs new file mode 100644 index 0000000..3f3d375 --- /dev/null +++ b/Inventory/PlayerInventoryItem.cs @@ -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; } + +} diff --git a/Leaderboards/GetRankingResponse.cs b/Leaderboards/GetRankingResponse.cs new file mode 100644 index 0000000..8dfbbba --- /dev/null +++ b/Leaderboards/GetRankingResponse.cs @@ -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 Entries { get; set; } + + [JsonProperty("total")] + public long Total { get; set; } + +} diff --git a/Leaderboards/RankEntry.cs b/Leaderboards/RankEntry.cs new file mode 100644 index 0000000..8f64890 --- /dev/null +++ b/Leaderboards/RankEntry.cs @@ -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; } + +} diff --git a/Leaderboards/SubmitScoreRequest.cs b/Leaderboards/SubmitScoreRequest.cs new file mode 100644 index 0000000..69436f5 --- /dev/null +++ b/Leaderboards/SubmitScoreRequest.cs @@ -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; } + +} diff --git a/PlanEdge.cs b/PlanEdge.cs new file mode 100644 index 0000000..2f2969f --- /dev/null +++ b/PlanEdge.cs @@ -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; } + +} diff --git a/Player/Player.cs b/Player/Player.cs new file mode 100644 index 0000000..7f6b751 --- /dev/null +++ b/Player/Player.cs @@ -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; } + +} diff --git a/Player/PlayerProfile.cs b/Player/PlayerProfile.cs new file mode 100644 index 0000000..4f61035 --- /dev/null +++ b/Player/PlayerProfile.cs @@ -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 Wallets { get; set; } + +} diff --git a/Player/Wallet.cs b/Player/Wallet.cs new file mode 100644 index 0000000..363cd65 --- /dev/null +++ b/Player/Wallet.cs @@ -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; } + +} diff --git a/Quests/ClaimQuestRequest.cs b/Quests/ClaimQuestRequest.cs new file mode 100644 index 0000000..373e834 --- /dev/null +++ b/Quests/ClaimQuestRequest.cs @@ -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; } + +} diff --git a/Quests/ClaimQuestResponse.cs b/Quests/ClaimQuestResponse.cs new file mode 100644 index 0000000..f1701f2 --- /dev/null +++ b/Quests/ClaimQuestResponse.cs @@ -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 Granted { get; set; } + + [JsonProperty("success")] + public bool Success { get; set; } + +} diff --git a/Quests/ListQuestsRequest.cs b/Quests/ListQuestsRequest.cs new file mode 100644 index 0000000..08207e9 --- /dev/null +++ b/Quests/ListQuestsRequest.cs @@ -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 +{ +} diff --git a/Quests/ListQuestsResponse.cs b/Quests/ListQuestsResponse.cs new file mode 100644 index 0000000..b5eb3c0 --- /dev/null +++ b/Quests/ListQuestsResponse.cs @@ -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 Quests { get; set; } + +} diff --git a/Quests/Quest.cs b/Quests/Quest.cs new file mode 100644 index 0000000..fb9911f --- /dev/null +++ b/Quests/Quest.cs @@ -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 Objectives { get; set; } + + [JsonProperty("rewards")] + public List Rewards { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + +} diff --git a/Quests/QuestObjectiveProgress.cs b/Quests/QuestObjectiveProgress.cs new file mode 100644 index 0000000..7750834 --- /dev/null +++ b/Quests/QuestObjectiveProgress.cs @@ -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; } + +} diff --git a/Quests/QuestReward.cs b/Quests/QuestReward.cs new file mode 100644 index 0000000..2535a56 --- /dev/null +++ b/Quests/QuestReward.cs @@ -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; } + +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bd970e --- /dev/null +++ b/README.md @@ -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`, `GetAsync` | +| `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 +``` diff --git a/RemoteConfig/ListRemoteConfigsResponse.cs b/RemoteConfig/ListRemoteConfigsResponse.cs new file mode 100644 index 0000000..999d261 --- /dev/null +++ b/RemoteConfig/ListRemoteConfigsResponse.cs @@ -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 Configs { get; set; } + +} diff --git a/RemoteConfig/RemoteConfig.cs b/RemoteConfig/RemoteConfig.cs new file mode 100644 index 0000000..019c1d1 --- /dev/null +++ b/RemoteConfig/RemoteConfig.cs @@ -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; } + +} diff --git a/Rudder.Core.csproj b/Rudder.Core.csproj new file mode 100644 index 0000000..f24a32d --- /dev/null +++ b/Rudder.Core.csproj @@ -0,0 +1,23 @@ + + + netstandard2.1 + latest + enable + Rudder.Core + RudderSdk.Core + Rudder.Core + 0.2.0 + Rudder + Rudder LiveOps client SDK for .NET: auth, player, stores, battle pass, quests, leaderboards, inventory, remote config, scenarios, storage, UGC and realtime. + MIT + true + + + + + + + + + + diff --git a/RudderApiException.cs b/RudderApiException.cs new file mode 100644 index 0000000..cba23d6 --- /dev/null +++ b/RudderApiException.cs @@ -0,0 +1,39 @@ +using System; + +namespace RudderSdk.Core; + +/// +/// Base exception for every API failure. Carries the HTTP status code +/// (0 for network failures, see ), the +/// machine-readable API error code (see ) and the +/// server-issued request id when available. +/// +public class RudderApiException : Exception +{ + /// HTTP status code, or 0 when the request never reached the server. + public int StatusCode { get; } + + /// Machine-readable API error code, or an empty string. + public string Code { get; } + + /// Server-issued request id for support tickets, or null. + public string? RequestId { get; } + + /// Creates the exception. + public RudderApiException(int statusCode, string? code, string message, string? requestId = null) + : base(message) + { + StatusCode = statusCode; + Code = code ?? string.Empty; + RequestId = requestId; + } + + /// Creates the exception wrapping an inner transport error. + public RudderApiException(int statusCode, string? code, string message, string? requestId, Exception? innerException) + : base(message, innerException) + { + StatusCode = statusCode; + Code = code ?? string.Empty; + RequestId = requestId; + } +} diff --git a/RudderAuthException.cs b/RudderAuthException.cs new file mode 100644 index 0000000..0a1d06a --- /dev/null +++ b/RudderAuthException.cs @@ -0,0 +1,15 @@ +namespace RudderSdk.Core; + +/// +/// 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. +/// +public sealed class RudderAuthException : RudderApiException +{ + /// Creates the exception. + public RudderAuthException(int statusCode, string? code, string message, string? requestId = null) + : base(statusCode, code, message, requestId) + { + } +} diff --git a/RudderAuthState.cs b/RudderAuthState.cs new file mode 100644 index 0000000..ae4c863 --- /dev/null +++ b/RudderAuthState.cs @@ -0,0 +1,11 @@ +namespace RudderSdk.Core; + +/// Session state reported by . +public enum RudderAuthState +{ + /// A token pair is stored; API calls carry it. + SignedIn, + + /// No valid session: logged out or the refresh token was rejected. + SignedOut +} diff --git a/RudderClient.cs b/RudderClient.cs new file mode 100644 index 0000000..b1fe8f6 --- /dev/null +++ b/RudderClient.cs @@ -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; + +/// +/// Entry point of the Rudder SDK. Composes all feature services around one +/// configuration () and owns the session +/// lifecycle: every API call goes through here, a 401 triggers a single-flight +/// token refresh and one transparent retry. +/// +public sealed class RudderClient +{ + private readonly object _refreshGate = new(); + private Task? _pendingRefresh; + + /// Authentication: device login, token refresh, logout. + public AuthService Auth { get; } + + /// Player profile and wallets. + public PlayerService Player { get; } + + /// Remote configuration values. + public RemoteConfigService RemoteConfig { get; } + + /// Player key-value storage. + public StorageService Storage { get; } + + /// In-game stores and offer purchases. + public StoresService Stores { get; } + + /// Leaderboards. + public LeaderboardsService Leaderboards { get; } + + /// Player inventory. + public InventoryService Inventory { get; } + + /// Battle pass progress and rewards. + public BattlePassService BattlePass { get; } + + /// Global quests. + public QuestsService Quests { get; } + + /// User-generated content. + public UgcService Ugc { get; } + + /// Scenario runtime: triggers, node sessions, persistence. + public ScenarioService Scenario { get; } + + /// Realtime websocket channel. + public RealtimeService Realtime { get; } + + internal RudderClientOptions Options { get; } + internal IRudderTransport Transport => Options.Transport!; + internal ITokenStore TokenStore => Options.TokenStore!; + internal IDeviceIdProvider DeviceIdProvider => Options.DeviceIdProvider!; + + /// Project key from . + public string ProjectKey => Options.ProjectKey!; + + /// Realtime URL from . + public string? RealtimeUrl => Options.RealtimeUrl; + + /// Time source used by the scenario runtime. + public IClock Clock => Options.Clock ?? SystemClock.Instance; + + /// + /// Creates the client. and + /// are required; transport, + /// token store and device id provider fall back to defaults. + /// + 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); + } + + /// Pumps time-dependent services; call every frame. + public void Update(float deltaTime) + { + Scenario.Update(deltaTime); + Realtime.Update(deltaTime); + } + + internal Task SendAsync( + string method, + string path, + TRequest? request, + CancellationToken cancellationToken = default) + { + return SendWithRetryAsync(method, path, request, cancellationToken); + } + + internal Task SendAsync( + string method, + string path, + CancellationToken cancellationToken = default) + { + return SendWithRetryAsync(method, path, null, cancellationToken); + } + + internal Task SendAsync( + string method, + string path, + TRequest? request, + CancellationToken cancellationToken = default) + { + return SendWithRetryAsync(method, path, request, cancellationToken); + } + + internal Task SendAsync( + string method, + string path, + CancellationToken cancellationToken = default) + { + return SendWithRetryAsync(method, path, null, cancellationToken); + } + + private async Task SendWithRetryAsync( + string method, + string path, + TRequest? request, + CancellationToken cancellationToken) + { + try + { + return await Transport + .SendAsync(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(method, path, request, TokenStore.GetAccessToken(), cancellationToken) + .ConfigureAwait(false); + } + } + + /// + /// 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 + /// . + /// + internal Task 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 DoRefreshTokensAsync() + { + var refreshToken = TokenStore.GetRefreshToken(); + if (!string.IsNullOrEmpty(refreshToken)) + { + try + { + var response = await Transport + .SendAsync( + "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 +{ +} diff --git a/RudderClientOptions.cs b/RudderClientOptions.cs new file mode 100644 index 0000000..eacb979 --- /dev/null +++ b/RudderClientOptions.cs @@ -0,0 +1,47 @@ +using RudderSdk.Core.Abstractions; + +namespace RudderSdk.Core; + +/// +/// Configuration for . Only and +/// are required; everything else falls back to a +/// sensible default. +/// +public sealed class RudderClientOptions +{ + /// API base URL, e.g. https://api.example.com. Required. + public string? BaseUrl { get; set; } + + /// Realtime websocket URL. Required only for . + public string? RealtimeUrl { get; set; } + + /// Project key issued in the admin panel. Required. + public string? ProjectKey { get; set; } + + /// HTTP transport. Defaults to . + public IRudderTransport? Transport { get; set; } + + /// Upload transport for UGC file uploads. Optional. + public IUploadTransport? UploadTransport { get; set; } + + /// Session token storage. Defaults to . + public ITokenStore? TokenStore { get; set; } + + /// Device identity for login. Defaults to . + public IDeviceIdProvider? DeviceIdProvider { get; set; } + + /// Diagnostic sink. Null by default (silent). + public IRudderLogger? Logger { get; set; } + + /// Time source for the scenario runtime; override in tests. + public IClock? Clock { get; set; } + + /// Scenario-run persistence between app launches. Optional. + public IPlanStateStore? PlanStateStore { get; set; } + + /// Optional scheduler for delayed plan work. + public IPlanScheduler? Scheduler { get; set; } + + /// Realtime transport factory. Required only for . + public IRealtimeTransportFactory? RealtimeTransportFactory { get; set; } +} diff --git a/RudderErrorCodes.cs b/RudderErrorCodes.cs new file mode 100644 index 0000000..777e8c3 --- /dev/null +++ b/RudderErrorCodes.cs @@ -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"; +} diff --git a/RudderNetworkException.cs b/RudderNetworkException.cs new file mode 100644 index 0000000..4d7233b --- /dev/null +++ b/RudderNetworkException.cs @@ -0,0 +1,17 @@ +using System; + +namespace RudderSdk.Core; + +/// +/// The request never got a response: DNS/connectivity failure or client-side +/// timeout. is 0. Retrying is safe +/// for idempotent operations. +/// +public sealed class RudderNetworkException : RudderApiException +{ + /// Creates the exception. + public RudderNetworkException(string message, Exception? innerException = null) + : base(0, string.Empty, message, null, innerException) + { + } +} diff --git a/RudderNotFoundException.cs b/RudderNotFoundException.cs new file mode 100644 index 0000000..4e1aae8 --- /dev/null +++ b/RudderNotFoundException.cs @@ -0,0 +1,11 @@ +namespace RudderSdk.Core; + +/// The requested resource does not exist (HTTP 404). +public sealed class RudderNotFoundException : RudderApiException +{ + /// Creates the exception. + public RudderNotFoundException(int statusCode, string? code, string message, string? requestId = null) + : base(statusCode, code, message, requestId) + { + } +} diff --git a/RudderRateLimitException.cs b/RudderRateLimitException.cs new file mode 100644 index 0000000..3da325d --- /dev/null +++ b/RudderRateLimitException.cs @@ -0,0 +1,11 @@ +namespace RudderSdk.Core; + +/// The client hit a server rate limit (HTTP 429); back off and retry later. +public sealed class RudderRateLimitException : RudderApiException +{ + /// Creates the exception. + public RudderRateLimitException(int statusCode, string? code, string message, string? requestId = null) + : base(statusCode, code, message, requestId) + { + } +} diff --git a/Scenarios/GetScenarioRunRequest.cs b/Scenarios/GetScenarioRunRequest.cs new file mode 100644 index 0000000..f0088d9 --- /dev/null +++ b/Scenarios/GetScenarioRunRequest.cs @@ -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; } + +} diff --git a/Scenarios/GetScenarioRunResponse.cs b/Scenarios/GetScenarioRunResponse.cs new file mode 100644 index 0000000..47d6c59 --- /dev/null +++ b/Scenarios/GetScenarioRunResponse.cs @@ -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; } + +} diff --git a/Scenarios/HandleScenarioCallbackRequest.cs b/Scenarios/HandleScenarioCallbackRequest.cs new file mode 100644 index 0000000..cbeb678 --- /dev/null +++ b/Scenarios/HandleScenarioCallbackRequest.cs @@ -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; } + +} diff --git a/Scenarios/HandleScenarioCallbackResponse.cs b/Scenarios/HandleScenarioCallbackResponse.cs new file mode 100644 index 0000000..d46975c --- /dev/null +++ b/Scenarios/HandleScenarioCallbackResponse.cs @@ -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; } + +} diff --git a/Scenarios/TriggerScenarioRequest.cs b/Scenarios/TriggerScenarioRequest.cs new file mode 100644 index 0000000..cd9974d --- /dev/null +++ b/Scenarios/TriggerScenarioRequest.cs @@ -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; } + +} diff --git a/Scenarios/TriggerScenarioResponse.cs b/Scenarios/TriggerScenarioResponse.cs new file mode 100644 index 0000000..f182f88 --- /dev/null +++ b/Scenarios/TriggerScenarioResponse.cs @@ -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 Plans { get; set; } + +} diff --git a/Scenarios/UpdateScenarioCounterRequest.cs b/Scenarios/UpdateScenarioCounterRequest.cs new file mode 100644 index 0000000..756f222 --- /dev/null +++ b/Scenarios/UpdateScenarioCounterRequest.cs @@ -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; } + +} diff --git a/Scenarios/UpdateScenarioCounterResponse.cs b/Scenarios/UpdateScenarioCounterResponse.cs new file mode 100644 index 0000000..285effd --- /dev/null +++ b/Scenarios/UpdateScenarioCounterResponse.cs @@ -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; } + +} diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..766fd09 --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using RudderSdk.Core.Models.Auth; + +namespace RudderSdk.Core; + +/// +/// Authentication: device login, explicit token refresh and logout. +/// Token refresh also happens automatically — see . +/// +public sealed class AuthService +{ + private readonly RudderClient _client; + + internal AuthService(RudderClient client) => _client = client; + + /// + /// Raised when the session state flips: after a successful login + /// () and after logout or a failed + /// token refresh (). + /// + public event Action? AuthStateChanged; + + /// + /// Signs the player in with the device identifier. The returned token pair + /// is stored in the configured token store. + /// + public async Task LoginWithDeviceAsync( + string region, + string language, + string? nickname = null, + CancellationToken cancellationToken = default) + { + var response = await _client.SendAsync( + "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; + } + + /// + /// 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. + /// + public Task RefreshAsync() => _client.RefreshTokensAsync(); + + /// Drops the stored session. + public void Logout() + { + _client.TokenStore.Clear(); + NotifySignedOut(); + } + + internal void NotifySignedIn() => AuthStateChanged?.Invoke(RudderAuthState.SignedIn); + + internal void NotifySignedOut() => AuthStateChanged?.Invoke(RudderAuthState.SignedOut); +} diff --git a/Services/BattlePassService.cs b/Services/BattlePassService.cs new file mode 100644 index 0000000..5e60094 --- /dev/null +++ b/Services/BattlePassService.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using RudderSdk.Core.Models.BattlePass; + +namespace RudderSdk.Core; + +/// +/// 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. +/// +public sealed class BattlePassService +{ + /// Free reward track, see . + public const string TrackFree = "free"; + + /// Premium reward track, see . + public const string TrackPremium = "premium"; + + private readonly RudderClient _client; + + internal BattlePassService(RudderClient client) => _client = client; + + /// Reads current progress: xp, level, premium ownership, claimed tiers. + public Task GetProgressAsync(CancellationToken cancellationToken = default) + => GetProgressAsync(new GetBattlePassProgressRequest(), cancellationToken); + + /// Credits xp and returns the new xp/level and level-up flags. + public Task AddXpAsync(long amount, CancellationToken cancellationToken = default) + => AddXpAsync(new AddBattlePassXpRequest { Amount = amount }, cancellationToken); + + /// + /// Claims a tier reward at a reached level (idempotent server-side). + /// is or . + /// + public Task ClaimRewardAsync(int level, string track, CancellationToken cancellationToken = default) + => ClaimRewardAsync(new ClaimBattlePassRewardRequest { Level = level, Track = track }, cancellationToken); + + /// + /// Purchases the premium track (charges the wallet). When + /// is null, a random one is generated. + /// + public Task PurchasePremiumAsync(string? idempotencyKey = null, CancellationToken cancellationToken = default) + => PurchasePremiumAsync(new PurchaseBattlePassPremiumRequest + { + IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString() + }, cancellationToken); + + internal Task GetProgressAsync(GetBattlePassProgressRequest request, CancellationToken cancellationToken = default) + => _client.SendAsync( + "POST", + "/sdk/v1/battlepass/progress", + request, + cancellationToken); + + internal Task AddXpAsync(AddBattlePassXpRequest request, CancellationToken cancellationToken = default) + => _client.SendAsync( + "POST", + "/sdk/v1/battlepass/xp", + request, + cancellationToken); + + internal Task ClaimRewardAsync(ClaimBattlePassRewardRequest request, CancellationToken cancellationToken = default) + => _client.SendAsync( + "POST", + "/sdk/v1/battlepass/claim", + request, + cancellationToken); + + internal Task PurchasePremiumAsync(PurchaseBattlePassPremiumRequest request, CancellationToken cancellationToken = default) + => _client.SendAsync( + "POST", + "/sdk/v1/battlepass/premium", + request, + cancellationToken); +} diff --git a/Services/InventoryService.cs b/Services/InventoryService.cs new file mode 100644 index 0000000..4751aec --- /dev/null +++ b/Services/InventoryService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RudderSdk.Core.Models.Inventory; + +namespace RudderSdk.Core; + +/// Player inventory. +public sealed class InventoryService +{ + private readonly RudderClient _client; + + internal InventoryService(RudderClient client) => _client = client; + + /// Lists the items the player owns. + public async Task> GetAsync(CancellationToken cancellationToken = default) + { + var response = await _client.SendAsync( + "GET", + "/sdk/v1/inventory", + cancellationToken).ConfigureAwait(false); + + return response?.Items ?? new List(); + } +} diff --git a/Services/LeaderboardsService.cs b/Services/LeaderboardsService.cs new file mode 100644 index 0000000..c45df7e --- /dev/null +++ b/Services/LeaderboardsService.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RudderSdk.Core.Models.Leaderboards; + +namespace RudderSdk.Core; + +/// +/// Leaderboards. Resolve a board with and work through +/// the returned . +/// +public sealed class LeaderboardsService +{ + private readonly RudderClient _client; + private readonly Dictionary _cache = new(); + + internal LeaderboardsService(RudderClient client) => _client = client; + + /// Returns the (cached) handle for the leaderboard with the given slug. + public LeaderboardHandle FindBySlug(string slug) + { + if (!_cache.TryGetValue(slug, out var leaderboard)) + { + leaderboard = new LeaderboardHandle(slug, this); + _cache[slug] = leaderboard; + } + + return leaderboard; + } + + internal Task GetRankingAsync(string slug, int limit, CancellationToken cancellationToken) + => _client.SendAsync( + "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); +} + +/// Operations on one leaderboard. +public sealed class LeaderboardHandle +{ + private readonly LeaderboardsService _service; + + internal LeaderboardHandle(string slug, LeaderboardsService service) + { + Slug = slug; + _service = service; + } + + /// Leaderboard slug. + public string Slug { get; } + + /// Entries fetched by the last call. + public IReadOnlyList Entries { get; private set; } = new List(); + + /// Submits a score for the current player. + public Task SubmitAsync(double score, CancellationToken cancellationToken = default) + => _service.SubmitScoreAsync(Slug, score, cancellationToken); + + /// Fetches the top entries and caches them in . + public async Task> ListAsync(int limit = 100, CancellationToken cancellationToken = default) + { + var response = await _service.GetRankingAsync(Slug, limit, cancellationToken).ConfigureAwait(false); + Entries = response?.Entries ?? new List(); + return Entries; + } +} diff --git a/Services/PlayerService.cs b/Services/PlayerService.cs new file mode 100644 index 0000000..9ca9823 --- /dev/null +++ b/Services/PlayerService.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using RudderSdk.Core.Models.Player; + +namespace RudderSdk.Core; + +/// Player profile data. +public sealed class PlayerService +{ + private readonly RudderClient _client; + + internal PlayerService(RudderClient client) => _client = client; + + /// Returns the player profile with wallets. + public Task GetProfileAsync(CancellationToken cancellationToken = default) + => _client.SendAsync("GET", "/sdk/v1/player/information", cancellationToken); +} diff --git a/Services/QuestsService.cs b/Services/QuestsService.cs new file mode 100644 index 0000000..e53e49d --- /dev/null +++ b/Services/QuestsService.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RudderSdk.Core.Models.Quests; + +namespace RudderSdk.Core; + +/// +/// Global quests (list + claim), distinct from scenario quest nodes which +/// advance through . +/// +public sealed class QuestsService +{ + private readonly RudderClient _client; + + internal QuestsService(RudderClient client) => _client = client; + + /// Lists the player's quests with per-objective progress and rewards. + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var response = await _client.SendAsync( + "POST", + "/sdk/v1/quests/list", + new ListQuestsRequest(), + cancellationToken).ConfigureAwait(false); + + return response?.Quests ?? new List(); + } + + /// Claims a completed quest's rewards (idempotent server-side). + public Task ClaimAsync(string questId, CancellationToken cancellationToken = default) + => _client.SendAsync( + "POST", + "/sdk/v1/quests/claim", + new ClaimQuestRequest { QuestId = questId }, + cancellationToken); +} diff --git a/Services/RealtimeService.cs b/Services/RealtimeService.cs new file mode 100644 index 0000000..be83d6a --- /dev/null +++ b/Services/RealtimeService.cs @@ -0,0 +1,105 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using RudderSdk.Core.Abstractions; + +namespace RudderSdk.Core; + +/// +/// Realtime websocket channel. Requires +/// and +/// . +/// +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; + + /// The current session, or null when not connected. + public RealtimeSession? Session => _session; + + /// True while a session is connected. + public bool IsConnected => _session?.IsConnected == true; + + /// Connects to the configured realtime URL. + public Task 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); + } + + /// Connects to an explicit realtime URL. + public async Task 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; + } + + /// Closes the current session, if any. + public Task DisconnectAsync(CancellationToken cancellationToken = default) + => _session?.DisconnectAsync(cancellationToken) ?? Task.CompletedTask; + + /// Pumps the underlying transport; call every frame. + public void Update(float deltaTime) + { + _session?.Update(deltaTime); + } +} + +/// An open realtime connection. +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); + } + + /// Access token the connection was authorized with. + public string AccessToken { get; } + + /// True while the connection is open. + public bool IsConnected => _transport.IsConnected; + + /// Raised when the connection closes. + public event Action? Closed; + + /// Raised on transport errors. + public event Action? Error; + + /// Raised for every incoming message. + public event Action>? MessageReceived; + + /// Sends one message. + public Task SendAsync(byte[] payload, CancellationToken cancellationToken = default) + => _transport.SendAsync(new ArraySegment(payload ?? Array.Empty()), cancellationToken); + + /// Closes the connection. + public Task DisconnectAsync(CancellationToken cancellationToken = default) + => _transport.CloseAsync(cancellationToken); + + internal void Update(float deltaTime) + { + _transport.Update(deltaTime); + } +} diff --git a/Services/RemoteConfigService.cs b/Services/RemoteConfigService.cs new file mode 100644 index 0000000..59e8a71 --- /dev/null +++ b/Services/RemoteConfigService.cs @@ -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; + +/// +/// Remote configuration values. caches every config +/// whose "active" flag is not explicitly false; reads +/// typed values from the cache. +/// +public sealed class RemoteConfigService +{ + private readonly RudderClient _client; + private readonly Dictionary _cache = new(); + private bool _isLoaded; + + internal RemoteConfigService(RudderClient client) => _client = client; + + /// True after the first successful . + public bool IsLoaded => _isLoaded; + + /// The cached configs keyed by config key. + public IReadOnlyDictionary Configs => _cache; + + /// Fetches all configs and rebuilds the cache. + public async Task> LoadAsync(CancellationToken cancellationToken = default) + { + var response = await _client.SendAsync( + "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() == false) + continue; + + var config = entry.Value?.ToObject(); + if (config != null) + _cache[config.Key ?? entry.Key] = config; + } + } + + _isLoaded = true; + return _cache; + } + + /// Fetches one config straight from the server, bypassing the cache. + public async Task GetConfigAsync(string key, CancellationToken cancellationToken = default) + { + return await _client.SendAsync( + "GET", + "/sdk/v1/remote-configs/" + Url.Encode(key), + cancellationToken).ConfigureAwait(false); + } + + /// Reads a typed value from the cache; returns the default when missing or unparsable. + public T Get(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); + } + + /// Loads the cache on first use, then reads a typed value. + public async Task GetAsync(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(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(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? Configs { get; set; } + } +} diff --git a/Services/ScenarioService.cs b/Services/ScenarioService.cs new file mode 100644 index 0000000..154887c --- /dev/null +++ b/Services/ScenarioService.cs @@ -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; + +/// +/// Scenario runtime. starts server-issued plans; +/// active nodes surface as typed sessions through the On* events and are +/// advanced by completing those sessions. +/// +public sealed partial class ScenarioService +{ + private readonly RudderClient _client; + private readonly Dictionary _runs = new(); + + /// Raised for a notification node. + public event Action? OnNotification; + + /// Raised for a store-offer node. + public event Action? OnStoreOffer; + + /// Raised for a leaderboard node. + public event Action? OnLeaderboard; + + /// Raised for a remote-config-override node, after the patches were applied. + public event Action? OnConfigChanged; + + /// Raised for a wait node. + public event Action? OnWait; + + /// Raised for a quest node. + public event Action? OnQuest; + + /// Raised for a battle-pass node. + public event Action? OnBattlePass; + + /// Raised for a battle-pass-level node. + public event Action? OnBattlePassLevel; + + /// Raised when a run finishes all its nodes. + public event Action? OnScenarioCompleted; + + /// Raised when a run dies on an unrecoverable error. + public event Action? OnScenarioFailed; + + internal ScenarioService(RudderClient client) => _client = client; + + /// True while at least one run is active. + public bool IsRunning => _runs.Count > 0; + + /// First active node id across the runs, or null. + public string? CurrentNodeId => _runs.Values.FirstOrDefault()?.ActiveNodes.Keys.FirstOrDefault(); + + /// Snapshots of the active runs. + public IReadOnlyList ActiveRuns => _runs.Values.Select(ToPlanRun).ToList(); + + /// + /// Triggers scenarios by event name and starts the plans the server + /// returns. Returns the runs this call started. + /// + public async Task> TriggerAsync(string eventName, CancellationToken cancellationToken = default) + { + var response = await _client.SendAsync( + "POST", + "/sdk/v1/scenarios/trigger", + new TriggerScenarioRequest { Event = eventName }, + cancellationToken).ConfigureAwait(false); + + return StartPlans(response?.Plans); + } + + /// + /// Restores persisted runs, reconciles them with the server and re-dispatches + /// active nodes. Call once after startup, after login. + /// + 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(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( + "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(); + } + } + + /// Drops all runs and the persisted state. + public void Clear() + { + _runs.Clear(); + Persist(); + } + + /// Completes wait nodes whose deadline passed; call every frame. + 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"); + } + } + } + + /// Completes the first active node with the given handle. + 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); + } + + /// Completes the first active node with the given handle (fire-and-forget). + public void Respond(string handle) + { + _ = RespondAsync(handle); + } + + /// Adds progress to a counter of the first active node. + 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( + "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); + } + } +} diff --git a/Services/Scenarios/Models/PlanRun.cs b/Services/Scenarios/Models/PlanRun.cs new file mode 100644 index 0000000..3732da6 --- /dev/null +++ b/Services/Scenarios/Models/PlanRun.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using RudderSdk.Core.Models; + +namespace RudderSdk.Core; + +/// Snapshot of one running scenario plan. +public sealed class PlanRun +{ + internal PlanRun( + string runId, + string planId, + string scenarioId, + string userId, + IReadOnlyList activeNodeIds, + ExecutionPlan plan) + { + RunId = runId; + PlanId = planId; + ScenarioId = scenarioId; + UserId = userId; + ActiveNodeIds = activeNodeIds; + Plan = plan; + } + + /// Server-issued run id. + public string RunId { get; } + + /// Plan id. + public string PlanId { get; } + + /// Scenario id. + public string ScenarioId { get; } + + /// Player the run belongs to. + public string UserId { get; } + + /// Ids of the currently active nodes. + public IReadOnlyList ActiveNodeIds { get; } + + /// The execution plan being run. + public ExecutionPlan Plan { get; } +} diff --git a/Services/Scenarios/Models/ScenarioFailedEvent.cs b/Services/Scenarios/Models/ScenarioFailedEvent.cs new file mode 100644 index 0000000..5b1a347 --- /dev/null +++ b/Services/Scenarios/Models/ScenarioFailedEvent.cs @@ -0,0 +1,23 @@ +using System; + +namespace RudderSdk.Core; + +/// Payload of . +public sealed class ScenarioFailedEvent +{ + internal ScenarioFailedEvent(PlanRun run, string nodeId, Exception exception) + { + Run = run; + NodeId = nodeId; + Exception = exception; + } + + /// The failed run. + public PlanRun Run { get; } + + /// Node the failure happened at. + public string NodeId { get; } + + /// The error that failed the run. + public Exception Exception { get; } +} diff --git a/Services/Scenarios/Models/ScenarioNodeContext.cs b/Services/Scenarios/Models/ScenarioNodeContext.cs new file mode 100644 index 0000000..fa3e22c --- /dev/null +++ b/Services/Scenarios/Models/ScenarioNodeContext.cs @@ -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; + +/// Context of the scenario node a session was created for. +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(); + } + + /// The run this node belongs to. + public PlanRun Run { get; } + + /// The plan node. + public ExecutionPlanNode Node { get; } + + /// Run id. + public string RunId => Run.RunId; + + /// Plan id. + public string PlanId => Run.PlanId; + + /// Scenario id. + public string ScenarioId => Run.ScenarioId; + + /// Node id. + public string NodeId => Node.Id; + + /// Node type. + public string Type => Node.Type; + + /// Node data payload. + public JObject Data { get; } + + /// Reads a typed value from the node data. + public T Get(string key, T defaultValue = default!) + { + if (Data == null || !Data.TryGetValue(key, out var value)) + return defaultValue; + try { return value.ToObject() ?? defaultValue; } + catch { return defaultValue; } + } + + /// Deserializes the whole node data payload. + public T Get() + { + try { return Data == null ? default! : Data.ToObject() ?? default!; } + catch { return default!; } + } + + /// Returns the node data as a plain dictionary. + public Dictionary AsObjectDictionary() + { + return Data?.ToObject>() ?? new Dictionary(); + } + + 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); +} diff --git a/Services/Scenarios/ScenarioService.Dispatch.cs b/Services/Scenarios/ScenarioService.Dispatch.cs new file mode 100644 index 0000000..189480e --- /dev/null +++ b/Services/Scenarios/ScenarioService.Dispatch.cs @@ -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()) + { + var key = patchToken.Value("path"); + if (string.IsNullOrEmpty(key)) + continue; + + var valueType = patchToken.Value("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("duration") ?? 0; + var unit = data.Value("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() : token.ToString(Formatting.None); + case "bool": + case "boolean": + return token.Value().ToString().ToLowerInvariant(); + case "int": + case "integer": + return token.Value().ToString(CultureInfo.InvariantCulture); + case "float": + case "double": + return token.Value().ToString(CultureInfo.InvariantCulture); + default: + return token.Type == JTokenType.String ? token.Value() : token.ToString(Formatting.None); + } + } +} diff --git a/Services/Scenarios/ScenarioService.NodeTypes.cs b/Services/Scenarios/ScenarioService.NodeTypes.cs new file mode 100644 index 0000000..b1c81e3 --- /dev/null +++ b/Services/Scenarios/ScenarioService.NodeTypes.cs @@ -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"; +} diff --git a/Services/Scenarios/ScenarioService.Persistence.cs b/Services/Scenarios/ScenarioService.Persistence.cs new file mode 100644 index 0000000..2b1277a --- /dev/null +++ b/Services/Scenarios/ScenarioService.Persistence.cs @@ -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 ActiveNodes { get; } = new(); + public HashSet 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? Runs { get; set; } + } + + private sealed class PersistedRun + { + public string RunId { get; set; } = string.Empty; + public ExecutionPlan Plan { get; set; } = null!; + public List? ActiveNodes { get; set; } + public List? CompletedHandles { get; set; } + } + + private sealed class ActiveNodeState + { + public string NodeId { get; set; } = string.Empty; + public DateTimeOffset? WaitDeadlineUtc { get; set; } + } +} diff --git a/Services/Scenarios/ScenarioService.Runtime.cs b/Services/Scenarios/ScenarioService.Runtime.cs new file mode 100644 index 0000000..830e95b --- /dev/null +++ b/Services/Scenarios/ScenarioService.Runtime.cs @@ -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; + +/// +/// 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. +/// +public sealed class TransientBoundaryException : Exception +{ + /// Creates the exception. + public TransientBoundaryException(string message, Exception inner) + : base(message, inner) + { + } + + /// Creates the exception wrapping the transport error. + public TransientBoundaryException(Exception inner) + : base($"Transient boundary error: {inner.Message}", inner) + { + } +} + +public sealed partial class ScenarioService +{ + /// + /// Returns true for exceptions that may succeed on retry + /// (timeout / network failures). + /// + private static bool IsTransientException(Exception ex) + { + return ex is OperationCanceledException || ex is RudderNetworkException; + } + + private IReadOnlyList StartPlans(IEnumerable? plans) + { + var started = new List(); + 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; + } + + /// + /// 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. + /// + 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 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( + "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( + "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 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 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); +} diff --git a/Services/Scenarios/Sessions/BattlePassLevelSession.cs b/Services/Scenarios/Sessions/BattlePassLevelSession.cs new file mode 100644 index 0000000..576a68b --- /dev/null +++ b/Services/Scenarios/Sessions/BattlePassLevelSession.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core; + +/// Session of a scenario battle-pass-level node. +public sealed class BattlePassLevelSession +{ + internal BattlePassLevelSession(ScenarioNodeContext context) => Context = context; + + /// Underlying node context. + public ScenarioNodeContext Context { get; } + + /// Node id. + public string Id => Context.NodeId; + + /// Reads a typed value from the node data. + public T Get(string key, T defaultValue = default!) => Context.Get(key, defaultValue); + + /// Advances the run through the onComplete handle. + public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken); + + /// Advances the run through the onComplete handle (fire-and-forget). + public void Complete() => Context.Complete("onComplete"); +} diff --git a/Services/Scenarios/Sessions/BattlePassSession.cs b/Services/Scenarios/Sessions/BattlePassSession.cs new file mode 100644 index 0000000..6c98864 --- /dev/null +++ b/Services/Scenarios/Sessions/BattlePassSession.cs @@ -0,0 +1,43 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core; + +/// Session of a scenario battle-pass node. +public sealed class BattlePassSession +{ + internal BattlePassSession(ScenarioNodeContext context) => Context = context; + + /// Underlying node context. + public ScenarioNodeContext Context { get; } + + /// Node id. + public string Id => Context.NodeId; + + /// Reads a typed value from the node data. + public T Get(string key, T defaultValue = default!) => Context.Get(key, defaultValue); + + /// Advances the run through the onLevelUp handle. + public Task LevelUpAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onLevelUp", cancellationToken); + + /// Advances the run through the onLevelUp handle (fire-and-forget). + public void LevelUp() => Context.Complete("onLevelUp"); + + /// Advances the run through the onMaxLevel handle. + public Task MaxLevelAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onMaxLevel", cancellationToken); + + /// Advances the run through the onMaxLevel handle (fire-and-forget). + public void MaxLevel() => Context.Complete("onMaxLevel"); + + /// Advances the run through the onPremiumPurchase handle. + public Task PremiumPurchaseAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onPremiumPurchase", cancellationToken); + + /// Advances the run through the onPremiumPurchase handle (fire-and-forget). + public void PremiumPurchase() => Context.Complete("onPremiumPurchase"); + + /// Advances the run through the onComplete handle. + public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken); + + /// Advances the run through the onComplete handle (fire-and-forget). + public void Complete() => Context.Complete("onComplete"); +} diff --git a/Services/Scenarios/Sessions/ConfigChangedSession.cs b/Services/Scenarios/Sessions/ConfigChangedSession.cs new file mode 100644 index 0000000..0f6008e --- /dev/null +++ b/Services/Scenarios/Sessions/ConfigChangedSession.cs @@ -0,0 +1,13 @@ +namespace RudderSdk.Core; + +/// +/// Session of a scenario remote-config-override node. The patches are already +/// applied to when the event fires. +/// +public sealed class ConfigChangedSession +{ + internal ConfigChangedSession(ScenarioNodeContext context) => Context = context; + + /// Underlying node context. + public ScenarioNodeContext Context { get; } +} diff --git a/Services/Scenarios/Sessions/LeaderboardSession.cs b/Services/Scenarios/Sessions/LeaderboardSession.cs new file mode 100644 index 0000000..70eb9ee --- /dev/null +++ b/Services/Scenarios/Sessions/LeaderboardSession.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core; + +/// Session of a scenario leaderboard node. +public sealed class LeaderboardSession +{ + internal LeaderboardSession(ScenarioNodeContext context) => Context = context; + + /// Underlying node context. + public ScenarioNodeContext Context { get; } + + /// Node id. + public string Id => Context.NodeId; + + /// Reads a typed value from the node data. + public T Get(string key, T defaultValue = default!) => Context.Get(key, defaultValue); + + /// Advances the run through the onEnd handle. + public Task EndAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onEnd", cancellationToken); + + /// Advances the run through the onEnd handle (fire-and-forget). + public void End() => Context.Complete("onEnd"); + + /// Advances the run through the onRewardClaimed handle. + public Task RewardClaimedAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onRewardClaimed", cancellationToken); + + /// Advances the run through the onRewardClaimed handle (fire-and-forget). + public void RewardClaimed() => Context.Complete("onRewardClaimed"); +} diff --git a/Services/Scenarios/Sessions/NotificationSession.cs b/Services/Scenarios/Sessions/NotificationSession.cs new file mode 100644 index 0000000..31b72a0 --- /dev/null +++ b/Services/Scenarios/Sessions/NotificationSession.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core; + +/// Session of a scenario notification node. +public sealed class NotificationSession +{ + internal NotificationSession(ScenarioNodeContext context) => Context = context; + + /// Underlying node context. + public ScenarioNodeContext Context { get; } + + /// Node id. + public string Id => Context.NodeId; + + /// Reads a typed value from the node data. + public T Get(string key, T defaultValue = default!) => Context.Get(key, defaultValue); + + /// Advances the run through the output handle. + public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("output", cancellationToken); + + /// Advances the run through the output handle (fire-and-forget). + public void Complete() => Context.Complete("output"); +} diff --git a/Services/Scenarios/Sessions/QuestSession.cs b/Services/Scenarios/Sessions/QuestSession.cs new file mode 100644 index 0000000..3785ce6 --- /dev/null +++ b/Services/Scenarios/Sessions/QuestSession.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core; + +/// Session of a scenario quest node. +public sealed class QuestSession +{ + internal QuestSession(ScenarioNodeContext context) => Context = context; + + /// Underlying node context. + public ScenarioNodeContext Context { get; } + + /// Node id. + public string Id => Context.NodeId; + + /// Reads a typed value from the node data. + public T Get(string key, T defaultValue = default!) => Context.Get(key, defaultValue); + + /// Adds progress to one of the node's counters. + public Task AddProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default) => Context.AddProgressAsync(counterKey, amount, cancellationToken); + + /// Adds progress to one of the node's counters (fire-and-forget). + public void AddProgress(string counterKey, long amount) => _ = AddProgressAsync(counterKey, amount); + + /// Advances the run through the onComplete handle. + public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken); + + /// Advances the run through the onComplete handle (fire-and-forget). + public void Complete() => Context.Complete("onComplete"); + + /// Advances the run through the onFail handle. + public Task FailAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onFail", cancellationToken); + + /// Advances the run through the onFail handle (fire-and-forget). + public void Fail() => Context.Complete("onFail"); +} diff --git a/Services/Scenarios/Sessions/StoreOfferSession.cs b/Services/Scenarios/Sessions/StoreOfferSession.cs new file mode 100644 index 0000000..b37cc44 --- /dev/null +++ b/Services/Scenarios/Sessions/StoreOfferSession.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RudderSdk.Core; + +/// Session of a scenario store-offer node; resolve it with a purchase or a decline. +public sealed class StoreOfferSession +{ + internal StoreOfferSession(ScenarioNodeContext context) + { + Context = context; + Data = context.AsObjectDictionary(); + } + + /// Underlying node context. + public ScenarioNodeContext Context { get; } + + /// Node id. + public string Id => Context.NodeId; + + /// Node data payload. + public IReadOnlyDictionary Data { get; } + + /// True after the session was resolved once. + public bool IsResolved { get; private set; } + + /// Reads a typed value from the node data. + public T Get(string key, T defaultValue = default!) => Context.Get(key, defaultValue); + + /// Resolves the offer as purchased. + public Task PurchaseAsync(CancellationToken cancellationToken = default) => ResolveAsync("onPurchase", cancellationToken); + + /// Resolves the offer as purchased (fire-and-forget). + public void Purchase() => Resolve("onPurchase"); + + /// Resolves the offer as declined. + public Task DeclineAsync(CancellationToken cancellationToken = default) => ResolveAsync("onDecline", cancellationToken); + + /// Resolves the offer as declined (fire-and-forget). + 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); + } +} diff --git a/Services/Scenarios/Sessions/WaitSession.cs b/Services/Scenarios/Sessions/WaitSession.cs new file mode 100644 index 0000000..d1c4cbd --- /dev/null +++ b/Services/Scenarios/Sessions/WaitSession.cs @@ -0,0 +1,19 @@ +using System; + +namespace RudderSdk.Core; + +/// Session of a scenario wait node; the run continues automatically at the deadline. +public sealed class WaitSession +{ + internal WaitSession(ScenarioNodeContext context, DateTimeOffset deadlineUtc) + { + Context = context; + DeadlineUtc = deadlineUtc; + } + + /// Underlying node context. + public ScenarioNodeContext Context { get; } + + /// When the wait ends (UTC). + public DateTimeOffset DeadlineUtc { get; } +} diff --git a/Services/StorageService.cs b/Services/StorageService.cs new file mode 100644 index 0000000..d3fd3ac --- /dev/null +++ b/Services/StorageService.cs @@ -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; + +/// Player key-value storage. +public sealed class StorageService +{ + private readonly RudderClient _client; + + internal StorageService(RudderClient client) => _client = client; + + /// Fetches one page of storage items, optionally filtered by type. + public Task 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("GET", "/sdk/v1/storage" + query, cancellationToken); + } + + /// Iterates over all storage items, following the cursor pagination. + public async IAsyncEnumerable 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)); + } + + /// Saves (upserts) storage items. + public Task SaveAsync(IEnumerable items, CancellationToken cancellationToken = default) + => _client.SendAsync( + "PUT", + "/sdk/v1/storage", + new UpdateStorageRequest { Items = items?.ToList() ?? new List() }, + cancellationToken); + + /// Deletes all items of the given type. + public Task DeleteAsync(string type, CancellationToken cancellationToken = default) + => _client.SendAsync("DELETE", "/sdk/v1/storage" + Url.Query(("type", type)), cancellationToken); +} diff --git a/Services/StoresService.cs b/Services/StoresService.cs new file mode 100644 index 0000000..0e366e7 --- /dev/null +++ b/Services/StoresService.cs @@ -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; + +/// In-game stores and offer purchases. +public sealed class StoresService +{ + private readonly RudderClient _client; + + internal StoresService(RudderClient client) => _client = client; + + /// Lists the available stores with their offers. + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var response = await _client.SendAsync( + "GET", + "/sdk/v1/stores", + cancellationToken).ConfigureAwait(false); + + return response?.Stores ?? new List(); + } + + /// Resolves one store by slug. + public Task GetAsync(string slug, CancellationToken cancellationToken = default) + => _client.SendAsync("GET", "/sdk/v1/stores/" + Url.Encode(slug), cancellationToken); + + /// + /// Purchases an offer (charges the wallet). When + /// is null, a random one is generated; + /// pass a stable key to make retries safe. + /// + public Task PurchaseAsync( + string storeSlug, + string offerId, + string? idempotencyKey = null, + CancellationToken cancellationToken = default) + { + return _client.SendAsync( + "POST", + "/sdk/v1/stores/" + Url.Encode(storeSlug) + "/offers/" + Url.Encode(offerId) + "/purchase", + new PurchaseOfferRequest + { + StoreSlug = storeSlug, + OfferId = offerId, + IdempotencyKey = idempotencyKey ?? Guid.NewGuid().ToString() + }, + cancellationToken); + } +} diff --git a/Services/UgcService.cs b/Services/UgcService.cs new file mode 100644 index 0000000..107302c --- /dev/null +++ b/Services/UgcService.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using RudderSdk.Core.Models.Ugc; + +namespace RudderSdk.Core; + +/// User-generated content: upload, list, download URLs, deletion. +public sealed class UgcService +{ + private readonly RudderClient _client; + + internal UgcService(RudderClient client) => _client = client; + + /// + /// Uploads a file in one call: pre-signed URL, PUT of the bytes, submission. + /// Requires an upload transport in . + /// + public async Task UploadAsync( + string name, + byte[] data, + string? description = null, + JToken? metadata = null, + CancellationToken cancellationToken = default) + { + var uploadTransport = _client.Options.UploadTransport + ?? throw new InvalidOperationException("UploadTransport is not configured."); + + var upload = await GetUploadUrlAsync(name, cancellationToken).ConfigureAwait(false); + await uploadTransport.PutAsync( + upload.UploadUrl, + data ?? Array.Empty(), + GetContentType(name), + cancellationToken).ConfigureAwait(false); + + return await SubmitAsync( + name, + upload.FileKey, + data?.LongLength ?? 0, + description, + metadata, + cancellationToken).ConfigureAwait(false); + } + + /// Fetches one page of the player's submissions. + public Task ListAsync(string? status = null, int limit = 20, string? cursor = null, CancellationToken cancellationToken = default) + => _client.SendAsync( + "GET", + "/sdk/v1/ugc" + Url.Query( + ("status", status), + ("limit", limit > 0 ? limit.ToString() : null), + ("cursor", cursor)), + cancellationToken); + + /// Iterates over all of the player's submissions, following the cursor pagination. + public async IAsyncEnumerable ListAllAsync( + string? status = null, + int limit = 20, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? cursor = null; + do + { + var page = await ListAsync(status, limit, cursor, cancellationToken).ConfigureAwait(false); + if (page?.Items != null) + { + foreach (var item in page.Items) + yield return item; + } + + cursor = page?.NextCursor; + } + while (!string.IsNullOrEmpty(cursor)); + } + + /// Returns one submission. + public Task GetAsync(string id, CancellationToken cancellationToken = default) + => _client.SendAsync("GET", "/sdk/v1/ugc/" + Url.Encode(id), cancellationToken); + + /// Deletes a submission; returns false when the server refused. + public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) + { + var response = await _client.SendAsync( + "DELETE", + "/sdk/v1/ugc/" + Url.Encode(id), + cancellationToken).ConfigureAwait(false); + + return response == null || response.Success; + } + + /// Requests a pre-signed upload URL. + public Task GetUploadUrlAsync(string filename, CancellationToken cancellationToken = default) + => _client.SendAsync( + "GET", + "/sdk/v1/ugc/upload-url" + Url.Query(("filename", filename)), + cancellationToken); + + /// + /// Resolves the download URL of a submission. + /// Throws when the server returned none. + /// + public async Task GetDownloadUrlAsync(string id, CancellationToken cancellationToken = default) + { + var response = await _client.SendAsync( + "GET", + "/sdk/v1/ugc/" + Url.Encode(id) + "/download", + cancellationToken).ConfigureAwait(false); + + if (response == null || string.IsNullOrEmpty(response.DownloadUrl)) + throw new InvalidOperationException("The server returned no download URL."); + + return response.DownloadUrl; + } + + /// Registers an uploaded file as a submission. + public Task SubmitAsync( + string name, + string fileKey, + long fileSize, + string? description = null, + JToken? metadata = null, + CancellationToken cancellationToken = default) + { + return _client.SendAsync( + "POST", + "/sdk/v1/ugc", + new SubmitUgcRequest + { + Name = name, + FileKey = fileKey, + FileSize = fileSize, + Description = description!, + Metadata = metadata! + }, + cancellationToken); + } + + private static string GetContentType(string filename) + { + var ext = System.IO.Path.GetExtension(filename)?.ToLowerInvariant(); + switch (ext) + { + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".gif": return "image/gif"; + case ".webp": return "image/webp"; + case ".txt": return "text/plain"; + default: return "application/octet-stream"; + } + } +} diff --git a/Services/Url.cs b/Services/Url.cs new file mode 100644 index 0000000..fa24b86 --- /dev/null +++ b/Services/Url.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace RudderSdk.Core; + +internal static class Url +{ + public static string Encode(string? value) => Uri.EscapeDataString(value ?? string.Empty); + + public static string Query(params (string Key, string? Value)[] values) + { + var parts = new List(); + foreach (var item in values) + { + if (!string.IsNullOrEmpty(item.Value)) + parts.Add(Encode(item.Key) + "=" + Encode(item.Value)); + } + + return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts); + } +} diff --git a/Storage/GetStorageResponse.cs b/Storage/GetStorageResponse.cs new file mode 100644 index 0000000..4088bcc --- /dev/null +++ b/Storage/GetStorageResponse.cs @@ -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.Storage; + +public class GetStorageResponse +{ + [JsonProperty("items")] + public List Items { get; set; } + + [JsonProperty("nextCursor")] + public string NextCursor { get; set; } + +} diff --git a/Storage/StorageItem.cs b/Storage/StorageItem.cs new file mode 100644 index 0000000..8c6f894 --- /dev/null +++ b/Storage/StorageItem.cs @@ -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.Storage; + +public class StorageItem +{ + [JsonProperty("data")] + public string Data { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + +} diff --git a/Storage/UpdateStorageRequest.cs b/Storage/UpdateStorageRequest.cs new file mode 100644 index 0000000..107e29a --- /dev/null +++ b/Storage/UpdateStorageRequest.cs @@ -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.Storage; + +public class UpdateStorageRequest +{ + [JsonProperty("idempotencyKey")] + public string IdempotencyKey { get; set; } + + [JsonProperty("items")] + public List Items { get; set; } + +} diff --git a/Stores/ListStoresResponse.cs b/Stores/ListStoresResponse.cs new file mode 100644 index 0000000..2eac0df --- /dev/null +++ b/Stores/ListStoresResponse.cs @@ -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.Stores; + +public class ListStoresResponse +{ + [JsonProperty("stores")] + public List Stores { get; set; } + + [JsonProperty("total")] + public int Total { get; set; } + +} diff --git a/Stores/Offer.cs b/Stores/Offer.cs new file mode 100644 index 0000000..52f1373 --- /dev/null +++ b/Stores/Offer.cs @@ -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.Stores; + +public class Offer +{ + [JsonProperty("contents")] + public List Contents { get; set; } + + [JsonProperty("createdAt")] + public string CreatedAt { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("maxPurchases")] + public int MaxPurchases { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("price")] + public OfferPrice Price { get; set; } + + [JsonProperty("updatedAt")] + public string UpdatedAt { get; set; } + +} diff --git a/Stores/OfferContent.cs b/Stores/OfferContent.cs new file mode 100644 index 0000000..4e097b1 --- /dev/null +++ b/Stores/OfferContent.cs @@ -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.Stores; + +public class OfferContent +{ + [JsonProperty("amount")] + public long Amount { get; set; } + + [JsonProperty("itemId")] + public string ItemId { get; set; } + +} diff --git a/Stores/OfferPrice.cs b/Stores/OfferPrice.cs new file mode 100644 index 0000000..e24a0db --- /dev/null +++ b/Stores/OfferPrice.cs @@ -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.Stores; + +public class OfferPrice +{ + [JsonProperty("amount")] + public long Amount { get; set; } + + [JsonProperty("currency")] + public string Currency { get; set; } + +} diff --git a/Stores/PurchaseOfferRequest.cs b/Stores/PurchaseOfferRequest.cs new file mode 100644 index 0000000..96830b2 --- /dev/null +++ b/Stores/PurchaseOfferRequest.cs @@ -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.Stores; + +public class PurchaseOfferRequest +{ + [JsonProperty("idempotencyKey")] + public string IdempotencyKey { get; set; } + + [JsonProperty("offerId")] + public string OfferId { get; set; } + + [JsonProperty("storeSlug")] + public string StoreSlug { get; set; } + +} diff --git a/Stores/PurchaseOfferResponse.cs b/Stores/PurchaseOfferResponse.cs new file mode 100644 index 0000000..df7a603 --- /dev/null +++ b/Stores/PurchaseOfferResponse.cs @@ -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.Stores; + +public class PurchaseOfferResponse +{ + [JsonProperty("error")] + public string Error { get; set; } + + [JsonProperty("purchaseId")] + public string PurchaseId { get; set; } + + [JsonProperty("success")] + public bool Success { get; set; } + +} diff --git a/Stores/Store.cs b/Stores/Store.cs new file mode 100644 index 0000000..81583ab --- /dev/null +++ b/Stores/Store.cs @@ -0,0 +1,46 @@ +// Code generated by apigen. DO NOT EDIT. +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; + +namespace RudderSdk.Core.Models.Stores; + +public class Store +{ + [JsonProperty("createdAt")] + public string CreatedAt { get; set; } + + [JsonProperty("data")] + public JToken Data { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("environment")] + public string Environment { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("offers")] + public List Offers { get; set; } + + [JsonProperty("projectId")] + public string ProjectId { get; set; } + + [JsonProperty("scenarioId")] + public string ScenarioId { get; set; } + + [JsonProperty("slug")] + public string Slug { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("updatedAt")] + public string UpdatedAt { get; set; } + +} diff --git a/Ugc/DeleteUgcResponse.cs b/Ugc/DeleteUgcResponse.cs new file mode 100644 index 0000000..17f68b3 --- /dev/null +++ b/Ugc/DeleteUgcResponse.cs @@ -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.Ugc; + +public class DeleteUgcResponse +{ + [JsonProperty("success")] + public bool Success { get; set; } + +} diff --git a/Ugc/GetDownloadUrlResponse.cs b/Ugc/GetDownloadUrlResponse.cs new file mode 100644 index 0000000..a7a832d --- /dev/null +++ b/Ugc/GetDownloadUrlResponse.cs @@ -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.Ugc; + +public class GetDownloadUrlResponse +{ + [JsonProperty("downloadUrl")] + public string DownloadUrl { get; set; } + +} diff --git a/Ugc/GetUploadUrlResponse.cs b/Ugc/GetUploadUrlResponse.cs new file mode 100644 index 0000000..148c485 --- /dev/null +++ b/Ugc/GetUploadUrlResponse.cs @@ -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.Ugc; + +public class GetUploadUrlResponse +{ + [JsonProperty("fileKey")] + public string FileKey { get; set; } + + [JsonProperty("uploadUrl")] + public string UploadUrl { get; set; } + +} diff --git a/Ugc/ListUgcResponse.cs b/Ugc/ListUgcResponse.cs new file mode 100644 index 0000000..de05f5b --- /dev/null +++ b/Ugc/ListUgcResponse.cs @@ -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.Ugc; + +public class ListUgcResponse +{ + [JsonProperty("items")] + public List Items { get; set; } + + [JsonProperty("nextCursor")] + public string NextCursor { get; set; } + +} diff --git a/Ugc/SubmitUgcRequest.cs b/Ugc/SubmitUgcRequest.cs new file mode 100644 index 0000000..6dd0b8f --- /dev/null +++ b/Ugc/SubmitUgcRequest.cs @@ -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.Ugc; + +public class SubmitUgcRequest +{ + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("fileKey")] + public string FileKey { get; set; } + + [JsonProperty("fileSize")] + public long FileSize { get; set; } + + [JsonProperty("metadata")] + public JToken Metadata { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + +} diff --git a/Ugc/SubmittedBy.cs b/Ugc/SubmittedBy.cs new file mode 100644 index 0000000..01f5fb5 --- /dev/null +++ b/Ugc/SubmittedBy.cs @@ -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.Ugc; + +public class SubmittedBy +{ + [JsonProperty("userId")] + public string UserId { get; set; } + + [JsonProperty("username")] + public string Username { get; set; } + +} diff --git a/Ugc/UgcSubmission.cs b/Ugc/UgcSubmission.cs new file mode 100644 index 0000000..a9b94af --- /dev/null +++ b/Ugc/UgcSubmission.cs @@ -0,0 +1,43 @@ +// Code generated by apigen. DO NOT EDIT. +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; + +namespace RudderSdk.Core.Models.Ugc; + +public class UgcSubmission +{ + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("fileSize")] + public long FileSize { get; set; } + + [JsonProperty("fileUrl")] + public string FileUrl { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("metadata")] + public JToken Metadata { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("reviewedAt")] + public string ReviewedAt { get; set; } + + [JsonProperty("reviewedBy")] + public string ReviewedBy { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("submittedAt")] + public string SubmittedAt { get; set; } + + [JsonProperty("submittedBy")] + public SubmittedBy SubmittedBy { get; set; } + +} diff --git a/tests/Rudder.Core.Tests/AuthSessionTests.cs b/tests/Rudder.Core.Tests/AuthSessionTests.cs new file mode 100644 index 0000000..e968878 --- /dev/null +++ b/tests/Rudder.Core.Tests/AuthSessionTests.cs @@ -0,0 +1,197 @@ +using RudderSdk.Core; +using RudderSdk.Core.Abstractions; +using RudderSdk.Core.Models.Auth; +using RudderSdk.Core.Models.Player; + +using Xunit; + +namespace RudderSdk.Core.Tests; + +public sealed class AuthSessionTests +{ + [Fact] + public async Task Request_401_Refreshes_Tokens_And_Retries_Once() + { + var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" }; + var transport = new FakeTransport(); + var profileCalls = 0; + transport.Handler = (method, path, accessToken) => + { + if (path == "/sdk/v1/authorization/refresh") + { + var request = new RefreshAccessTokenResponse { AccessToken = "new-access", RefreshToken = "new-refresh" }; + return Task.FromResult(request); + } + + profileCalls++; + if (profileCalls == 1) + throw new RudderAuthException(401, "unauthorized", "Unauthorized"); + + Assert.Equal("new-access", accessToken); + return Task.FromResult(new PlayerProfile()); + }; + var client = CreateClient(transport, tokenStore); + + var profile = await client.Player.GetProfileAsync(); + + Assert.NotNull(profile); + Assert.Equal(2, profileCalls); + Assert.Equal("new-access", tokenStore.AccessToken); + Assert.Equal("new-refresh", tokenStore.RefreshToken); + } + + [Fact] + public async Task Concurrent_401s_Share_One_Refresh_Request() + { + var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" }; + var transport = new FakeTransport(); + var refreshCalls = 0; + transport.Handler = async (method, path, accessToken) => + { + if (path == "/sdk/v1/authorization/refresh") + { + refreshCalls++; + await Task.Delay(50); + return new RefreshAccessTokenResponse { AccessToken = "new-access", RefreshToken = "new-refresh" }; + } + + if (accessToken != "new-access") + throw new RudderAuthException(401, "unauthorized", "Unauthorized"); + + return new PlayerProfile(); + }; + var client = CreateClient(transport, tokenStore); + + await Task.WhenAll( + client.Player.GetProfileAsync(), + client.Player.GetProfileAsync(), + client.Player.GetProfileAsync()); + + Assert.Equal(1, refreshCalls); + } + + [Fact] + public async Task Failed_Refresh_Clears_Tokens_Raises_SignedOut_And_Rethrows() + { + var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" }; + var transport = new FakeTransport(); + transport.Handler = (method, path, accessToken) => + throw new RudderAuthException(401, "unauthorized", "Unauthorized"); + var client = CreateClient(transport, tokenStore); + var states = new List(); + client.Auth.AuthStateChanged += states.Add; + + await Assert.ThrowsAsync(() => client.Player.GetProfileAsync()); + + Assert.Null(tokenStore.AccessToken); + Assert.Null(tokenStore.RefreshToken); + Assert.Equal(new[] { RudderAuthState.SignedOut }, states); + } + + [Fact] + public async Task Missing_Refresh_Token_Fails_Without_Refresh_Call() + { + var tokenStore = new FakeTokenStore { AccessToken = "old-access" }; + var transport = new FakeTransport(); + var refreshCalls = 0; + transport.Handler = (method, path, accessToken) => + { + if (path == "/sdk/v1/authorization/refresh") + { + refreshCalls++; + return Task.FromResult(new RefreshAccessTokenResponse()); + } + + throw new RudderAuthException(401, "unauthorized", "Unauthorized"); + }; + var client = CreateClient(transport, tokenStore); + var states = new List(); + client.Auth.AuthStateChanged += states.Add; + + await Assert.ThrowsAsync(() => client.Player.GetProfileAsync()); + + Assert.Equal(0, refreshCalls); + Assert.Equal(new[] { RudderAuthState.SignedOut }, states); + } + + [Fact] + public async Task RefreshAsync_Stores_New_Token_Pair() + { + var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" }; + var transport = new FakeTransport(); + transport.Handler = (method, path, accessToken) => + { + Assert.Equal("/sdk/v1/authorization/refresh", path); + Assert.Null(accessToken); + return Task.FromResult(new RefreshAccessTokenResponse + { + AccessToken = "new-access", + RefreshToken = "new-refresh" + }); + }; + var client = CreateClient(transport, tokenStore); + + var refreshed = await client.Auth.RefreshAsync(); + + Assert.True(refreshed); + Assert.Equal("new-access", tokenStore.AccessToken); + Assert.Equal("new-refresh", tokenStore.RefreshToken); + } + + private static RudderClient CreateClient(FakeTransport transport, FakeTokenStore tokenStore) + { + return new RudderClient(new RudderClientOptions + { + BaseUrl = "http://localhost:8082", + ProjectKey = "project-key", + Transport = transport, + TokenStore = tokenStore, + DeviceIdProvider = new FakeDeviceIdProvider() + }); + } + + private sealed class FakeTransport : IRudderTransport + { + public Func>? Handler; + + public Task SendAsync( + string method, + string path, + TRequest? request, + string? accessToken, + CancellationToken cancellationToken = default) + { + return Handle(method, path, accessToken); + } + + private async Task Handle(string method, string path, string? accessToken) + { + var result = await Handler!(method, path, accessToken); + return (TResponse)result!; + } + } + + private sealed class FakeTokenStore : ITokenStore + { + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public string? GetAccessToken() => AccessToken; + public string? GetRefreshToken() => RefreshToken; + public void SaveTokens(string accessToken, string refreshToken) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + } + + public void Clear() + { + AccessToken = null; + RefreshToken = null; + } + } + + private sealed class FakeDeviceIdProvider : IDeviceIdProvider + { + public string DeviceId => "device-id"; + } +} diff --git a/tests/Rudder.Core.Tests/HttpClientTransportTests.cs b/tests/Rudder.Core.Tests/HttpClientTransportTests.cs new file mode 100644 index 0000000..f3c4add --- /dev/null +++ b/tests/Rudder.Core.Tests/HttpClientTransportTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Text; +using RudderSdk.Core; +using RudderSdk.Core.Models.Player; + +using Xunit; + +namespace RudderSdk.Core.Tests; + +public sealed class HttpClientTransportTests +{ + [Fact] + public async Task Maps_404_To_NotFoundException_With_Code_And_RequestId() + { + var transport = CreateTransport(_ => Error(HttpStatusCode.NotFound, """{"code":"store_not_found","error":"Store not found","requestId":"req-123"}""")); + + var exception = await Assert.ThrowsAsync( + () => transport.SendAsync("GET", "/sdk/v1/stores/main", null, "token")); + + Assert.Equal(404, exception.StatusCode); + Assert.Equal("store_not_found", exception.Code); + Assert.Equal("req-123", exception.RequestId); + Assert.Equal("Store not found", exception.Message); + } + + [Fact] + public async Task Maps_429_To_RateLimitException() + { + var transport = CreateTransport(_ => Error(HttpStatusCode.TooManyRequests, """{"code":"rate_limited","error":"Slow down"}""")); + + var exception = await Assert.ThrowsAsync( + () => transport.SendAsync("GET", "/sdk/v1/player/information", null, null)); + + Assert.Equal(429, exception.StatusCode); + Assert.Equal("rate_limited", exception.Code); + } + + [Fact] + public async Task Maps_401_To_AuthException() + { + var transport = CreateTransport(_ => Error(HttpStatusCode.Unauthorized, """{"error":"Unauthorized"}""")); + + var exception = await Assert.ThrowsAsync( + () => transport.SendAsync("GET", "/sdk/v1/player/information", null, null)); + + Assert.Equal(401, exception.StatusCode); + Assert.Null(exception.RequestId); + } + + [Fact] + public async Task Maps_500_To_Base_ApiException() + { + var transport = CreateTransport(_ => Error(HttpStatusCode.InternalServerError, """{"code":"internal","error":"Boom"}""")); + + var exception = await Assert.ThrowsAsync( + () => transport.SendAsync("GET", "/sdk/v1/player/information", null, null)); + + Assert.Equal(typeof(RudderApiException), exception.GetType()); + Assert.Equal(500, exception.StatusCode); + Assert.Equal("internal", exception.Code); + } + + [Fact] + public async Task Non_Json_Error_Body_Falls_Back_To_Reason_Phrase() + { + var transport = CreateTransport(_ => new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("bad gateway", Encoding.UTF8, "text/html"), + ReasonPhrase = "Bad Gateway" + }); + + var exception = await Assert.ThrowsAsync( + () => transport.SendAsync("GET", "/sdk/v1/player/information", null, null)); + + Assert.Equal(502, exception.StatusCode); + Assert.Equal(string.Empty, exception.Code); + Assert.Equal("Bad Gateway", exception.Message); + } + + [Fact] + public async Task Network_Failure_Maps_To_NetworkException() + { + var transport = CreateTransport(_ => throw new HttpRequestException("Connection refused")); + + var exception = await Assert.ThrowsAsync( + () => transport.SendAsync("GET", "/sdk/v1/player/information", null, null)); + + Assert.Equal(0, exception.StatusCode); + Assert.Equal("Connection refused", exception.Message); + } + + [Fact] + public async Task Success_Deserializes_Response_And_Sends_Bearer_Token() + { + StubHandler? handler = null; + var transport = CreateTransport(request => + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"player":{"id":"p1"},"wallets":[]}""", Encoding.UTF8, "application/json") + }; + }, h => handler = h); + + var profile = await transport.SendAsync("GET", "/sdk/v1/player/information", null, "access-token"); + + Assert.Equal("p1", profile.Player.Id); + Assert.Equal("Bearer", handler!.LastRequest!.Headers.Authorization!.Scheme); + Assert.Equal("access-token", handler.LastRequest.Headers.Authorization.Parameter); + Assert.Equal("http://localhost:8082/sdk/v1/player/information", handler.LastRequest.RequestUri!.ToString()); + } + + private static HttpClientTransport CreateTransport( + Func respond, + Action? capture = null) + { + var handler = new StubHandler(respond); + capture?.Invoke(handler); + return new HttpClientTransport("http://localhost:8082/", new HttpClient(handler)); + } + + private static HttpResponseMessage Error(HttpStatusCode statusCode, string json) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _respond; + + public StubHandler(Func respond) => _respond = respond; + + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(_respond(request)); + } + } +} diff --git a/tests/Rudder.Core.Tests/Rudder.Core.Tests.csproj b/tests/Rudder.Core.Tests/Rudder.Core.Tests.csproj new file mode 100644 index 0000000..7d75dbd --- /dev/null +++ b/tests/Rudder.Core.Tests/Rudder.Core.Tests.csproj @@ -0,0 +1,21 @@ + + + net9.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/tests/Rudder.Core.Tests/RudderClientTests.cs b/tests/Rudder.Core.Tests/RudderClientTests.cs new file mode 100644 index 0000000..a61a220 --- /dev/null +++ b/tests/Rudder.Core.Tests/RudderClientTests.cs @@ -0,0 +1,215 @@ +using RudderSdk.Core; +using RudderSdk.Core.Abstractions; +using RudderSdk.Core.Models.Auth; + +using Xunit; + +namespace RudderSdk.Core.Tests; + +public sealed class RudderClientTests +{ + [Fact] + public void Constructor_Composes_All_Runtime_Services() + { + var client = CreateClient(); + + Assert.NotNull(client.Auth); + Assert.NotNull(client.Player); + Assert.NotNull(client.RemoteConfig); + Assert.NotNull(client.Storage); + Assert.NotNull(client.Stores); + Assert.NotNull(client.Leaderboards); + Assert.NotNull(client.Inventory); + Assert.NotNull(client.BattlePass); + Assert.NotNull(client.Quests); + Assert.NotNull(client.Ugc); + Assert.NotNull(client.Scenario); + Assert.NotNull(client.Realtime); + } + + [Fact] + public void Constructor_Requires_BaseUrl_And_ProjectKey() + { + Assert.Throws(() => new RudderClient(new RudderClientOptions { ProjectKey = "key" })); + Assert.Throws(() => new RudderClient(new RudderClientOptions { BaseUrl = "http://localhost" })); + } + + [Fact] + public void Constructor_Uses_HttpClientTransport_By_Default() + { + var client = new RudderClient(new RudderClientOptions + { + BaseUrl = "http://localhost:8082", + ProjectKey = "project-key" + }); + + Assert.NotNull(client); + } + + [Fact] + public async Task Auth_LoginWithDevice_Saves_Tokens_And_Raises_SignedIn() + { + var transport = new FakeTransport(); + transport.Responses[typeof(LoginViaDeviceResponse)] = new LoginViaDeviceResponse + { + AccessToken = "access-token", + RefreshToken = "refresh-token" + }; + var tokenStore = new FakeTokenStore(); + var client = CreateClient(transport: transport, tokenStore: tokenStore); + var states = new List(); + client.Auth.AuthStateChanged += states.Add; + + await client.Auth.LoginWithDeviceAsync("en", "en", "nickname"); + + var request = Assert.IsType(transport.LastRequest); + Assert.Equal("POST", transport.LastMethod); + Assert.Equal("/sdk/v1/authorization/device", transport.LastPath); + Assert.Equal("nickname", request.Nickname); + Assert.Equal("access-token", tokenStore.AccessToken); + Assert.Equal("refresh-token", tokenStore.RefreshToken); + Assert.Equal(new[] { RudderAuthState.SignedIn }, states); + } + + [Fact] + public void Auth_Logout_Clears_Tokens_And_Raises_SignedOut() + { + var tokenStore = new FakeTokenStore { AccessToken = "a", RefreshToken = "r" }; + var client = CreateClient(tokenStore: tokenStore); + var states = new List(); + client.Auth.AuthStateChanged += states.Add; + + client.Auth.Logout(); + + Assert.Null(tokenStore.AccessToken); + Assert.Null(tokenStore.RefreshToken); + Assert.Equal(new[] { RudderAuthState.SignedOut }, states); + } + + [Fact] + public async Task Realtime_Connect_Returns_Session_From_Injected_Transport() + { + var realtimeTransport = new FakeRealtimeTransport(); + var tokenStore = new FakeTokenStore { AccessToken = "access-token" }; + var client = CreateClient( + tokenStore: tokenStore, + realtimeTransportFactory: new FakeRealtimeTransportFactory(realtimeTransport)); + + var session = await client.Realtime.ConnectAsync(); + + Assert.NotNull(session); + Assert.True(session.IsConnected); + Assert.Equal("access-token", session.AccessToken); + Assert.Equal(new Uri("ws://localhost:8090/api/realtime/ws"), realtimeTransport.Uri); + } + + private static RudderClient CreateClient( + FakeTransport? transport = null, + FakeTokenStore? tokenStore = null, + IRealtimeTransportFactory? realtimeTransportFactory = null) + { + return new RudderClient(new RudderClientOptions + { + BaseUrl = "http://localhost:8082", + RealtimeUrl = "ws://localhost:8090/api/realtime/ws", + ProjectKey = "project-key", + Transport = transport ?? new FakeTransport(), + TokenStore = tokenStore ?? new FakeTokenStore(), + DeviceIdProvider = new FakeDeviceIdProvider(), + RealtimeTransportFactory = realtimeTransportFactory ?? new FakeRealtimeTransportFactory(new FakeRealtimeTransport()) + }); + } + + internal sealed class FakeTransport : IRudderTransport + { + public Dictionary Responses { get; } = new(); + public string? LastMethod { get; private set; } + public string? LastPath { get; private set; } + public object? LastRequest { get; private set; } + public string? LastAccessToken { get; private set; } + + public Task SendAsync( + string method, + string path, + TRequest? request, + string? accessToken, + CancellationToken cancellationToken = default) + { + LastMethod = method; + LastPath = path; + LastRequest = request; + LastAccessToken = accessToken; + + if (Responses.TryGetValue(typeof(TResponse), out var response)) + return Task.FromResult((TResponse)response); + + return Task.FromResult(default(TResponse)!); + } + } + + internal sealed class FakeTokenStore : ITokenStore + { + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public string? GetAccessToken() => AccessToken; + public string? GetRefreshToken() => RefreshToken; + public void SaveTokens(string accessToken, string refreshToken) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + } + + public void Clear() + { + AccessToken = null; + RefreshToken = null; + } + } + + private sealed class FakeDeviceIdProvider : IDeviceIdProvider + { + public string DeviceId => "device-id"; + } + + private sealed class FakeRealtimeTransportFactory : IRealtimeTransportFactory + { + private readonly IRealtimeTransport _transport; + + public FakeRealtimeTransportFactory(IRealtimeTransport transport) => _transport = transport; + + public IRealtimeTransport Create() => _transport; + } + + private sealed class FakeRealtimeTransport : IRealtimeTransport + { + public Uri? Uri { get; private set; } + public bool IsConnected { get; private set; } + public event Action? Closed; + public event Action? Error; + public event Action>? Received; + + public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default) + { + Uri = uri; + IsConnected = true; + return Task.CompletedTask; + } + + public Task SendAsync(ArraySegment payload, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task CloseAsync(CancellationToken cancellationToken = default) + { + IsConnected = false; + Closed?.Invoke(); + return Task.CompletedTask; + } + + public void Update(float deltaTime) + { + } + + public void EmitError(Exception ex) => Error?.Invoke(ex); + public void EmitReceived(ArraySegment data) => Received?.Invoke(data); + } +} diff --git a/tests/Rudder.Core.Tests/ScenarioServiceTests.cs b/tests/Rudder.Core.Tests/ScenarioServiceTests.cs new file mode 100644 index 0000000..aa1170f --- /dev/null +++ b/tests/Rudder.Core.Tests/ScenarioServiceTests.cs @@ -0,0 +1,504 @@ +using Newtonsoft.Json.Linq; +using RudderSdk.Core; +using RudderSdk.Core.Abstractions; +using RudderSdk.Core.Models; +using RudderSdk.Core.Models.Scenarios; + +using Xunit; + +namespace RudderSdk.Core.Tests; + +public sealed class ScenarioServiceTests +{ + [Fact] + public async Task TriggerAsync_Starts_All_Returned_Plans() + { + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan("plan-1", Node("n1", "notification")), + Plan("plan-2", Node("n2", "notification")) + } + }); + var client = CreateClient(transport); + var notifications = new List(); + client.Scenario.OnNotification += notifications.Add; + + var started = await client.Scenario.TriggerAsync("login"); + + Assert.Equal(2, notifications.Count); + Assert.Equal(2, started.Count); + Assert.Equal(2, client.Scenario.ActiveRuns.Count); + } + + [Fact] + public async Task Completing_Node_Activates_All_Matching_Client_Edges() + { + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan( + "plan", + new[] { Node("start", "notification"), Node("store", "store"), Node("wait", "wait", new { duration = 1, unit = "minutes" }) }, + new[] + { + Edge("start", "output", "store"), + Edge("start", "output", "wait") + }) + } + }); + var client = CreateClient(transport); + NotificationSession? notification = null; + var stores = 0; + var waits = 0; + client.Scenario.OnNotification += session => notification = session; + client.Scenario.OnStoreOffer += _ => stores++; + client.Scenario.OnWait += _ => waits++; + + await client.Scenario.TriggerAsync("login"); + await notification!.CompleteAsync(); + + Assert.Equal(1, stores); + Assert.Equal(1, waits); + Assert.Equal(2, client.Scenario.ActiveRuns.Single().ActiveNodeIds.Count); + } + + [Fact] + public async Task Emits_Typed_Events_For_All_Supported_Interactive_Node_Types() + { + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan( + "plan", + new[] + { + Node("start", "notification"), + Node("store", "store"), + Node("quest", "quest"), + Node("leaderboard", "leaderboard"), + Node("battlepass", "battlepass"), + Node("battlepass-level", "battlepass_level"), + }, + new[] + { + Edge("start", "output", "store"), + Edge("start", "output", "quest"), + Edge("start", "output", "leaderboard"), + Edge("start", "output", "battlepass"), + Edge("start", "output", "battlepass-level"), + }) + } + }); + var client = CreateClient(transport); + NotificationSession? notification = null; + var store = 0; + var quest = 0; + var leaderboard = 0; + var battlePass = 0; + var battlePassLevel = 0; + client.Scenario.OnNotification += session => notification = session; + client.Scenario.OnStoreOffer += _ => store++; + client.Scenario.OnQuest += _ => quest++; + client.Scenario.OnLeaderboard += _ => leaderboard++; + client.Scenario.OnBattlePass += _ => battlePass++; + client.Scenario.OnBattlePassLevel += _ => battlePassLevel++; + + await client.Scenario.TriggerAsync("login"); + await notification!.CompleteAsync(); + + Assert.Equal(1, store); + Assert.Equal(1, quest); + Assert.Equal(1, leaderboard); + Assert.Equal(1, battlePass); + Assert.Equal(1, battlePassLevel); + } + + [Fact] + public async Task Wait_Persists_Deadline_And_Completes_After_Restore() + { + var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero)); + var stateStore = new FakePlanStateStore(); + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan( + "plan", + new[] { Node("wait", "wait", new { duration = 30, unit = "minutes" }), Node("done", "notification") }, + new[] { Edge("wait", "onComplete", "done") }) + } + }); + var client = CreateClient(transport, clock: clock, stateStore: stateStore); + + await client.Scenario.TriggerAsync("login"); + Assert.False(string.IsNullOrEmpty(stateStore.State)); + + clock.UtcNow = clock.UtcNow.AddMinutes(31); + var restoredClient = CreateClient(new FakeTransport(), clock: clock, stateStore: stateStore); + var completed = 0; + restoredClient.Scenario.OnNotification += _ => completed++; + + await restoredClient.Scenario.RestoreAsync(); + restoredClient.Update(0); + await Task.Delay(20); + + Assert.Equal(1, completed); + var run = Assert.Single(restoredClient.Scenario.ActiveRuns); + Assert.Equal("done", Assert.Single(run.ActiveNodeIds)); + Assert.Contains("\"done\"", stateStore.State); + } + + [Fact] + public async Task RemoteConfigOverride_Applies_Patches_And_Continues() + { + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan( + "plan", + new[] + { + Node("override", "remote_config_override", new + { + patches = new object[] + { + new { path = "difficulty", valueType = "string", value = "hard" }, + new { path = "enemy_count", valueType = "int", value = 12 } + } + }), + Node("done", "notification") + }, + new[] { Edge("override", "output", "done") }) + } + }); + var client = CreateClient(transport); + var notifications = 0; + var configChanges = 0; + client.Scenario.OnNotification += _ => notifications++; + client.Scenario.OnConfigChanged += _ => configChanges++; + + await client.Scenario.TriggerAsync("login"); + + Assert.Equal("hard", client.RemoteConfig.Get("difficulty", "normal")); + Assert.Equal(12, client.RemoteConfig.Get("enemy_count", 0)); + Assert.Equal(1, configChanges); + Assert.Equal(1, notifications); + } + + [Fact] + public async Task Boundary_Callback_Sends_Source_Node_And_Starts_Continuation_Plan() + { + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan( + "plan", + new[] { Node("store", "store") }, + Array.Empty(), + new[] { Boundary("store", "onPurchase", "server-condition") }, + "run-1") + } + }); + transport.Enqueue(new HandleScenarioCallbackResponse + { + Plan = Plan("continuation", Node("done", "notification"), "run-1") + }); + var client = CreateClient(transport); + StoreOfferSession? store = null; + var notifications = 0; + client.Scenario.OnStoreOffer += session => store = session; + client.Scenario.OnNotification += _ => notifications++; + + await client.Scenario.TriggerAsync("login"); + await store!.PurchaseAsync(); + + var callback = Assert.IsType( + transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request); + Assert.Equal("store", callback.NodeId); + Assert.Equal("onPurchase", callback.Handle); + Assert.Equal(1, notifications); + } + + [Fact] + public async Task Boundary_Wins_Over_Local_Edges() + { + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan( + "plan", + new[] { Node("store", "store"), Node("local", "notification") }, + new[] { Edge("store", "onPurchase", "local") }, + new[] { Boundary("store", "onPurchase", "server-condition") }, + "run-1") + } + }); + transport.Enqueue(new HandleScenarioCallbackResponse + { + Plan = Plan("continuation", Node("server", "notification"), "run-1") + }); + var client = CreateClient(transport); + StoreOfferSession? store = null; + var notificationIds = new List(); + client.Scenario.OnStoreOffer += session => store = session; + client.Scenario.OnNotification += session => notificationIds.Add(session.Id); + + await client.Scenario.TriggerAsync("login"); + await store!.PurchaseAsync(); + + Assert.Equal(new[] { "server" }, notificationIds); + Assert.Contains(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback"); + } + + [Fact] + public async Task Quest_Progress_Response_Can_Start_Continuation_Plan() + { + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan("plan", Node("quest", "quest")) + } + }); + transport.Enqueue(new UpdateScenarioCounterResponse + { + Completed = true, + Plan = Plan("continuation", Node("done", "notification")) + }); + var client = CreateClient(transport); + QuestSession? quest = null; + var notifications = 0; + client.Scenario.OnQuest += session => quest = session; + client.Scenario.OnNotification += _ => notifications++; + + await client.Scenario.TriggerAsync("login"); + await quest!.AddProgressAsync("wins", 1); + + var counter = Assert.IsType( + transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/counter").Request); + Assert.Equal("quest", counter.NodeId); + Assert.Equal("wins", counter.CounterKey); + Assert.Equal(1, notifications); + } + + [Fact] + public async Task Unknown_Nodes_Are_Logged_And_Ignored() + { + var transport = new FakeTransport(); + transport.Enqueue(new TriggerScenarioResponse + { + Plans = new List + { + Plan("plan", Node("future", "future_node")) + } + }); + var logger = new FakeLogger(); + var client = CreateClient(transport, logger: logger); + + await client.Scenario.TriggerAsync("login"); + + var (level, message) = Assert.Single(logger.Messages); + Assert.Equal(RudderLogLevel.Warning, level); + Assert.Contains("Unsupported scenario node type 'future_node'", message); + } + + private static RudderClient CreateClient( + FakeTransport transport, + FakeClock? clock = null, + FakePlanStateStore? stateStore = null, + IRudderLogger? logger = null) + { + return new RudderClient(new RudderClientOptions + { + BaseUrl = "http://localhost:8082", + RealtimeUrl = "ws://localhost:8090/api/realtime/ws", + ProjectKey = "project-key", + Transport = transport, + TokenStore = new FakeTokenStore { AccessToken = "access-token" }, + DeviceIdProvider = new FakeDeviceIdProvider(), + Clock = clock ?? new FakeClock(DateTimeOffset.UtcNow), + PlanStateStore = stateStore ?? new FakePlanStateStore(), + RealtimeTransportFactory = new FakeRealtimeTransportFactory(), + Logger = logger + }); + } + + private static ExecutionPlan Plan(string id, ExecutionPlanNode node, string? runId = null) + => Plan(id, new[] { node }, Array.Empty(), runId: runId); + + private static ExecutionPlan Plan( + string id, + IEnumerable nodes, + IEnumerable edges, + IEnumerable? boundaries = null, + string? runId = null) + { + var nodeList = nodes.ToList(); + return new ExecutionPlan + { + PlanId = id, + ScenarioId = "scenario-" + id, + UserId = "user", + StartNodeId = nodeList[0].Id, + Nodes = nodeList, + Edges = edges.ToList(), + BoundaryNodes = boundaries?.ToList() ?? new List(), + RunId = runId!, + Context = new JObject() + }; + } + + private static ExecutionPlanNode Node(string id, string type, object? data = null) + { + return new ExecutionPlanNode + { + Id = id, + Type = type, + Data = data == null ? new JObject() : JObject.FromObject(data) + }; + } + + private static PlanEdge Edge(string source, string handle, string target) + { + return new PlanEdge + { + Id = source + "-" + handle + "-" + target, + Source = source, + SourceHandle = handle, + Target = target, + TargetHandle = "in" + }; + } + + private static BoundaryNode Boundary(string source, string handle, string target) + { + return new BoundaryNode + { + SourceNodeId = source, + SourceHandle = handle, + NodeId = target, + CallbackUrl = "/sdk/v1/scenarios/callback" + }; + } + + private sealed class FakeTransport : IRudderTransport + { + private readonly Queue _responses = new(); + public List Calls { get; } = new(); + + public void Enqueue(object response) => _responses.Enqueue(response); + + public Task SendAsync( + string method, + string path, + TRequest? request, + string? accessToken, + CancellationToken cancellationToken = default) + { + Calls.Add(new FakeCall(method, path, request, accessToken)); + if (_responses.Count == 0) + return Task.FromResult(default(TResponse)!); + + return Task.FromResult((TResponse)_responses.Dequeue()); + } + } + + private sealed class FakeCall + { + public FakeCall(string method, string path, object? request, string? accessToken) + { + Method = method; + Path = path; + Request = request; + AccessToken = accessToken; + } + + public string Method { get; } + public string Path { get; } + public object? Request { get; } + public string? AccessToken { get; } + } + + private sealed class FakeTokenStore : ITokenStore + { + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public string? GetAccessToken() => AccessToken; + public string? GetRefreshToken() => RefreshToken; + public void SaveTokens(string accessToken, string refreshToken) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + } + public void Clear() + { + AccessToken = null; + RefreshToken = null; + } + } + + private sealed class FakeDeviceIdProvider : IDeviceIdProvider + { + public string DeviceId => "device-id"; + } + + private sealed class FakeClock : IClock + { + public FakeClock(DateTimeOffset utcNow) => UtcNow = utcNow; + public DateTimeOffset UtcNow { get; set; } + } + + private sealed class FakePlanStateStore : IPlanStateStore + { + public string? State { get; set; } + } + + private sealed class FakeLogger : IRudderLogger + { + public List<(RudderLogLevel Level, string Message)> Messages { get; } = new(); + public void Log(RudderLogLevel level, string message) => Messages.Add((level, message)); + } + + private sealed class FakeRealtimeTransportFactory : IRealtimeTransportFactory + { + public IRealtimeTransport Create() => new FakeRealtimeTransport(); + } + +#pragma warning disable CS0067 + private sealed class FakeRealtimeTransport : IRealtimeTransport + { + public bool IsConnected { get; private set; } + public event Action? Closed; + public event Action? Error; + public event Action>? Received; + public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default) + { + IsConnected = true; + return Task.CompletedTask; + } + public Task SendAsync(ArraySegment payload, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task CloseAsync(CancellationToken cancellationToken = default) + { + IsConnected = false; + Closed?.Invoke(); + return Task.CompletedTask; + } + public void Update(float deltaTime) { } + } +#pragma warning restore CS0067 +}