From 8cf738d9f09ce45774f9ee8512672024f4eee248 Mon Sep 17 00:00:00 2001 From: rudder Date: Wed, 12 Aug 2026 14:04:55 +0300 Subject: [PATCH] Initial commit --- .DS_Store | Bin 0 -> 6148 bytes .editorconfig | 15 + .gitignore | 5 + Abstractions/IClock.cs | 10 + Abstractions/IDeviceIdProvider.cs | 12 + Abstractions/IPlanScheduler.cs | 12 + Abstractions/IPlanStateStore.cs | 11 + Abstractions/IRealtimeTransport.cs | 36 ++ Abstractions/IRealtimeTransportFactory.cs | 8 + Abstractions/IRudderLogger.cs | 24 + Abstractions/IRudderTransport.cs | 28 + Abstractions/ITokenStore.cs | 20 + Abstractions/IUploadTransport.cs | 14 + Auth/LoginViaDeviceRequest.cs | 25 + Auth/LoginViaDeviceResponse.cs | 16 + Auth/RefreshAccessTokenRequest.cs | 13 + Auth/RefreshAccessTokenResponse.cs | 16 + BattlePass/AddBattlePassXpRequest.cs | 25 + BattlePass/AddBattlePassXpResponse.cs | 26 + BattlePass/BattlePassReward.cs | 19 + BattlePass/ClaimBattlePassRewardRequest.cs | 25 + BattlePass/ClaimBattlePassRewardResponse.cs | 22 + BattlePass/ClaimedTier.cs | 16 + BattlePass/GetBattlePassProgressRequest.cs | 16 + BattlePass/GetBattlePassProgressResponse.cs | 22 + .../PurchaseBattlePassPremiumRequest.cs | 22 + .../PurchaseBattlePassPremiumResponse.cs | 20 + BoundaryNode.cs | 31 ++ CHANGELOG.md | 76 +++ Catalog/CatalogItem.cs | 22 + Catalog/ListCatalogItemsResponse.cs | 13 + ErrorResponse.cs | 19 + ExecutionPlan.cs | 37 ++ ExecutionPlanNode.cs | 19 + GuidDeviceIdProvider.cs | 15 + HttpClientTransport.cs | 115 ++++ InMemoryTokenStore.cs | 33 ++ Inventory/GetInventoryResponse.cs | 13 + Inventory/PlayerInventoryItem.cs | 25 + Leaderboards/GetRankingResponse.cs | 16 + Leaderboards/RankEntry.cs | 22 + Leaderboards/SubmitScoreRequest.cs | 16 + PlanEdge.cs | 25 + Player/Player.cs | 28 + Player/PlayerProfile.cs | 16 + Player/Wallet.cs | 16 + Quests/ClaimQuestRequest.cs | 13 + Quests/ClaimQuestResponse.cs | 22 + Quests/ListQuestsRequest.cs | 10 + Quests/ListQuestsResponse.cs | 13 + Quests/Quest.cs | 25 + Quests/QuestObjectiveProgress.cs | 25 + Quests/QuestReward.cs | 19 + README.md | 98 ++++ RemoteConfig/ListRemoteConfigsResponse.cs | 13 + RemoteConfig/RemoteConfig.cs | 40 ++ Rudder.Core.csproj | 23 + RudderApiException.cs | 39 ++ RudderAuthException.cs | 15 + RudderAuthState.cs | 11 + RudderClient.cs | 239 +++++++++ RudderClientOptions.cs | 47 ++ RudderErrorCodes.cs | 16 + RudderNetworkException.cs | 17 + RudderNotFoundException.cs | 11 + RudderRateLimitException.cs | 11 + Scenarios/GetScenarioRunRequest.cs | 13 + Scenarios/GetScenarioRunResponse.cs | 20 + Scenarios/HandleScenarioCallbackRequest.cs | 22 + Scenarios/HandleScenarioCallbackResponse.cs | 14 + Scenarios/TriggerScenarioRequest.cs | 13 + Scenarios/TriggerScenarioResponse.cs | 14 + Scenarios/UpdateScenarioCounterRequest.cs | 25 + Scenarios/UpdateScenarioCounterResponse.cs | 17 + Services/AuthService.cs | 73 +++ Services/BattlePassService.cs | 77 +++ Services/InventoryService.cs | 25 + Services/LeaderboardsService.cs | 73 +++ Services/PlayerService.cs | 17 + Services/QuestsService.cs | 37 ++ Services/RealtimeService.cs | 105 ++++ Services/RemoteConfigService.cs | 155 ++++++ Services/ScenarioService.cs | 238 +++++++++ Services/Scenarios/Models/PlanRun.cs | 42 ++ .../Scenarios/Models/ScenarioFailedEvent.cs | 23 + .../Scenarios/Models/ScenarioNodeContext.cs | 78 +++ .../Scenarios/ScenarioService.Dispatch.cs | 190 +++++++ .../Scenarios/ScenarioService.NodeTypes.cs | 13 + .../Scenarios/ScenarioService.Persistence.cs | 92 ++++ Services/Scenarios/ScenarioService.Runtime.cs | 341 ++++++++++++ .../Sessions/BattlePassLevelSession.cs | 25 + .../Scenarios/Sessions/BattlePassSession.cs | 43 ++ .../Sessions/ConfigChangedSession.cs | 13 + .../Scenarios/Sessions/LeaderboardSession.cs | 31 ++ .../Scenarios/Sessions/NotificationSession.cs | 25 + Services/Scenarios/Sessions/QuestSession.cs | 37 ++ .../Scenarios/Sessions/StoreOfferSession.cs | 56 ++ Services/Scenarios/Sessions/WaitSession.cs | 19 + Services/StorageService.cs | 60 +++ Services/StoresService.cs | 53 ++ Services/UgcService.cs | 155 ++++++ Services/Url.cs | 21 + Storage/GetStorageResponse.cs | 16 + Storage/StorageItem.cs | 19 + Storage/UpdateStorageRequest.cs | 16 + Stores/ListStoresResponse.cs | 16 + Stores/Offer.cs | 31 ++ Stores/OfferContent.cs | 16 + Stores/OfferPrice.cs | 16 + Stores/PurchaseOfferRequest.cs | 19 + Stores/PurchaseOfferResponse.cs | 19 + Stores/Store.cs | 46 ++ Ugc/DeleteUgcResponse.cs | 13 + Ugc/GetDownloadUrlResponse.cs | 13 + Ugc/GetUploadUrlResponse.cs | 16 + Ugc/ListUgcResponse.cs | 16 + Ugc/SubmitUgcRequest.cs | 25 + Ugc/SubmittedBy.cs | 16 + Ugc/UgcSubmission.cs | 43 ++ tests/Rudder.Core.Tests/AuthSessionTests.cs | 197 +++++++ .../HttpClientTransportTests.cs | 143 +++++ .../Rudder.Core.Tests.csproj | 21 + tests/Rudder.Core.Tests/RudderClientTests.cs | 215 ++++++++ .../Rudder.Core.Tests/ScenarioServiceTests.cs | 504 ++++++++++++++++++ 124 files changed, 5433 insertions(+) create mode 100644 .DS_Store create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Abstractions/IClock.cs create mode 100644 Abstractions/IDeviceIdProvider.cs create mode 100644 Abstractions/IPlanScheduler.cs create mode 100644 Abstractions/IPlanStateStore.cs create mode 100644 Abstractions/IRealtimeTransport.cs create mode 100644 Abstractions/IRealtimeTransportFactory.cs create mode 100644 Abstractions/IRudderLogger.cs create mode 100644 Abstractions/IRudderTransport.cs create mode 100644 Abstractions/ITokenStore.cs create mode 100644 Abstractions/IUploadTransport.cs create mode 100644 Auth/LoginViaDeviceRequest.cs create mode 100644 Auth/LoginViaDeviceResponse.cs create mode 100644 Auth/RefreshAccessTokenRequest.cs create mode 100644 Auth/RefreshAccessTokenResponse.cs create mode 100644 BattlePass/AddBattlePassXpRequest.cs create mode 100644 BattlePass/AddBattlePassXpResponse.cs create mode 100644 BattlePass/BattlePassReward.cs create mode 100644 BattlePass/ClaimBattlePassRewardRequest.cs create mode 100644 BattlePass/ClaimBattlePassRewardResponse.cs create mode 100644 BattlePass/ClaimedTier.cs create mode 100644 BattlePass/GetBattlePassProgressRequest.cs create mode 100644 BattlePass/GetBattlePassProgressResponse.cs create mode 100644 BattlePass/PurchaseBattlePassPremiumRequest.cs create mode 100644 BattlePass/PurchaseBattlePassPremiumResponse.cs create mode 100644 BoundaryNode.cs create mode 100644 CHANGELOG.md create mode 100644 Catalog/CatalogItem.cs create mode 100644 Catalog/ListCatalogItemsResponse.cs create mode 100644 ErrorResponse.cs create mode 100644 ExecutionPlan.cs create mode 100644 ExecutionPlanNode.cs create mode 100644 GuidDeviceIdProvider.cs create mode 100644 HttpClientTransport.cs create mode 100644 InMemoryTokenStore.cs create mode 100644 Inventory/GetInventoryResponse.cs create mode 100644 Inventory/PlayerInventoryItem.cs create mode 100644 Leaderboards/GetRankingResponse.cs create mode 100644 Leaderboards/RankEntry.cs create mode 100644 Leaderboards/SubmitScoreRequest.cs create mode 100644 PlanEdge.cs create mode 100644 Player/Player.cs create mode 100644 Player/PlayerProfile.cs create mode 100644 Player/Wallet.cs create mode 100644 Quests/ClaimQuestRequest.cs create mode 100644 Quests/ClaimQuestResponse.cs create mode 100644 Quests/ListQuestsRequest.cs create mode 100644 Quests/ListQuestsResponse.cs create mode 100644 Quests/Quest.cs create mode 100644 Quests/QuestObjectiveProgress.cs create mode 100644 Quests/QuestReward.cs create mode 100644 README.md create mode 100644 RemoteConfig/ListRemoteConfigsResponse.cs create mode 100644 RemoteConfig/RemoteConfig.cs create mode 100644 Rudder.Core.csproj create mode 100644 RudderApiException.cs create mode 100644 RudderAuthException.cs create mode 100644 RudderAuthState.cs create mode 100644 RudderClient.cs create mode 100644 RudderClientOptions.cs create mode 100644 RudderErrorCodes.cs create mode 100644 RudderNetworkException.cs create mode 100644 RudderNotFoundException.cs create mode 100644 RudderRateLimitException.cs create mode 100644 Scenarios/GetScenarioRunRequest.cs create mode 100644 Scenarios/GetScenarioRunResponse.cs create mode 100644 Scenarios/HandleScenarioCallbackRequest.cs create mode 100644 Scenarios/HandleScenarioCallbackResponse.cs create mode 100644 Scenarios/TriggerScenarioRequest.cs create mode 100644 Scenarios/TriggerScenarioResponse.cs create mode 100644 Scenarios/UpdateScenarioCounterRequest.cs create mode 100644 Scenarios/UpdateScenarioCounterResponse.cs create mode 100644 Services/AuthService.cs create mode 100644 Services/BattlePassService.cs create mode 100644 Services/InventoryService.cs create mode 100644 Services/LeaderboardsService.cs create mode 100644 Services/PlayerService.cs create mode 100644 Services/QuestsService.cs create mode 100644 Services/RealtimeService.cs create mode 100644 Services/RemoteConfigService.cs create mode 100644 Services/ScenarioService.cs create mode 100644 Services/Scenarios/Models/PlanRun.cs create mode 100644 Services/Scenarios/Models/ScenarioFailedEvent.cs create mode 100644 Services/Scenarios/Models/ScenarioNodeContext.cs create mode 100644 Services/Scenarios/ScenarioService.Dispatch.cs create mode 100644 Services/Scenarios/ScenarioService.NodeTypes.cs create mode 100644 Services/Scenarios/ScenarioService.Persistence.cs create mode 100644 Services/Scenarios/ScenarioService.Runtime.cs create mode 100644 Services/Scenarios/Sessions/BattlePassLevelSession.cs create mode 100644 Services/Scenarios/Sessions/BattlePassSession.cs create mode 100644 Services/Scenarios/Sessions/ConfigChangedSession.cs create mode 100644 Services/Scenarios/Sessions/LeaderboardSession.cs create mode 100644 Services/Scenarios/Sessions/NotificationSession.cs create mode 100644 Services/Scenarios/Sessions/QuestSession.cs create mode 100644 Services/Scenarios/Sessions/StoreOfferSession.cs create mode 100644 Services/Scenarios/Sessions/WaitSession.cs create mode 100644 Services/StorageService.cs create mode 100644 Services/StoresService.cs create mode 100644 Services/UgcService.cs create mode 100644 Services/Url.cs create mode 100644 Storage/GetStorageResponse.cs create mode 100644 Storage/StorageItem.cs create mode 100644 Storage/UpdateStorageRequest.cs create mode 100644 Stores/ListStoresResponse.cs create mode 100644 Stores/Offer.cs create mode 100644 Stores/OfferContent.cs create mode 100644 Stores/OfferPrice.cs create mode 100644 Stores/PurchaseOfferRequest.cs create mode 100644 Stores/PurchaseOfferResponse.cs create mode 100644 Stores/Store.cs create mode 100644 Ugc/DeleteUgcResponse.cs create mode 100644 Ugc/GetDownloadUrlResponse.cs create mode 100644 Ugc/GetUploadUrlResponse.cs create mode 100644 Ugc/ListUgcResponse.cs create mode 100644 Ugc/SubmitUgcRequest.cs create mode 100644 Ugc/SubmittedBy.cs create mode 100644 Ugc/UgcSubmission.cs create mode 100644 tests/Rudder.Core.Tests/AuthSessionTests.cs create mode 100644 tests/Rudder.Core.Tests/HttpClientTransportTests.cs create mode 100644 tests/Rudder.Core.Tests/Rudder.Core.Tests.csproj create mode 100644 tests/Rudder.Core.Tests/RudderClientTests.cs create mode 100644 tests/Rudder.Core.Tests/ScenarioServiceTests.cs diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..26a5be2b0ce27b4ab5f71e6391d904f38b185ab1 GIT binary patch literal 6148 zcmeHKziSjh7=3fM7Z#&%5C{qu+t>zE2#eeBZb4?b{!73qVb8w8w!efS6q{ zy2YVSleqLkE7_I%L?y>qTo|vnvhH#rGb{uLf&P0WSDn>oNsA2drGCueDR=#OI9dY@NA055iKGoN! zOgCBQo^dhi@m2POFGijB#40v+aGGbqo~RnqrM zg?9+maP$@JzZ;<!L%MI!h4t6`%6WNOIvg^iJA`@b2F*nE+w(v(l+YpA}z?C}i8_MPD A`v3p{ literal 0 HcmV?d00001 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 +}