9 Commits

Author SHA1 Message Date
edmand46 372ab7cf7e 2.0.0: slug-based quests, scenarios and offers; environment carried by the token; regenerated models
CI / check (push) Successful in 25s
CI / publish (push) Has been skipped
Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
2026-09-06 22:25:32 +03:00
edmand46 62025592ba Effects client reconciles pending effects: drop stale wait deadlines and seen keys, emit completion for vanished runs; run_not_active is terminal
CI / check (push) Successful in 25s
CI / publish (push) Has been skipped
2026-09-06 09:53:45 +03:00
edmand46 ef5624ee4e Release 1.0.0
CI / publish (push) Failing after 19s
CI / check (push) Successful in 28s
2026-09-04 15:37:00 +03:00
edmand46 1029f08fba Docs: skills/README/CHANGELOG for effects client rewrite
- scenarios.md rewritten for server-side execution + effects API
- realtime.md deleted; realtime/planstore references purged from docs
- CHANGELOG Unreleased: major bump + migration table
2026-09-04 14:18:05 +03:00
edmand46 6c590ca520 Server-side scenario execution: effects client replaces local engine
- client.Effects: typed effects + completion methods posting scenario callbacks
- client.Scenario reduced to TriggerAsync
- pending polling via Update() pump: 30s heartbeat + wait-deadline checks
- local engine, plan persistence (IPlanStateStore) and resume removed
- RealtimeService and realtime transports removed (relay never deployed)
- dead IPlanScheduler leftover removed
- models regenerated from openapi (ExecutionPlan/BoundaryNode gone, PendingEffect added)
2026-09-04 14:03:41 +03:00
edmand46 05dd30f31d Regenerate player models with avatarUrl
CI / check (push) Successful in 40s
CI / publish (push) Has been skipped
2026-09-04 12:40:54 +03:00
edmand46 ea20ea6d51 0.4.0: Claim, Models/ DTOs, compile
CI / publish (push) Failing after 39s
CI / check (push) Successful in 52s
LeaderboardSession.ClaimAsync sends onClaim. Generated DTOs live under Models/ with nullable wire fields. Remove incomplete duplicate type files.
2026-08-29 11:33:01 +03:00
edmand46 3c590294c9 sdk: LoginWithCustomAsync + regenerated auth models
CI / check (push) Successful in 58s
CI / publish (push) Has been skipped
2026-08-28 13:36:12 +03:00
edmand46 d4bbafb314 quests: QuestMetrics helpers, README quests section
CI / check (push) Successful in 53s
CI / publish (push) Has been skipped
2026-08-25 16:25:34 +03:00
133 changed files with 2992 additions and 2396 deletions
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -3,3 +3,4 @@ obj/
.vs/
.idea/
TestResults/
.DS_Store
+1 -1
View File
@@ -2,7 +2,7 @@ using System;
namespace RudderSdk.Core.Abstractions;
/// <summary>Time source used by the scenario runtime; override in tests.</summary>
/// <summary>Time source used by the effects client; override in tests.</summary>
public interface IClock
{
/// <summary>Current UTC time.</summary>
-12
View File
@@ -1,12 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core.Abstractions;
/// <summary>Optional scheduler abstraction for delayed plan work.</summary>
public interface IPlanScheduler
{
/// <summary>Completes after the given delay.</summary>
Task ScheduleAsync(TimeSpan delay, CancellationToken cancellationToken = default);
}
-11
View File
@@ -1,11 +0,0 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Persists serialized scenario-run state between app launches.
/// Null by default (no persistence).
/// </summary>
public interface IPlanStateStore
{
/// <summary>Serialized state blob, or null when empty.</summary>
string? State { get; set; }
}
-36
View File
@@ -1,36 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core.Abstractions;
/// <summary>
/// Low-level realtime (websocket) transport. There is no default implementation;
/// provide one through <see cref="IRealtimeTransportFactory"/> (Unity ships its own).
/// </summary>
public interface IRealtimeTransport
{
/// <summary>Raised when the connection closes.</summary>
event Action Closed;
/// <summary>Raised for every incoming message.</summary>
event Action<ArraySegment<byte>> Received;
/// <summary>Raised on transport errors.</summary>
event Action<Exception> Error;
/// <summary>True while the connection is open.</summary>
bool IsConnected { get; }
/// <summary>Opens the connection, giving up after <paramref name="timeout"/>.</summary>
Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default);
/// <summary>Sends one message.</summary>
Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default);
/// <summary>Closes the connection.</summary>
Task CloseAsync(CancellationToken cancellationToken = default);
/// <summary>Pumps time-dependent logic; call every frame.</summary>
void Update(float deltaTime);
}
@@ -1,8 +0,0 @@
namespace RudderSdk.Core.Abstractions;
/// <summary>Creates realtime transports on demand (one per connection).</summary>
public interface IRealtimeTransportFactory
{
/// <summary>Creates a new, unconnected transport.</summary>
IRealtimeTransport Create();
}
+24
View File
@@ -0,0 +1,24 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core.Models.Auth;
public class LoginViaCustomRequest
{
[JsonProperty("customData")]
public JToken? CustomData { 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; }
}
+14
View File
@@ -0,0 +1,14 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
namespace RudderSdk.Core.Models.Auth;
public class LoginViaCustomResponse
{
[JsonProperty("accessToken")]
public string? AccessToken { get; set; }
[JsonProperty("refreshToken")]
public string? RefreshToken { get; set; }
}
+5 -7
View File
@@ -1,25 +1,23 @@
// 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; }
public string? DeviceId { get; set; }
[JsonProperty("key")]
public string Key { get; set; }
public string? Key { get; set; }
[JsonProperty("language")]
public string Language { get; set; }
public string? Language { get; set; }
[JsonProperty("nickname")]
public string Nickname { get; set; }
public string? Nickname { get; set; }
[JsonProperty("region")]
public string Region { get; set; }
public string? Region { get; set; }
}
+2 -4
View File
@@ -1,16 +1,14 @@
// 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; }
public string? AccessToken { get; set; }
[JsonProperty("refreshToken")]
public string RefreshToken { get; set; }
public string? RefreshToken { get; set; }
}
+1 -3
View File
@@ -1,13 +1,11 @@
// 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; }
public string? RefreshToken { get; set; }
}
+2 -4
View File
@@ -1,16 +1,14 @@
// 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; }
public string? AccessToken { get; set; }
[JsonProperty("refreshToken")]
public string RefreshToken { get; set; }
public string? RefreshToken { get; set; }
}
+6 -8
View File
@@ -1,25 +1,23 @@
// 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; }
public long? Amount { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
public string? NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
[JsonProperty("source")]
public string Source { get; set; }
public string? Source { get; set; }
}
+4 -10
View File
@@ -1,26 +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 AddBattlePassXpResponse
{
[JsonProperty("level")]
public int Level { get; set; }
public int? Level { get; set; }
[JsonProperty("leveledUp")]
public bool LeveledUp { get; set; }
public bool? LeveledUp { get; set; }
[JsonProperty("maxLevel")]
public bool MaxLevel { get; set; }
[JsonProperty("plan")]
public ExecutionPlan Plan { get; set; }
public bool? MaxLevel { get; set; }
[JsonProperty("xp")]
public long Xp { get; set; }
public long? Xp { get; set; }
}
-19
View File
@@ -1,19 +0,0 @@
// 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; }
}
+6 -8
View File
@@ -1,25 +1,23 @@
// 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; }
public int? Level { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
public string? NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
[JsonProperty("track")]
public string Track { get; set; }
public string? Track { get; set; }
}
+5 -5
View File
@@ -1,22 +1,22 @@
// 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 ClaimBattlePassRewardResponse
{
[JsonProperty("alreadyClaimed")]
public bool AlreadyClaimed { get; set; }
public bool? AlreadyClaimed { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
public string? Error { get; set; }
[JsonProperty("granted")]
public List<BattlePassReward> Granted { get; set; }
public List<Reward>? Granted { get; set; }
[JsonProperty("success")]
public bool Success { get; set; }
public bool? Success { get; set; }
}
+2 -4
View File
@@ -1,16 +1,14 @@
// 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; }
public int? Level { get; set; }
[JsonProperty("track")]
public string Track { get; set; }
public string? Track { get; set; }
}
+3 -5
View File
@@ -1,16 +1,14 @@
// 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; }
public string? NodeId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
}
+4 -5
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.BattlePass;
@@ -8,15 +7,15 @@ namespace RudderSdk.Core.Models.BattlePass;
public class GetBattlePassProgressResponse
{
[JsonProperty("claimedTiers")]
public List<ClaimedTier> ClaimedTiers { get; set; }
public List<ClaimedTier>? ClaimedTiers { get; set; }
[JsonProperty("level")]
public int Level { get; set; }
public int? Level { get; set; }
[JsonProperty("premiumOwned")]
public bool PremiumOwned { get; set; }
public bool? PremiumOwned { get; set; }
[JsonProperty("xp")]
public long Xp { get; set; }
public long? Xp { get; set; }
}
@@ -1,22 +1,20 @@
// 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; }
public string? IdempotencyKey { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
public string? NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
}
@@ -1,20 +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.BattlePass;
public class PurchaseBattlePassPremiumResponse
{
[JsonProperty("error")]
public string Error { get; set; }
[JsonProperty("plan")]
public ExecutionPlan Plan { get; set; }
public string? Error { get; set; }
[JsonProperty("success")]
public bool Success { get; set; }
public bool? Success { get; set; }
}
-31
View File
@@ -1,31 +0,0 @@
// 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; }
}
+89
View File
@@ -1,5 +1,94 @@
# Changelog
## 2.0.0
Breaking change — major bump, required by the backend environments release.
Every project now has exactly two environments, `staging` and `prod`, and the
SDK key you configure decides which one the player belongs to. The environment
never appears in the client API: it is resolved at login and carried inside the
access and refresh tokens. Tokens issued before this release have no
environment claim and are rejected with 401, so the first call after the
backend upgrade refreshes, fails, clears the stored tokens and raises
`AuthService.AuthStateChanged` with `RudderAuthState.SignedOut`. Log the player
in again with `Auth.LoginWithDeviceAsync` / `LoginWithCustomAsync`.
Quests, scenarios and offers are addressed by their slug instead of their id,
because ids differ between staging and prod while slugs are stable. Renamed
accordingly: `Quest.Id` is now `Quest.Slug`, `QuestsService.ClaimAsync` takes a
quest slug, `ReportProgressAsync` returns the slugs of the completed quests
(`ReportQuestProgressResponse.CompletedQuestSlugs`), `StoresService.PurchaseAsync`
takes an offer slug and `Offer` carries a `Slug`, `QuestMetrics.PurchaseOffer`
builds its metric from the offer slug, and every scenario-scoped type exposes
`ScenarioSlug` instead of `ScenarioId``PendingEffect`, the effect objects,
`ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle pass requests and
`BattlePassService.GetProgressAsync`. Leaderboards, items and stores already
used slugs and are unchanged.
Also shipped here, previously committed but never published: the effects client
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run that
disappears server-side (finished elsewhere, expired after a promote) now emits
`OnScenarioCompleted` instead of lingering; and `run_not_active` joins
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
emits `OnScenarioFailed`.
## 1.0.0
Breaking change — major bump. The local scenario engine is replaced by a
server-driven effects client; the realtime websocket client is removed.
### Removed
- Local scenario runtime: `PlanRun`, `ExecutionPlan` walking, node sessions
(`NotificationSession`, `StoreOfferSession`, `LeaderboardSession`,
`WaitSession`, `QuestSession`, `BattlePassSession`,
`BattlePassLevelSession`, `ConfigChangedSession`), `RespondAsync` /
`Respond`, `RestoreAsync`, `Clear`, `ActiveRuns`, `IsRunning`,
`CurrentNodeId`, `OnConfigChanged`.
- `RudderClientOptions.PlanStateStore` / `IPlanStateStore` and
`IPlanScheduler` — no local plan persistence or delayed plan work.
- `RealtimeService` (`client.Realtime`), `RudderClientOptions.RealtimeUrl` /
`RealtimeTransportFactory`, `IRealtimeTransport` /
`IRealtimeTransportFactory`.
### Changed (breaking)
- `client.Scenario` is a trigger only: `TriggerAsync(eventName)` returns
`Task` (not started plans). Returned pending effects are ingested into
`client.Effects`.
- Subscribe to `client.Effects.On*` instead of `client.Scenario.On*`.
Sessions are now effect objects (`NotificationEffect`, `StoreOfferEffect`,
…). Complete them with the methods on the effect (`DoneAsync`,
`PurchaseAsync`/`DeclineAsync`, `EndAsync`/`ClaimAsync`, …).
- `client.Update(deltaTime)` is still required every frame — it now pumps
the effects client (30s `GET /sdk/v1/scenarios/pending` heartbeat +
wait-deadline checks), not a local DAG or a websocket.
- After login (or when constructed with a stored access token), the next
`Update` fetches pending effects. There is no `loginEvent` option; trigger
login-gated scenarios yourself.
### Migration
| Before | After |
|---|---|
| `client.Scenario.OnNotification` (and other `On*`) | `client.Effects.OnNotification` (same event names) |
| `NotificationSession` / `StoreOfferSession` / … | `NotificationEffect` / `StoreOfferEffect` / … |
| `session.CompleteAsync()` / `RespondAsync("output")` | `effect.DoneAsync()` (and the matching method on each effect) |
| `RudderClientOptions.PlanStateStore` + `RestoreAsync` | gone — server owns run state; pending fetch after login + heartbeat |
| `client.Realtime` | gone |
| `client.Update(deltaTime)` | still required (effects pump) |
## 0.4.0
- Generated DTOs live under `Models/` (`RudderSdk.Core.Models`). Wire fields
are nullable. `ListQuestsRequest` is gone; `Quests.ListAsync` POSTs with
no body. `BoundaryNode.WaitDeadline` is `DateTimeOffset?`.
- `LeaderboardSession.ClaimAsync` / `Claim` complete the leaderboard node
with handle `onClaim`. The server matches live rank to an authored place.
- `RewardClaimedAsync` / `RewardClaimed` are obsolete aliases and will be
removed in the next SDK version.
- `rank_not_eligible` on Claim leaves the session open (retry Claim or End);
it no longer fails the run.
## 0.3.0
### Removed
-22
View File
@@ -1,22 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Catalog;
public class CatalogItem
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("properties")]
public JToken Properties { get; set; }
[JsonProperty("slug")]
public string Slug { get; set; }
[JsonProperty("tags")]
public List<string> Tags { get; set; }
}
-13
View File
@@ -1,13 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Catalog;
public class ListCatalogItemsResponse
{
[JsonProperty("items")]
public List<CatalogItem> Items { get; set; }
}
-19
View File
@@ -1,19 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models;
public class ErrorResponse
{
[JsonProperty("code")]
public string Code { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
[JsonProperty("requestId")]
public string RequestId { get; set; }
}
-37
View File
@@ -1,37 +0,0 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models;
public class ExecutionPlan
{
[JsonProperty("boundaryNodes")]
public List<BoundaryNode> BoundaryNodes { get; set; }
[JsonProperty("context")]
public JToken Context { get; set; }
[JsonProperty("edges")]
public List<PlanEdge> Edges { get; set; }
[JsonProperty("nodes")]
public List<ExecutionPlanNode> Nodes { get; set; }
[JsonProperty("planId")]
public string PlanId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("startNodeId")]
public string StartNodeId { get; set; }
[JsonProperty("userId")]
public string UserId { get; set; }
}
-19
View File
@@ -1,19 +0,0 @@
// 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; }
}
+1 -2
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Inventory;
@@ -8,6 +7,6 @@ namespace RudderSdk.Core.Models.Inventory;
public class GetInventoryResponse
{
[JsonProperty("items")]
public List<PlayerInventoryItem> Items { get; set; }
public List<PlayerInventoryItem>? Items { get; set; }
}
+6 -6
View File
@@ -1,25 +1,25 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System;
namespace RudderSdk.Core.Models.Inventory;
public class PlayerInventoryItem
{
[JsonProperty("amount")]
public long Amount { get; set; }
public long? Amount { get; set; }
[JsonProperty("nameOverride")]
public string NameOverride { get; set; }
public string? NameOverride { get; set; }
[JsonProperty("propertiesOverride")]
public JToken PropertiesOverride { get; set; }
public JToken? PropertiesOverride { get; set; }
[JsonProperty("slug")]
public string Slug { get; set; }
public string? Slug { get; set; }
[JsonProperty("updatedAt")]
public string UpdatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}
+2 -3
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Leaderboards;
@@ -8,9 +7,9 @@ namespace RudderSdk.Core.Models.Leaderboards;
public class GetRankingResponse
{
[JsonProperty("entries")]
public List<RankEntry> Entries { get; set; }
public List<RankEntry>? Entries { get; set; }
[JsonProperty("total")]
public long Total { get; set; }
public long? Total { get; set; }
}
+4 -6
View File
@@ -1,22 +1,20 @@
// 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; }
public string? PlayerId { get; set; }
[JsonProperty("playerName")]
public string PlayerName { get; set; }
public string? PlayerName { get; set; }
[JsonProperty("rank")]
public long Rank { get; set; }
public long? Rank { get; set; }
[JsonProperty("score")]
public double Score { get; set; }
public double? Score { get; set; }
}
+2 -4
View File
@@ -1,16 +1,14 @@
// 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; }
public double? Score { get; set; }
[JsonProperty("slug")]
public string Slug { get; set; }
public string? Slug { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
namespace RudderSdk.Core.Models;
public class ErrorResponse
{
[JsonProperty("code")]
public string? Code { get; set; }
[JsonProperty("error")]
public string? Error { get; set; }
[JsonProperty("index")]
public int? Index { get; set; }
[JsonProperty("playerId")]
public string? PlayerId { get; set; }
[JsonProperty("requestId")]
public string? RequestId { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
namespace RudderSdk.Core.Models;
public class Reward
{
[JsonProperty("amount")]
public long? Amount { get; set; }
[JsonProperty("currency")]
public string? Currency { get; set; }
[JsonProperty("itemId")]
public string? ItemId { get; set; }
}
@@ -1,6 +1,6 @@
// Code generated by apigen. DO NOT EDIT.
namespace RudderSdk.Core;
namespace RudderSdk.Core.Models;
public static class RudderErrorCodes
{
-25
View File
@@ -1,25 +0,0 @@
// 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; }
}
+10 -8
View File
@@ -1,28 +1,30 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System;
namespace RudderSdk.Core.Models.Player;
public class Player
{
[JsonProperty("avatarUrl")]
public string? AvatarUrl { get; set; }
[JsonProperty("createdAt")]
public string CreatedAt { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
public string? Id { get; set; }
[JsonProperty("language")]
public string Language { get; set; }
public string? Language { get; set; }
[JsonProperty("nickname")]
public string Nickname { get; set; }
public string? Nickname { get; set; }
[JsonProperty("projectId")]
public string ProjectId { get; set; }
public string? ProjectId { get; set; }
[JsonProperty("region")]
public string Region { get; set; }
public string? Region { get; set; }
}
+2 -3
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Player;
@@ -8,9 +7,9 @@ namespace RudderSdk.Core.Models.Player;
public class PlayerProfile
{
[JsonProperty("player")]
public Player Player { get; set; }
public Player? Player { get; set; }
[JsonProperty("wallets")]
public List<Wallet> Wallets { get; set; }
public List<Wallet>? Wallets { get; set; }
}
+2 -4
View File
@@ -1,16 +1,14 @@
// 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; }
public long? Balance { get; set; }
[JsonProperty("currency")]
public string Currency { get; set; }
public string? Currency { get; set; }
}
+2 -3
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.ProjectStorage;
@@ -8,9 +7,9 @@ namespace RudderSdk.Core.Models.ProjectStorage;
public class GetProjectStorageResponse
{
[JsonProperty("items")]
public List<ProjectStorageItem> Items { get; set; }
public List<ProjectStorageItem>? Items { get; set; }
[JsonProperty("nextCursor")]
public string NextCursor { get; set; }
public string? NextCursor { get; set; }
}
+10 -11
View File
@@ -1,37 +1,36 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System;
namespace RudderSdk.Core.Models.ProjectStorage;
public class ProjectStorageItem
{
[JsonProperty("data")]
public string Data { get; set; }
public string? Data { get; set; }
[JsonProperty("expiresAt")]
public string ExpiresAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
public string? Id { get; set; }
[JsonProperty("readPermission")]
public string ReadPermission { get; set; }
public string? ReadPermission { get; set; }
[JsonProperty("size")]
public long Size { get; set; }
public long? Size { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
public string? Type { get; set; }
[JsonProperty("updatedAt")]
public string UpdatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
[JsonProperty("version")]
public long Version { get; set; }
public long? Version { get; set; }
[JsonProperty("writePermission")]
public string WritePermission { get; set; }
public string? WritePermission { get; set; }
}
+2 -4
View File
@@ -1,16 +1,14 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.ProjectStorage;
public class ProjectStorageUpdateItem
{
[JsonProperty("data")]
public string Data { get; set; }
public string? Data { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
public string? Type { get; set; }
}
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.ProjectStorage;
@@ -8,9 +7,9 @@ namespace RudderSdk.Core.Models.ProjectStorage;
public class UpdateProjectStorageRequest
{
[JsonProperty("idempotencyKey")]
public string IdempotencyKey { get; set; }
public string? IdempotencyKey { get; set; }
[JsonProperty("items")]
public List<ProjectStorageUpdateItem> Items { get; set; }
public List<ProjectStorageUpdateItem>? Items { get; set; }
}
+2 -4
View File
@@ -1,13 +1,11 @@
// 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; }
[JsonProperty("questSlug")]
public string? QuestSlug { get; set; }
}
+5 -5
View File
@@ -1,22 +1,22 @@
// 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.Quests;
public class ClaimQuestResponse
{
[JsonProperty("alreadyClaimed")]
public bool AlreadyClaimed { get; set; }
public bool? AlreadyClaimed { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
public string? Error { get; set; }
[JsonProperty("granted")]
public List<QuestReward> Granted { get; set; }
public List<Reward>? Granted { get; set; }
[JsonProperty("success")]
public bool Success { get; set; }
public bool? Success { get; set; }
}
-10
View File
@@ -1,10 +0,0 @@
// 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
{
}
+1 -2
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.Quests;
@@ -8,6 +7,6 @@ namespace RudderSdk.Core.Models.Quests;
public class ListQuestsResponse
{
[JsonProperty("quests")]
public List<Quest> Quests { get; set; }
public List<Quest>? Quests { get; set; }
}
+8 -8
View File
@@ -1,25 +1,25 @@
// 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.Quests;
public class Quest
{
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
public string? Name { get; set; }
[JsonProperty("objectives")]
public List<QuestObjectiveProgress> Objectives { get; set; }
public List<QuestObjectiveProgress>? Objectives { get; set; }
[JsonProperty("rewards")]
public List<QuestReward> Rewards { get; set; }
public List<Reward>? Rewards { get; set; }
[JsonProperty("slug")]
public string? Slug { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
public string? Status { get; set; }
}
+5 -7
View File
@@ -1,25 +1,23 @@
// 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; }
public bool? Completed { get; set; }
[JsonProperty("current")]
public long Current { get; set; }
public long? Current { get; set; }
[JsonProperty("metric")]
public string Metric { get; set; }
public string? Metric { get; set; }
[JsonProperty("objectiveId")]
public string ObjectiveId { get; set; }
public string? ObjectiveId { get; set; }
[JsonProperty("target")]
public long Target { get; set; }
public long? Target { get; set; }
}
-19
View File
@@ -1,19 +0,0 @@
// 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; }
}
+2 -4
View File
@@ -1,16 +1,14 @@
// 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 ReportQuestProgressRequest
{
[JsonProperty("amount")]
public long Amount { get; set; }
public long? Amount { get; set; }
[JsonProperty("metric")]
public string Metric { get; set; }
public string? Metric { get; set; }
}
+2 -3
View File
@@ -1,13 +1,12 @@
// 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 ReportQuestProgressResponse
{
[JsonProperty("completedQuestIds")]
public List<string> CompletedQuestIds { get; set; }
[JsonProperty("completedQuestSlugs")]
public List<string>? CompletedQuestSlugs { get; set; }
}
+40 -9
View File
@@ -2,7 +2,7 @@
.NET client SDK for the Rudder LiveOps platform: authentication, player
profile, stores, battle pass, quests, leaderboards, inventory, remote config,
scenarios, storage and realtime.
scenarios (server-driven effects), and storage.
- Target framework: `netstandard2.1` (works in Unity, .NET, Xamarin).
- JSON: Newtonsoft.Json.
@@ -36,7 +36,7 @@ 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.
// Pump the effects client (30s pending heartbeat + wait deadlines) every frame.
client.Update(deltaTime);
```
@@ -53,14 +53,38 @@ stack.
| `Auth` | `AuthService` | `LoginWithDeviceAsync`, `RefreshAsync`, `Logout`, `AuthStateChanged` |
| `Player` | `PlayerService` | `GetProfileAsync` |
| `BattlePass` | `BattlePassService` | `GetProgressAsync`, `AddXpAsync`, `ClaimRewardAsync`, `PurchasePremiumAsync` |
| `Quests` | `QuestsService` | `ListAsync`, `ClaimAsync` |
| `Quests` | `QuestsService` | `ListAsync`, `ClaimAsync`, `ReportProgressAsync` |
| `Stores` | `StoresService` | `ListAsync`, `GetAsync`, `PurchaseAsync` |
| `Inventory` | `InventoryService` | `GetAsync` |
| `Leaderboards` | `LeaderboardsService` | `FindBySlug(slug)` → handle: `SubmitAsync`, `ListAsync` |
| `RemoteConfig` | `RemoteConfigService` | `LoadAsync`, `Get<T>`, `GetAsync<T>` |
| `Scenario` | `ScenarioService` | `TriggerAsync`, `RestoreAsync`, `On*` effect events |
| `Scenario` | `ScenarioService` | `TriggerAsync` |
| `Effects` | `EffectsService` | `On*` effect events; complete via methods on the effect objects |
| `Storage` | `StorageService` | `GetAsync`, `ListAllAsync`, `SaveAsync`, `DeleteAsync` |
| `Realtime` | `RealtimeService` | `ConnectAsync`, `DisconnectAsync` |
## Quests
`client.Quests` covers the player's global quests — list with per-objective
progress, claim, and metric reports. These are distinct from scenario quest
nodes, which advance through `QuestEffect` (`Effects.OnQuest`).
```csharp
var quests = await client.Quests.ListAsync();
foreach (var quest in quests)
{
if (quest.Status == "completed")
await client.Quests.ClaimAsync(quest.Slug);
}
// Custom metrics advance matching objectives server-side; the call returns
// the ids of quests completed by this report.
var completedIds = await client.Quests.ReportProgressAsync("kills", 1);
```
Purchase metrics are reported automatically by store purchases;
`QuestMetrics.PurchaseOffer(offerSlug)` / `QuestMetrics.PurchaseItem(itemId)`
name the format (`purchase.offer:<offerSlug>`, `purchase.item:<itemId>`) so
quest configs and client code agree on it.
## Sessions
@@ -84,11 +108,18 @@ All of them carry `StatusCode` (`int`), the machine-readable `Code`
## Scenario effects
Subscribe to typed sessions on `client.Scenario`:
The server owns scenario execution. Trigger with `client.Scenario.TriggerAsync`,
subscribe to typed effects on `client.Effects`, and call `client.Update` every
frame:
`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnConfigChanged`,
`OnWait`, `OnQuest`, `OnBattlePass`, `OnBattlePassLevel`,
`OnScenarioCompleted`, `OnScenarioFailed`.
`OnNotification`, `OnStoreOffer`, `OnLeaderboard`, `OnWait`, `OnQuest`,
`OnBattlePass`, `OnBattlePassLevel`, `OnScenarioCompleted`,
`OnScenarioFailed`.
Complete an effect with the methods on the object (`DoneAsync`,
`PurchaseAsync`/`DeclineAsync`, `EndAsync`/`ClaimAsync`, …) or the run
stalls. `unknown_run` / `run_expired` drop the run and fire
`OnScenarioFailed`.
## Tests
+1 -2
View File
@@ -1,6 +1,5 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace RudderSdk.Core.Models.RemoteConfig;
@@ -8,6 +7,6 @@ namespace RudderSdk.Core.Models.RemoteConfig;
public class ListRemoteConfigsResponse
{
[JsonProperty("configs")]
public Dictionary<string, RemoteConfig> Configs { get; set; }
public Dictionary<string, RemoteConfig>? Configs { get; set; }
}
+11 -12
View File
@@ -1,40 +1,39 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System;
namespace RudderSdk.Core.Models.RemoteConfig;
public class RemoteConfig
{
[JsonProperty("active")]
public bool Active { get; set; }
public bool? Active { get; set; }
[JsonProperty("createdAt")]
public string CreatedAt { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
[JsonProperty("description")]
public string Description { get; set; }
public string? Description { get; set; }
[JsonProperty("environment")]
public string Environment { get; set; }
public string? Environment { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
public string? Id { get; set; }
[JsonProperty("key")]
public string Key { get; set; }
public string? Key { get; set; }
[JsonProperty("projectId")]
public string ProjectId { get; set; }
public string? ProjectId { get; set; }
[JsonProperty("updatedAt")]
public string UpdatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
[JsonProperty("value")]
public string Value { get; set; }
public string? Value { get; set; }
[JsonProperty("valueType")]
public string ValueType { get; set; }
public string? ValueType { get; set; }
}
+2 -2
View File
@@ -6,9 +6,9 @@
<AssemblyName>Rudder.Core</AssemblyName>
<RootNamespace>RudderSdk.Core</RootNamespace>
<PackageId>Rudder.Core</PackageId>
<Version>0.3.0</Version>
<Version>2.0.0</Version>
<Authors>Rudder</Authors>
<Description>Rudder LiveOps client SDK for .NET: auth, player, stores, battle pass, quests, leaderboards, inventory, remote config, scenarios, storage and realtime.</Description>
<Description>Rudder LiveOps client SDK for .NET: auth, player, stores, battle pass, quests, leaderboards, inventory, remote config, scenarios and storage.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
+7 -13
View File
@@ -47,11 +47,11 @@ public sealed class RudderClient
/// <summary>Global quests.</summary>
public QuestsService Quests { get; }
/// <summary>Scenario runtime: triggers, node sessions, persistence.</summary>
/// <summary>Scenario trigger. Execution lives on the server.</summary>
public ScenarioService Scenario { get; }
/// <summary>Realtime websocket channel.</summary>
public RealtimeService Realtime { get; }
/// <summary>Pending scenario effects: subscriptions and completion callbacks.</summary>
public EffectsService Effects { get; }
internal RudderClientOptions Options { get; }
internal IRudderTransport Transport => Options.Transport!;
@@ -61,10 +61,7 @@ public sealed class RudderClient
/// <summary>Project key from <see cref="RudderClientOptions.ProjectKey"/>.</summary>
public string ProjectKey => Options.ProjectKey!;
/// <summary>Realtime URL from <see cref="RudderClientOptions.RealtimeUrl"/>.</summary>
public string? RealtimeUrl => Options.RealtimeUrl;
/// <summary>Time source used by the scenario runtime.</summary>
/// <summary>Time source used by the effects client.</summary>
public IClock Clock => Options.Clock ?? SystemClock.Instance;
/// <summary>
@@ -90,17 +87,16 @@ public sealed class RudderClient
Stores = new StoresService(this);
Leaderboards = new LeaderboardsService(this);
Inventory = new InventoryService(this);
Scenario = new ScenarioService(this);
BattlePass = new BattlePassService(this);
Quests = new QuestsService(this);
Realtime = new RealtimeService(this);
Effects = new EffectsService(this);
Scenario = new ScenarioService(this);
}
/// <summary>Pumps time-dependent services; call every frame.</summary>
public void Update(float deltaTime)
{
Scenario.Update(deltaTime);
Realtime.Update(deltaTime);
Effects.Update(deltaTime);
}
internal Task<TResponse> SendAsync<TRequest, TResponse>(
@@ -151,7 +147,6 @@ public sealed class RudderClient
}
catch (RudderAuthException)
{
// Session rejected — refresh once (single-flight) and retry the call once.
if (!await RefreshTokensAsync().ConfigureAwait(false))
throw;
@@ -218,7 +213,6 @@ public sealed class RudderClient
}
catch
{
// Transport-level failure during refresh — session is over.
}
}
+1 -13
View File
@@ -12,9 +12,6 @@ public sealed class RudderClientOptions
/// <summary>API base URL, e.g. https://api.example.com. Required.</summary>
public string? BaseUrl { get; set; }
/// <summary>Realtime websocket URL. Required only for <see cref="RealtimeService"/>.</summary>
public string? RealtimeUrl { get; set; }
/// <summary>Project key issued in the admin panel. Required.</summary>
public string? ProjectKey { get; set; }
@@ -30,15 +27,6 @@ public sealed class RudderClientOptions
/// <summary>Diagnostic sink. Null by default (silent).</summary>
public IRudderLogger? Logger { get; set; }
/// <summary>Time source for the scenario runtime; override in tests.</summary>
/// <summary>Time source for the effects client; override in tests.</summary>
public IClock? Clock { get; set; }
/// <summary>Scenario-run persistence between app launches. Optional.</summary>
public IPlanStateStore? PlanStateStore { get; set; }
/// <summary>Optional scheduler for delayed plan work.</summary>
public IPlanScheduler? Scheduler { get; set; }
/// <summary>Realtime transport factory. Required only for <see cref="RealtimeService"/>.</summary>
public IRealtimeTransportFactory? RealtimeTransportFactory { get; set; }
}
-20
View File
@@ -1,20 +0,0 @@
// 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; }
}
+5 -7
View File
@@ -1,22 +1,20 @@
// 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; }
public string? Handle { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
public string? NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
}
+2 -4
View File
@@ -1,14 +1,12 @@
// 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; }
[JsonProperty("effect")]
public JToken? Effect { get; set; }
}
@@ -1,13 +1,12 @@
// 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
public class ListPendingScenarioEffectsResponse
{
[JsonProperty("runId")]
public string RunId { get; set; }
[JsonProperty("effects")]
public List<PendingEffect> Effects { get; set; } = null!;
}
+28
View File
@@ -0,0 +1,28 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
namespace RudderSdk.Core.Models.Scenarios;
public class PendingEffect
{
[JsonProperty("data")]
public JToken Data { get; set; } = null!;
[JsonProperty("nodeId")]
public string NodeId { get; set; } = null!;
[JsonProperty("runId")]
public string RunId { get; set; } = null!;
[JsonProperty("scenarioSlug")]
public string ScenarioSlug { get; set; } = null!;
[JsonProperty("type")]
public string Type { get; set; } = null!;
[JsonProperty("waitDeadline")]
public DateTimeOffset? WaitDeadline { get; set; }
}
+1 -3
View File
@@ -1,13 +1,11 @@
// 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; }
public string? Event { get; set; }
}
+2 -4
View File
@@ -1,14 +1,12 @@
// Code generated by apigen. DO NOT EDIT.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core.Models.Scenarios;
public class TriggerScenarioResponse
{
[JsonProperty("plans")]
public List<ExecutionPlan> Plans { get; set; }
[JsonProperty("effects")]
public List<PendingEffect> Effects { get; set; } = null!;
}
+6 -8
View File
@@ -1,25 +1,23 @@
// 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; }
public long? Amount { get; set; }
[JsonProperty("counterKey")]
public string CounterKey { get; set; }
public string? CounterKey { get; set; }
[JsonProperty("nodeId")]
public string NodeId { get; set; }
public string? NodeId { get; set; }
[JsonProperty("runId")]
public string RunId { get; set; }
public string? RunId { get; set; }
[JsonProperty("scenarioId")]
public string ScenarioId { get; set; }
[JsonProperty("scenarioSlug")]
public string? ScenarioSlug { get; set; }
}
+3 -5
View File
@@ -1,17 +1,15 @@
// 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; }
public bool? Completed { get; set; }
[JsonProperty("plan")]
public ExecutionPlan Plan { get; set; }
[JsonProperty("effect")]
public JToken? Effect { get; set; }
}
+34 -1
View File
@@ -1,6 +1,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Models.Auth;
namespace RudderSdk.Core;
@@ -41,7 +42,39 @@ public sealed class AuthService
DeviceId = _client.DeviceIdProvider.DeviceId,
Region = region,
Language = language,
Nickname = nickname!
Nickname = nickname
},
cancellationToken).ConfigureAwait(false);
if (response == null || string.IsNullOrEmpty(response.AccessToken) || string.IsNullOrEmpty(response.RefreshToken))
throw new InvalidOperationException("The server returned an incomplete session.");
_client.TokenStore.SaveTokens(response.AccessToken, response.RefreshToken);
NotifySignedIn();
return response;
}
/// <summary>
/// Signs the player in through the project's custom authorization webhook.
/// The returned token pair is stored in the configured token store.
/// </summary>
public async Task<LoginViaCustomResponse> LoginWithCustomAsync(
JToken customData,
string region,
string language,
string? nickname = null,
CancellationToken cancellationToken = default)
{
var response = await _client.SendAsync<LoginViaCustomRequest, LoginViaCustomResponse>(
"POST",
"/sdk/v1/authorization/custom",
new LoginViaCustomRequest
{
Key = _client.ProjectKey,
CustomData = customData,
Region = region,
Language = language,
Nickname = nickname
},
cancellationToken).ConfigureAwait(false);
+3 -3
View File
@@ -8,7 +8,7 @@ namespace RudderSdk.Core;
/// <summary>
/// Battle pass progress and rewards. Battle pass state is tied to a scenario
/// battle pass node, so every call carries the scenario/node ids (and a run id
/// for the mutating calls) — <see cref="BattlePassSession"/> supplies them
/// for the mutating calls) — <see cref="BattlePassEffect"/> supplies them
/// during scenario runs.
/// </summary>
public sealed class BattlePassService
@@ -24,11 +24,11 @@ public sealed class BattlePassService
internal BattlePassService(RudderClient client) => _client = client;
/// <summary>Reads current progress: xp, level, premium ownership, claimed tiers.</summary>
public Task<GetBattlePassProgressResponse> GetProgressAsync(string scenarioId, string nodeId, CancellationToken cancellationToken = default)
public Task<GetBattlePassProgressResponse> GetProgressAsync(string scenarioSlug, string nodeId, CancellationToken cancellationToken = default)
=> _client.SendAsync<GetBattlePassProgressRequest, GetBattlePassProgressResponse>(
"POST",
"/sdk/v1/battlepass/progress",
new GetBattlePassProgressRequest { ScenarioId = scenarioId, NodeId = nodeId },
new GetBattlePassProgressRequest { ScenarioSlug = scenarioSlug, NodeId = nodeId },
cancellationToken);
/// <summary>Credits xp and returns the new xp/level and level-up flags.</summary>
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Models.BattlePass;
namespace RudderSdk.Core;
/// <summary>
/// A scenario battle-pass node. Battle-pass operations are bound to this
/// node's scenario/node/run ids; <see cref="LevelUpAsync"/>, <see cref="EndAsync"/>
/// and a successful <see cref="PurchasePremiumAsync"/> post callbacks.
/// </summary>
public sealed class BattlePassEffect
{
private readonly EffectHandle _handle;
private readonly BattlePassService _battlePass;
internal BattlePassEffect(EffectHandle handle, BattlePassService battlePass)
{
_handle = handle;
_battlePass = battlePass;
}
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Reads current progress (xp, level, premium ownership, claimed tiers) for this node.</summary>
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken cancellationToken = default)
=> _battlePass.GetProgressAsync(_handle.ScenarioSlug, _handle.NodeId, cancellationToken);
/// <summary>Credits xp from a configured source.</summary>
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken cancellationToken = default)
=> _battlePass.AddXpAsync(new AddBattlePassXpRequest
{
ScenarioSlug = _handle.ScenarioSlug,
NodeId = _handle.NodeId,
RunId = _handle.RunId,
Source = source,
Amount = amount
}, cancellationToken);
/// <summary>
/// Claims a tier reward at a reached level.
/// <paramref name="track"/> is <see cref="BattlePassService.TrackFree"/> or <see cref="BattlePassService.TrackPremium"/>.
/// </summary>
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken cancellationToken = default)
=> _battlePass.ClaimRewardAsync(new ClaimBattlePassRewardRequest
{
ScenarioSlug = _handle.ScenarioSlug,
NodeId = _handle.NodeId,
RunId = _handle.RunId,
Level = level,
Track = track
}, cancellationToken);
/// <summary>Purchases the premium track, then posts <c>onPremiumPurchase</c> on success.</summary>
public async Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken cancellationToken = default)
{
var response = await _battlePass.PurchasePremiumAsync(new PurchaseBattlePassPremiumRequest
{
ScenarioSlug = _handle.ScenarioSlug,
NodeId = _handle.NodeId,
RunId = _handle.RunId,
IdempotencyKey = Guid.NewGuid().ToString()
}, cancellationToken).ConfigureAwait(false);
if (response != null && response.Success == true)
await _handle.CompleteAsync("onPremiumPurchase", cancellationToken).ConfigureAwait(false);
return response!;
}
/// <summary>Posts the <c>onLevelUp</c> callback.</summary>
public Task LevelUpAsync(CancellationToken cancellationToken = default) => _handle.CompleteAsync("onLevelUp", cancellationToken);
/// <summary>Posts the <c>onLevelUp</c> callback (fire-and-forget).</summary>
public void LevelUp() => _handle.Complete("onLevelUp");
/// <summary>Posts the <c>onComplete</c> callback.</summary>
public Task EndAsync(CancellationToken cancellationToken = default) => _handle.CompleteAsync("onComplete", cancellationToken);
/// <summary>Posts the <c>onComplete</c> callback (fire-and-forget).</summary>
public void End() => _handle.Complete("onComplete");
}
+41
View File
@@ -0,0 +1,41 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>
/// A scenario battle-pass-level node — a single claimable tier.
/// <see cref="ClaimAsync"/> posts <c>onComplete</c>, which the server accepts
/// only once the player has reached the node's configured level.
/// </summary>
public sealed class BattlePassLevelEffect
{
private readonly EffectHandle _handle;
internal BattlePassLevelEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>The tier level this node claims.</summary>
public int Level => _handle.Get("levelNumber", 0);
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Claims this tier; posts <c>onComplete</c>.</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => _handle.CompleteAsync("onComplete", cancellationToken);
/// <summary>Claims this tier (fire-and-forget).</summary>
public void Claim() => _handle.Complete("onComplete");
}
+61
View File
@@ -0,0 +1,61 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario leaderboard node. End it with <see cref="EndAsync"/> or claim with <see cref="ClaimAsync"/>.</summary>
public sealed class LeaderboardEffect
{
private readonly EffectHandle _handle;
internal LeaderboardEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>True after the effect was resolved once.</summary>
public bool IsResolved { get; private set; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Posts the <c>onEnd</c> callback.</summary>
public Task EndAsync(CancellationToken cancellationToken = default) => ResolveAsync("onEnd", cancellationToken);
/// <summary>Posts the <c>onEnd</c> callback (fire-and-forget).</summary>
public void End() => Resolve("onEnd");
/// <summary>Posts the <c>onClaim</c> callback. The server matches live rank to a place.</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => ResolveAsync("onClaim", cancellationToken);
/// <summary>Posts the <c>onClaim</c> callback (fire-and-forget).</summary>
public void Claim() => Resolve("onClaim");
private async Task ResolveAsync(string handle, CancellationToken cancellationToken)
{
if (IsResolved)
return;
IsResolved = true;
await _handle.CompleteAsync(handle, cancellationToken).ConfigureAwait(false);
}
private void Resolve(string handle)
{
if (IsResolved)
return;
IsResolved = true;
_handle.Complete(handle);
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario notification node. <see cref="DoneAsync"/> posts the <c>output</c> callback.</summary>
public sealed class NotificationEffect
{
private readonly EffectHandle _handle;
internal NotificationEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Notification title.</summary>
public string Title => _handle.Get("title", string.Empty);
/// <summary>Notification message.</summary>
public string Message => _handle.Get("message", string.Empty);
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Posts the <c>output</c> callback.</summary>
public Task DoneAsync(CancellationToken cancellationToken = default) => _handle.CompleteAsync("output", cancellationToken);
/// <summary>Posts the <c>output</c> callback (fire-and-forget).</summary>
public void Done() => _handle.Complete("output");
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>
/// A scenario quest node. Report objective progress; the server auto-completes
/// the node once every objective is satisfied.
/// </summary>
public sealed class QuestEffect
{
private readonly EffectHandle _handle;
internal QuestEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Quest name from the node data.</summary>
public string Name => _handle.Get("name", string.Empty);
/// <summary>Objective definitions from the node data.</summary>
public IReadOnlyList<JObject> Objectives
{
get
{
if (_handle.Data["objectives"] is not JArray array)
return Array.Empty<JObject>();
var list = new List<JObject>(array.Count);
foreach (var item in array)
{
if (item is JObject obj)
list.Add(obj);
}
return list;
}
}
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Reports progress toward an objective via <c>POST /sdk/v1/scenarios/counter</c>.</summary>
public Task ReportProgressAsync(string objectiveId, long amount = 1, CancellationToken cancellationToken = default)
=> _handle.ReportProgressAsync(objectiveId, amount, cancellationToken);
/// <summary>Reports progress toward an objective (fire-and-forget).</summary>
public void ReportProgress(string objectiveId, long amount = 1) => _handle.ReportProgress(objectiveId, amount);
}
@@ -0,0 +1,17 @@
namespace RudderSdk.Core;
/// <summary>Payload of <see cref="EffectsService.OnScenarioCompleted"/>.</summary>
public sealed class ScenarioCompletedEffect
{
internal ScenarioCompletedEffect(string runId, string scenarioSlug)
{
RunId = runId;
ScenarioSlug = scenarioSlug;
}
/// <summary>Server-issued run id.</summary>
public string RunId { get; }
/// <summary>Scenario id.</summary>
public string ScenarioSlug { get; }
}
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace RudderSdk.Core;
/// <summary>Payload of <see cref="EffectsService.OnScenarioFailed"/>.</summary>
public sealed class ScenarioFailedEffect
{
internal ScenarioFailedEffect(string runId, string scenarioSlug, string nodeId, Exception exception)
{
RunId = runId;
ScenarioSlug = scenarioSlug;
NodeId = nodeId;
Exception = exception;
}
/// <summary>Server-issued run id.</summary>
public string RunId { get; }
/// <summary>Scenario id.</summary>
public string ScenarioSlug { get; }
/// <summary>Node the failure happened at.</summary>
public string NodeId { get; }
/// <summary>The error that failed the run.</summary>
public Exception Exception { get; }
}
+74
View File
@@ -0,0 +1,74 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario store-offer node. Resolve it with <see cref="PurchaseAsync"/> or <see cref="DeclineAsync"/>.</summary>
public sealed class StoreOfferEffect
{
private readonly EffectHandle _handle;
internal StoreOfferEffect(EffectHandle handle) => _handle = handle;
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>Store slug from the node data, if present.</summary>
public string StoreSlug => _handle.Get("storeSlug", string.Empty);
/// <summary>Optional message from the node data.</summary>
public string? Message
{
get
{
var value = _handle.Get<string?>("message", null);
return string.IsNullOrEmpty(value) ? null : value;
}
}
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>True after the offer was resolved once.</summary>
public bool IsResolved { get; private set; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
/// <summary>Posts the <c>onPurchase</c> callback.</summary>
public Task PurchaseAsync(CancellationToken cancellationToken = default) => ResolveAsync("onPurchase", cancellationToken);
/// <summary>Posts the <c>onPurchase</c> callback (fire-and-forget).</summary>
public void Purchase() => Resolve("onPurchase");
/// <summary>Posts the <c>onDecline</c> callback.</summary>
public Task DeclineAsync(CancellationToken cancellationToken = default) => ResolveAsync("onDecline", cancellationToken);
/// <summary>Posts the <c>onDecline</c> callback (fire-and-forget).</summary>
public void Decline() => Resolve("onDecline");
private async Task ResolveAsync(string handle, CancellationToken cancellationToken)
{
if (IsResolved)
return;
IsResolved = true;
await _handle.CompleteAsync(handle, cancellationToken).ConfigureAwait(false);
}
private void Resolve(string handle)
{
if (IsResolved)
return;
IsResolved = true;
_handle.Complete(handle);
}
}
+34
View File
@@ -0,0 +1,34 @@
using System;
using Newtonsoft.Json.Linq;
namespace RudderSdk.Core;
/// <summary>A scenario wait node. The run resumes when the server advances it at <see cref="DeadlineUtc"/>.</summary>
public sealed class WaitEffect
{
private readonly EffectHandle _handle;
internal WaitEffect(EffectHandle handle, DateTimeOffset deadlineUtc)
{
_handle = handle;
DeadlineUtc = deadlineUtc;
}
/// <summary>Run id.</summary>
public string RunId => _handle.RunId;
/// <summary>Scenario id.</summary>
public string ScenarioSlug => _handle.ScenarioSlug;
/// <summary>Node id.</summary>
public string NodeId => _handle.NodeId;
/// <summary>When the wait ends (UTC).</summary>
public DateTimeOffset DeadlineUtc { get; }
/// <summary>Node data payload.</summary>
public JObject Data => _handle.Data;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => _handle.Get(key, defaultValue);
}
+457
View File
@@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core;
/// <summary>
/// Thin effects client. The server owns scenario execution; this service
/// surfaces pending effects and posts callbacks. Drive
/// <see cref="RudderClient.Update"/> every frame for the 30s heartbeat and
/// wait-deadline checks.
/// </summary>
public sealed class EffectsService
{
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(30);
private readonly RudderClient _client;
private readonly object _gate = new();
private readonly Dictionary<(string RunId, string NodeId), string> _seen = new();
private readonly Dictionary<(string RunId, string NodeId), DateTimeOffset> _waitDeadlines = new();
private bool _refreshDue;
private bool _inFlight;
private DateTimeOffset _nextHeartbeat;
/// <summary>Raised for a notification effect.</summary>
public event Action<NotificationEffect>? OnNotification;
/// <summary>Raised for a store-offer effect.</summary>
public event Action<StoreOfferEffect>? OnStoreOffer;
/// <summary>Raised for a leaderboard effect.</summary>
public event Action<LeaderboardEffect>? OnLeaderboard;
/// <summary>Raised for a wait effect.</summary>
public event Action<WaitEffect>? OnWait;
/// <summary>Raised for a quest effect.</summary>
public event Action<QuestEffect>? OnQuest;
/// <summary>Raised for a battle-pass effect.</summary>
public event Action<BattlePassEffect>? OnBattlePass;
/// <summary>Raised for a battle-pass-level effect.</summary>
public event Action<BattlePassLevelEffect>? OnBattlePassLevel;
/// <summary>Raised when a run finishes all its nodes.</summary>
public event Action<ScenarioCompletedEffect>? OnScenarioCompleted;
/// <summary>Raised when a run is dropped after a definitive server rejection or an unsupported effect type.</summary>
public event Action<ScenarioFailedEffect>? OnScenarioFailed;
internal EffectsService(RudderClient client)
{
_client = client;
_nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval;
if (!string.IsNullOrEmpty(_client.TokenStore.GetAccessToken()))
_refreshDue = true;
_client.Auth.AuthStateChanged += OnAuthStateChanged;
}
/// <summary>Pumps heartbeat and wait-deadline checks; call every frame.</summary>
public void Update(float deltaTime)
{
if (string.IsNullOrEmpty(_client.TokenStore.GetAccessToken()))
return;
var now = _client.Clock.UtcNow;
lock (_gate)
{
if (_inFlight)
return;
if (!_refreshDue && now < _nextHeartbeat && !HasDueWaitUnlocked(now))
return;
_refreshDue = false;
_inFlight = true;
}
_ = RefreshPendingAsync();
}
internal void Ingest(IEnumerable<PendingEffect>? effects)
{
if (effects == null)
return;
var batch = new List<PendingEffect>();
lock (_gate)
{
foreach (var effect in effects)
{
if (effect == null || string.IsNullOrEmpty(effect.RunId) || string.IsNullOrEmpty(effect.NodeId))
continue;
var key = (effect.RunId, effect.NodeId);
if (_seen.ContainsKey(key))
continue;
_seen[key] = effect.ScenarioSlug;
if (string.Equals(effect.Type, EffectTypes.Wait, StringComparison.Ordinal))
{
var deadline = effect.WaitDeadline ?? _client.Clock.UtcNow;
_waitDeadlines[key] = deadline;
}
batch.Add(effect);
}
}
foreach (var effect in batch)
Dispatch(effect);
}
internal async Task CompleteAsync(PendingEffect source, string handle, CancellationToken cancellationToken = default)
{
try
{
var response = await _client.SendAsync<HandleScenarioCallbackRequest, HandleScenarioCallbackResponse>(
"POST",
"/sdk/v1/scenarios/callback",
new HandleScenarioCallbackRequest
{
ScenarioSlug = source.ScenarioSlug,
NodeId = source.NodeId,
Handle = handle,
RunId = source.RunId
},
cancellationToken).ConfigureAwait(false);
ForgetWait(source.RunId, source.NodeId);
var next = ReadEffect(response?.Effect);
if (next == null)
Emit(OnScenarioCompleted, new ScenarioCompletedEffect(source.RunId, source.ScenarioSlug));
else
Ingest(new[] { next });
}
catch (Exception ex) when (IsDefinitiveRejection(ex))
{
DropRun(source.RunId, source.ScenarioSlug, source.NodeId, ex);
}
}
internal async Task ReportProgressAsync(
PendingEffect source,
string counterKey,
long amount,
CancellationToken cancellationToken = default)
{
try
{
var response = await _client.SendAsync<UpdateScenarioCounterRequest, UpdateScenarioCounterResponse>(
"POST",
"/sdk/v1/scenarios/counter",
new UpdateScenarioCounterRequest
{
ScenarioSlug = source.ScenarioSlug,
NodeId = source.NodeId,
CounterKey = counterKey,
Amount = amount,
RunId = source.RunId
},
cancellationToken).ConfigureAwait(false);
if (response == null || response.Completed != true)
return;
ForgetWait(source.RunId, source.NodeId);
var next = ReadEffect(response.Effect);
if (next == null)
Emit(OnScenarioCompleted, new ScenarioCompletedEffect(source.RunId, source.ScenarioSlug));
else
Ingest(new[] { next });
}
catch (Exception ex) when (IsDefinitiveRejection(ex))
{
DropRun(source.RunId, source.ScenarioSlug, source.NodeId, ex);
}
catch (Exception ex)
{
_client.Options.Logger?.Log(
RudderLogLevel.Warning,
$"[Rudder] Scenario counter update failed at node {source.NodeId}: {ex.Message}");
}
}
private void OnAuthStateChanged(RudderAuthState state)
{
if (state == RudderAuthState.SignedIn)
{
lock (_gate)
_refreshDue = true;
return;
}
lock (_gate)
{
_seen.Clear();
_waitDeadlines.Clear();
_refreshDue = false;
_nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval;
}
}
private async Task RefreshPendingAsync()
{
try
{
var response = await _client.SendAsync<ListPendingScenarioEffectsResponse>(
"GET",
"/sdk/v1/scenarios/pending",
CancellationToken.None).ConfigureAwait(false);
if (response != null)
{
Ingest(response.Effects);
Reconcile(response.Effects);
}
lock (_gate)
_nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval;
}
catch (Exception ex)
{
_client.Options.Logger?.Log(
RudderLogLevel.Warning,
"[Rudder] Failed to refresh pending scenario effects. " + ex.Message);
lock (_gate)
_nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval;
}
finally
{
lock (_gate)
_inFlight = false;
}
}
private void Dispatch(PendingEffect effect)
{
var handle = new EffectHandle(this, effect);
switch (effect.Type ?? string.Empty)
{
case EffectTypes.Notification:
Emit(OnNotification, new NotificationEffect(handle));
break;
case EffectTypes.Store:
Emit(OnStoreOffer, new StoreOfferEffect(handle));
break;
case EffectTypes.Leaderboard:
Emit(OnLeaderboard, new LeaderboardEffect(handle));
break;
case EffectTypes.Wait:
Emit(OnWait, new WaitEffect(handle, effect.WaitDeadline ?? _client.Clock.UtcNow));
break;
case EffectTypes.Quest:
Emit(OnQuest, new QuestEffect(handle));
break;
case EffectTypes.BattlePass:
Emit(OnBattlePass, new BattlePassEffect(handle, _client.BattlePass));
break;
case EffectTypes.BattlePassLevel:
Emit(OnBattlePassLevel, new BattlePassLevelEffect(handle));
break;
default:
_client.Options.Logger?.Log(
RudderLogLevel.Warning,
$"[Rudder] Unsupported scenario node type '{effect.Type}' ({effect.NodeId}).");
Emit(
OnScenarioFailed,
new ScenarioFailedEffect(
effect.RunId,
effect.ScenarioSlug,
effect.NodeId,
new Exception($"Unsupported scenario node type '{effect.Type}'")));
break;
}
}
private void DropRun(string runId, string scenarioSlug, string nodeId, Exception exception)
{
lock (_gate)
{
var toRemove = new List<(string RunId, string NodeId)>();
foreach (var key in _seen.Keys)
{
if (key.RunId == runId)
toRemove.Add(key);
}
foreach (var key in toRemove)
{
_seen.Remove(key);
_waitDeadlines.Remove(key);
}
}
Emit(OnScenarioFailed, new ScenarioFailedEffect(runId, scenarioSlug, nodeId, exception));
}
private void Reconcile(IEnumerable<PendingEffect>? effects)
{
var incoming = new HashSet<(string RunId, string NodeId)>();
if (effects != null)
{
foreach (var effect in effects)
{
if (effect == null || string.IsNullOrEmpty(effect.RunId) || string.IsNullOrEmpty(effect.NodeId))
continue;
incoming.Add((effect.RunId, effect.NodeId));
}
}
var finished = new Dictionary<string, string>();
lock (_gate)
{
var stale = new List<(string RunId, string NodeId)>();
foreach (var key in _seen.Keys)
{
if (!incoming.Contains(key))
stale.Add(key);
}
foreach (var key in stale)
{
finished[key.RunId] = _seen[key];
_seen.Remove(key);
_waitDeadlines.Remove(key);
}
foreach (var key in _seen.Keys)
finished.Remove(key.RunId);
}
foreach (var entry in finished)
Emit(OnScenarioCompleted, new ScenarioCompletedEffect(entry.Key, entry.Value));
}
private void ForgetWait(string runId, string nodeId)
{
lock (_gate)
_waitDeadlines.Remove((runId, nodeId));
}
private bool HasDueWaitUnlocked(DateTimeOffset now)
{
foreach (var deadline in _waitDeadlines.Values)
{
if (now >= deadline)
return true;
}
return false;
}
private void Emit<T>(Action<T>? handlers, T effect)
{
if (handlers == null)
return;
foreach (var subscriber in handlers.GetInvocationList())
{
try
{
((Action<T>)subscriber).Invoke(effect);
}
catch (Exception ex)
{
_client.Options.Logger?.Log(
RudderLogLevel.Error,
"[Rudder] Effect handler failed. " + ex.Message);
}
}
}
private static PendingEffect? ReadEffect(JToken? token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
return token.ToObject<PendingEffect>();
}
private static bool IsDefinitiveRejection(Exception ex)
{
if (ex is RudderNotFoundException)
return true;
return ex is RudderApiException api
&& (api.Code == RudderErrorCodes.UnknownRun
|| api.Code == RudderErrorCodes.RunExpired
|| api.Code == RudderErrorCodes.RunNotActive);
}
}
internal static class EffectTypes
{
public const string Notification = "notification";
public const string Store = "store";
public const string Leaderboard = "leaderboard";
public const string Wait = "wait";
public const string Quest = "quest";
public const string BattlePass = "battlepass";
public const string BattlePassLevel = "battlepass_level";
}
internal sealed class EffectHandle
{
private readonly EffectsService _service;
private readonly PendingEffect _effect;
public EffectHandle(EffectsService service, PendingEffect effect)
{
_service = service;
_effect = effect;
}
public PendingEffect Source => _effect;
public string RunId => _effect.RunId;
public string ScenarioSlug => _effect.ScenarioSlug;
public string NodeId => _effect.NodeId;
public JObject Data => _effect.Data as JObject ?? new JObject();
public T Get<T>(string key, T defaultValue = default!)
{
if (!Data.TryGetValue(key, out var value) || value == null || value.Type == JTokenType.Null)
return defaultValue;
try
{
return value.ToObject<T>() ?? defaultValue;
}
catch
{
return defaultValue;
}
}
public Task CompleteAsync(string handle, CancellationToken cancellationToken = default)
=> _service.CompleteAsync(_effect, handle, cancellationToken);
public void Complete(string handle) => _ = CompleteAsync(handle);
public Task ReportProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
=> _service.ReportProgressAsync(_effect, counterKey, amount, cancellationToken);
public void ReportProgress(string counterKey, long amount) => _ = ReportProgressAsync(counterKey, amount);
}
+16
View File
@@ -0,0 +1,16 @@
namespace RudderSdk.Core;
/// <summary>
/// Builders for the quest metric strings known to the platform. Purchase
/// metrics are reported automatically by the shop purchase fan-out; any other
/// metric is a custom string reported via
/// <see cref="QuestsService.ReportProgressAsync"/>.
/// </summary>
public static class QuestMetrics
{
/// <summary>Metric for purchasing a store offer; auto-reported on purchase.</summary>
public static string PurchaseOffer(string offerSlug) => $"purchase.offer:{offerSlug}";
/// <summary>Metric for purchasing a catalog item; auto-reported on purchase.</summary>
public static string PurchaseItem(string itemId) => $"purchase.item:{itemId}";
}
+5 -6
View File
@@ -7,7 +7,7 @@ namespace RudderSdk.Core;
/// <summary>
/// Global quests (list + claim), distinct from scenario quest nodes which
/// advance through <see cref="QuestSession"/>.
/// advance through <see cref="QuestEffect"/>.
/// </summary>
public sealed class QuestsService
{
@@ -18,21 +18,20 @@ public sealed class QuestsService
/// <summary>Lists the player's quests with per-objective progress and rewards.</summary>
public async Task<IReadOnlyList<Quest>> ListAsync(CancellationToken cancellationToken = default)
{
var response = await _client.SendAsync<ListQuestsRequest, ListQuestsResponse>(
var response = await _client.SendAsync<ListQuestsResponse>(
"POST",
"/sdk/v1/quests/list",
new ListQuestsRequest(),
cancellationToken).ConfigureAwait(false);
return response?.Quests ?? new List<Quest>();
}
/// <summary>Claims a completed quest's rewards (idempotent server-side).</summary>
public Task<ClaimQuestResponse> ClaimAsync(string questId, CancellationToken cancellationToken = default)
public Task<ClaimQuestResponse> ClaimAsync(string questSlug, CancellationToken cancellationToken = default)
=> _client.SendAsync<ClaimQuestRequest, ClaimQuestResponse>(
"POST",
"/sdk/v1/quests/claim",
new ClaimQuestRequest { QuestId = questId },
new ClaimQuestRequest { QuestSlug = questSlug },
cancellationToken);
/// <summary>Reports progress for a metric and returns the ids of quests completed by this report.</summary>
@@ -44,6 +43,6 @@ public sealed class QuestsService
new ReportQuestProgressRequest { Metric = metric, Amount = amount },
cancellationToken).ConfigureAwait(false);
return response?.CompletedQuestIds ?? new List<string>();
return response?.CompletedQuestSlugs ?? new List<string>();
}
}
-105
View File
@@ -1,105 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Core;
/// <summary>
/// Realtime websocket channel. Requires
/// <see cref="RudderClientOptions.RealtimeUrl"/> and
/// <see cref="RudderClientOptions.RealtimeTransportFactory"/>.
/// </summary>
public sealed class RealtimeService
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private readonly RudderClient _client;
private RealtimeSession? _session;
internal RealtimeService(RudderClient client) => _client = client;
/// <summary>The current session, or null when not connected.</summary>
public RealtimeSession? Session => _session;
/// <summary>True while a session is connected.</summary>
public bool IsConnected => _session?.IsConnected == true;
/// <summary>Connects to the configured realtime URL.</summary>
public Task<RealtimeSession> ConnectAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(_client.RealtimeUrl))
throw new InvalidOperationException("RealtimeUrl is not configured.");
return ConnectAsync(new Uri(_client.RealtimeUrl), timeout, cancellationToken);
}
/// <summary>Connects to an explicit realtime URL.</summary>
public async Task<RealtimeSession> ConnectAsync(Uri uri, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var factory = _client.Options.RealtimeTransportFactory
?? throw new InvalidOperationException("RealtimeTransportFactory is not configured.");
var token = _client.TokenStore.GetAccessToken();
if (string.IsNullOrEmpty(token))
throw new InvalidOperationException("LiveOps access token is required for realtime authorization.");
var transport = factory.Create();
await transport.ConnectAsync(uri, timeout ?? DefaultTimeout, cancellationToken).ConfigureAwait(false);
_session = new RealtimeSession(transport, token);
return _session;
}
/// <summary>Closes the current session, if any.</summary>
public Task DisconnectAsync(CancellationToken cancellationToken = default)
=> _session?.DisconnectAsync(cancellationToken) ?? Task.CompletedTask;
/// <summary>Pumps the underlying transport; call every frame.</summary>
public void Update(float deltaTime)
{
_session?.Update(deltaTime);
}
}
/// <summary>An open realtime connection.</summary>
public sealed class RealtimeSession
{
private readonly IRealtimeTransport _transport;
internal RealtimeSession(IRealtimeTransport transport, string accessToken)
{
_transport = transport;
AccessToken = accessToken;
_transport.Closed += () => Closed?.Invoke();
_transport.Error += ex => Error?.Invoke(ex);
_transport.Received += data => MessageReceived?.Invoke(data);
}
/// <summary>Access token the connection was authorized with.</summary>
public string AccessToken { get; }
/// <summary>True while the connection is open.</summary>
public bool IsConnected => _transport.IsConnected;
/// <summary>Raised when the connection closes.</summary>
public event Action? Closed;
/// <summary>Raised on transport errors.</summary>
public event Action<Exception>? Error;
/// <summary>Raised for every incoming message.</summary>
public event Action<ArraySegment<byte>>? MessageReceived;
/// <summary>Sends one message.</summary>
public Task SendAsync(byte[] payload, CancellationToken cancellationToken = default)
=> _transport.SendAsync(new ArraySegment<byte>(payload ?? Array.Empty<byte>()), cancellationToken);
/// <summary>Closes the connection.</summary>
public Task DisconnectAsync(CancellationToken cancellationToken = default)
=> _transport.CloseAsync(cancellationToken);
internal void Update(float deltaTime)
{
_transport.Update(deltaTime);
}
}
+7 -218
View File
@@ -1,71 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core;
/// <summary>
/// Scenario runtime. <see cref="TriggerAsync"/> starts server-issued plans;
/// active nodes surface as typed sessions through the On* events and are
/// advanced by completing those sessions.
/// Scenario trigger. Execution lives on the server; resulting effects surface
/// through <see cref="RudderClient.Effects"/>.
/// </summary>
public sealed partial class ScenarioService
public sealed class ScenarioService
{
private readonly RudderClient _client;
private readonly Dictionary<string, RuntimeRun> _runs = new();
/// <summary>Raised for a notification node.</summary>
public event Action<NotificationSession>? OnNotification;
/// <summary>Raised for a store-offer node.</summary>
public event Action<StoreOfferSession>? OnStoreOffer;
/// <summary>Raised for a leaderboard node.</summary>
public event Action<LeaderboardSession>? OnLeaderboard;
/// <summary>Raised for a remote-config-override node, after the patches were applied.</summary>
public event Action<ConfigChangedSession>? OnConfigChanged;
/// <summary>Raised for a wait node.</summary>
public event Action<WaitSession>? OnWait;
/// <summary>Raised for a quest node.</summary>
public event Action<QuestSession>? OnQuest;
/// <summary>Raised for a battle-pass node.</summary>
public event Action<BattlePassSession>? OnBattlePass;
/// <summary>Raised for a battle-pass-level node.</summary>
public event Action<BattlePassLevelSession>? OnBattlePassLevel;
/// <summary>Raised when a run finishes all its nodes.</summary>
public event Action<PlanRun>? OnScenarioCompleted;
/// <summary>Raised when a run dies on an unrecoverable error.</summary>
public event Action<ScenarioFailedEvent>? OnScenarioFailed;
internal ScenarioService(RudderClient client) => _client = client;
/// <summary>True while at least one run is active.</summary>
public bool IsRunning => _runs.Count > 0;
/// <summary>First active node id across the runs, or null.</summary>
public string? CurrentNodeId => _runs.Values.FirstOrDefault()?.ActiveNodes.Keys.FirstOrDefault();
/// <summary>Snapshots of the active runs.</summary>
public IReadOnlyList<PlanRun> ActiveRuns => _runs.Values.Select(ToPlanRun).ToList();
/// <summary>
/// Triggers scenarios by event name and starts the plans the server
/// returns. Returns the runs this call started.
/// Triggers scenarios by event name. Returned pending effects are ingested
/// into <see cref="RudderClient.Effects"/>.
/// </summary>
public async Task<IReadOnlyList<PlanRun>> TriggerAsync(string eventName, CancellationToken cancellationToken = default)
public async Task TriggerAsync(string eventName, CancellationToken cancellationToken = default)
{
var response = await _client.SendAsync<TriggerScenarioRequest, TriggerScenarioResponse>(
"POST",
@@ -73,170 +26,6 @@ public sealed partial class ScenarioService
new TriggerScenarioRequest { Event = eventName },
cancellationToken).ConfigureAwait(false);
return StartPlans(response?.Plans);
}
/// <summary>
/// Restores persisted runs, reconciles them with the server and re-dispatches
/// active nodes. Call once after startup, after login.
/// </summary>
public Task RestoreAsync(CancellationToken cancellationToken = default)
{
return RestoreCoreAsync(cancellationToken);
}
private async Task RestoreCoreAsync(CancellationToken cancellationToken = default)
{
var store = _client.Options.PlanStateStore;
if (store == null || string.IsNullOrEmpty(store.State))
return;
try
{
var state = JsonConvert.DeserializeObject<PersistedScenarioState>(store.State);
_runs.Clear();
if (state?.Runs != null)
{
foreach (var savedRun in state.Runs)
{
if (savedRun?.Plan == null)
continue;
// Reconcile with server
try
{
var response = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
"POST",
"/sdk/v1/scenarios/run",
new GetScenarioRunRequest { RunId = savedRun.RunId },
cancellationToken);
if (response?.Status == "unknown_run" || response?.Status == "expired")
continue;
if (response?.Plan != null)
{
savedRun.Plan = response.Plan;
savedRun.ActiveNodes = null;
savedRun.CompletedHandles = null;
}
}
catch
{
// Network error — keep local state as fallback
}
var run = RuntimeRun.FromPersisted(savedRun);
_runs[run.RunId] = run;
}
}
foreach (var run in _runs.Values.ToList())
{
if (run.ActiveNodes.Count > 0)
{
foreach (var nodeState in run.ActiveNodes.Values.ToList())
DispatchActiveNode(run, nodeState, restored: true);
}
else
{
// Rebuilt from server — activate start node
var startNode = FindNode(run.Plan, run.Plan.StartNodeId) ?? run.Plan.Nodes[0];
ActivateNode(run, startNode.Id);
}
}
Persist();
}
catch (Exception ex)
{
_client.Options.Logger?.Log(RudderLogLevel.Error, "[Rudder] Failed to restore scenario state. Clearing persisted state. " + ex.Message);
Clear();
}
}
/// <summary>Drops all runs and the persisted state.</summary>
public void Clear()
{
_runs.Clear();
Persist();
}
/// <summary>Completes wait nodes whose deadline passed; call every frame.</summary>
public void Update(float deltaTime)
{
var now = _client.Clock.UtcNow;
foreach (var run in _runs.Values.ToList())
{
foreach (var node in run.ActiveNodes.Values.ToList())
{
if (node.WaitDeadlineUtc.HasValue && now >= node.WaitDeadlineUtc.Value)
_ = CompleteNodeAsync(run.RunId, node.NodeId, "onComplete");
}
}
}
/// <summary>Completes the first active node with the given handle.</summary>
public Task RespondAsync(string handle, CancellationToken cancellationToken = default)
{
var run = _runs.Values.FirstOrDefault();
var node = run?.ActiveNodes.Values.FirstOrDefault();
return run == null || node == null
? Task.CompletedTask
: CompleteNodeAsync(run.RunId, node.NodeId, handle, cancellationToken);
}
/// <summary>Completes the first active node with the given handle (fire-and-forget).</summary>
public void Respond(string handle)
{
_ = RespondAsync(handle);
}
/// <summary>Adds progress to a counter of the first active node.</summary>
public Task UpdateProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
{
var run = _runs.Values.FirstOrDefault();
var node = run?.ActiveNodes.Values.FirstOrDefault();
return run == null || node == null
? Task.CompletedTask
: UpdateProgressAsync(run.RunId, node.NodeId, counterKey, amount, cancellationToken);
}
internal async Task UpdateProgressAsync(
string runId,
string nodeId,
string counterKey,
long amount,
CancellationToken cancellationToken = default)
{
if (!_runs.TryGetValue(runId, out var run) || !run.ActiveNodes.ContainsKey(nodeId))
return;
try
{
var response = await _client.SendAsync<UpdateScenarioCounterRequest, UpdateScenarioCounterResponse>(
"POST",
"/sdk/v1/scenarios/counter",
new UpdateScenarioCounterRequest
{
ScenarioId = run.Plan.ScenarioId,
NodeId = nodeId,
CounterKey = counterKey,
Amount = amount,
RunId = run.RunId
},
cancellationToken).ConfigureAwait(false);
// The server reports objective completion; it no longer returns a plan from the
// counter endpoint. On completion, cross the node's onComplete handle (which
// advances the run) — idempotent if the consumer also completes the session.
if (response != null && response.Completed)
await CompleteNodeAsync(runId, nodeId, "onComplete", cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Counter update failure does not fail the run.
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Scenario counter update failed at node {nodeId}: {ex.Message}");
}
_client.Effects.Ingest(response?.Effects);
}
}
-42
View File
@@ -1,42 +0,0 @@
using System.Collections.Generic;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
/// <summary>Snapshot of one running scenario plan.</summary>
public sealed class PlanRun
{
internal PlanRun(
string runId,
string planId,
string scenarioId,
string userId,
IReadOnlyList<string> activeNodeIds,
ExecutionPlan plan)
{
RunId = runId;
PlanId = planId;
ScenarioId = scenarioId;
UserId = userId;
ActiveNodeIds = activeNodeIds;
Plan = plan;
}
/// <summary>Server-issued run id.</summary>
public string RunId { get; }
/// <summary>Plan id.</summary>
public string PlanId { get; }
/// <summary>Scenario id.</summary>
public string ScenarioId { get; }
/// <summary>Player the run belongs to.</summary>
public string UserId { get; }
/// <summary>Ids of the currently active nodes.</summary>
public IReadOnlyList<string> ActiveNodeIds { get; }
/// <summary>The execution plan being run.</summary>
public ExecutionPlan Plan { get; }
}
@@ -1,23 +0,0 @@
using System;
namespace RudderSdk.Core;
/// <summary>Payload of <see cref="ScenarioService.OnScenarioFailed"/>.</summary>
public sealed class ScenarioFailedEvent
{
internal ScenarioFailedEvent(PlanRun run, string nodeId, Exception exception)
{
Run = run;
NodeId = nodeId;
Exception = exception;
}
/// <summary>The failed run.</summary>
public PlanRun Run { get; }
/// <summary>Node the failure happened at.</summary>
public string NodeId { get; }
/// <summary>The error that failed the run.</summary>
public Exception Exception { get; }
}
@@ -1,78 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
/// <summary>Context of the scenario node a session was created for.</summary>
public class ScenarioNodeContext
{
private readonly ScenarioService _service;
internal ScenarioNodeContext(ScenarioService service, PlanRun run, ExecutionPlanNode node)
{
_service = service;
Run = run;
Node = node;
Data = node?.Data as JObject ?? new JObject();
}
/// <summary>The run this node belongs to.</summary>
public PlanRun Run { get; }
/// <summary>The plan node.</summary>
public ExecutionPlanNode Node { get; }
/// <summary>Run id.</summary>
public string RunId => Run.RunId;
/// <summary>Plan id.</summary>
public string PlanId => Run.PlanId;
/// <summary>Scenario id.</summary>
public string ScenarioId => Run.ScenarioId;
/// <summary>Node id.</summary>
public string NodeId => Node.Id;
/// <summary>Node type.</summary>
public string Type => Node.Type;
/// <summary>Node data payload.</summary>
public JObject Data { get; }
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!)
{
if (Data == null || !Data.TryGetValue(key, out var value))
return defaultValue;
try { return value.ToObject<T>() ?? defaultValue; }
catch { return defaultValue; }
}
/// <summary>Deserializes the whole node data payload.</summary>
public T Get<T>()
{
try { return Data == null ? default! : Data.ToObject<T>() ?? default!; }
catch { return default!; }
}
/// <summary>Returns the node data as a plain dictionary.</summary>
public Dictionary<string, object> AsObjectDictionary()
{
return Data?.ToObject<Dictionary<string, object>>() ?? new Dictionary<string, object>();
}
internal Task CompleteAsync(string handle, CancellationToken cancellationToken = default)
=> _service.CompleteNodeAsync(RunId, NodeId, handle, cancellationToken);
internal void Complete(string handle)
{
_ = CompleteAsync(handle);
}
internal Task AddProgressAsync(string counterKey, long amount, CancellationToken cancellationToken = default)
=> _service.UpdateProgressAsync(RunId, NodeId, counterKey, amount, cancellationToken);
}
@@ -1,193 +0,0 @@
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:
// Unsupported node type — fail the run (surfaced via OnScenarioFailed)
// instead of leaving it stalled on a node no handler will complete.
_client.Options.Logger?.Log(RudderLogLevel.Warning, $"[Rudder] Unsupported scenario node type '{node.Type}' ({node.Id}).");
FailRun(run, state.NodeId, new Exception($"Unsupported scenario node type '{node.Type}'"));
break;
}
}
private void DispatchWait(RuntimeRun run, ActiveNodeState state, ScenarioNodeContext context)
{
if (!state.WaitDeadlineUtc.HasValue)
{
// Prefer server-provided WaitDeadline from the plan boundary over local calculation.
// The server stamps WaitDeadline on server-enforced wait boundaries (see StampBoundaries).
var boundary = run.Plan.BoundaryNodes?.FirstOrDefault(
b => b.SourceNodeId == state.NodeId && !string.IsNullOrEmpty(b.WaitDeadline)
);
if (boundary != null && DateTimeOffset.TryParse(boundary.WaitDeadline, null, DateTimeStyles.RoundtripKind, out var parsed))
{
state.WaitDeadlineUtc = parsed;
}
else
{
var delay = GetWaitDelay(context.Data);
state.WaitDeadlineUtc = _client.Clock.UtcNow.Add(delay);
}
Persist();
}
var session = new WaitSession(context, state.WaitDeadlineUtc.Value);
OnWait?.Invoke(session);
if (_client.Clock.UtcNow >= state.WaitDeadlineUtc.Value)
_ = CompleteNodeAsync(run.RunId, state.NodeId, "onComplete");
}
private void DispatchRemoteConfigOverride(RuntimeRun run, ActiveNodeState state, ScenarioNodeContext context)
{
var patches = context.Data["patches"] as JArray;
if (patches != null)
{
foreach (var patchToken in patches.OfType<JObject>())
{
var key = patchToken.Value<string>("path");
if (string.IsNullOrEmpty(key))
continue;
var valueType = patchToken.Value<string>("valueType") ?? "json";
var value = SerializeRemoteConfigValue(patchToken["value"], valueType);
_client.RemoteConfig.ApplyOverride(key, value, valueType);
}
}
var session = new ConfigChangedSession(context);
OnConfigChanged?.Invoke(session);
_ = CompleteNodeAsync(run.RunId, state.NodeId, "output");
}
private void EmitNotification(ScenarioNodeContext context)
{
OnNotification?.Invoke(new NotificationSession(context));
}
private void EmitStoreOffer(ScenarioNodeContext context)
{
OnStoreOffer?.Invoke(new StoreOfferSession(context));
}
private void EmitQuest(ScenarioNodeContext context)
{
OnQuest?.Invoke(new QuestSession(context));
}
private void EmitLeaderboard(ScenarioNodeContext context)
{
OnLeaderboard?.Invoke(new LeaderboardSession(context));
}
private void EmitBattlePass(ScenarioNodeContext context)
{
OnBattlePass?.Invoke(new BattlePassSession(context, _client.BattlePass));
}
private void EmitBattlePassLevel(ScenarioNodeContext context)
{
OnBattlePassLevel?.Invoke(new BattlePassLevelSession(context));
}
private static TimeSpan GetWaitDelay(JObject data)
{
var duration = data.Value<double?>("duration") ?? 0;
var unit = data.Value<string>("unit") ?? "seconds";
if (duration <= 0)
return TimeSpan.Zero;
switch (unit)
{
case "days":
case "day":
case "d":
return TimeSpan.FromDays(duration);
case "hours":
case "hour":
case "hr":
case "h":
return TimeSpan.FromHours(duration);
case "minutes":
case "minute":
case "min":
case "m":
return TimeSpan.FromMinutes(duration);
case "seconds":
case "second":
case "sec":
case "s":
return TimeSpan.FromSeconds(duration);
default:
return TimeSpan.FromSeconds(duration);
}
}
private static string? SerializeRemoteConfigValue(JToken? token, string valueType)
{
if (token == null || token.Type == JTokenType.Null)
return null;
switch ((valueType ?? string.Empty).ToLowerInvariant())
{
case "string":
return token.Type == JTokenType.String ? token.Value<string>() : token.ToString(Formatting.None);
case "bool":
case "boolean":
return token.Value<bool>().ToString().ToLowerInvariant();
case "int":
case "integer":
return token.Value<long>().ToString(CultureInfo.InvariantCulture);
case "float":
case "double":
return token.Value<double>().ToString(CultureInfo.InvariantCulture);
default:
return token.Type == JTokenType.String ? token.Value<string>() : token.ToString(Formatting.None);
}
}
}
@@ -1,13 +0,0 @@
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";
}
@@ -1,92 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using RudderSdk.Core.Models;
namespace RudderSdk.Core;
public sealed partial class ScenarioService
{
private void Persist()
{
var store = _client.Options.PlanStateStore;
if (store == null)
return;
if (_runs.Count == 0)
{
store.State = null;
return;
}
var state = new PersistedScenarioState
{
Runs = _runs.Values.Select(run => run.ToPersisted()).ToList()
};
store.State = JsonConvert.SerializeObject(state);
}
private sealed class RuntimeRun
{
public RuntimeRun(string runId, ExecutionPlan plan)
{
RunId = runId;
Plan = plan;
}
public string RunId { get; }
public ExecutionPlan Plan { get; }
public Dictionary<string, ActiveNodeState> ActiveNodes { get; } = new();
public HashSet<string> CompletedHandles { get; } = new(StringComparer.Ordinal);
public PersistedRun ToPersisted()
{
return new PersistedRun
{
RunId = RunId,
Plan = Plan,
ActiveNodes = ActiveNodes.Values.ToList(),
CompletedHandles = CompletedHandles.ToList()
};
}
public static RuntimeRun FromPersisted(PersistedRun saved)
{
var run = new RuntimeRun(saved.RunId, saved.Plan);
if (saved.ActiveNodes != null)
{
foreach (var node in saved.ActiveNodes)
{
if (!string.IsNullOrEmpty(node?.NodeId))
run.ActiveNodes[node.NodeId] = node;
}
}
if (saved.CompletedHandles != null)
{
foreach (var handle in saved.CompletedHandles)
run.CompletedHandles.Add(handle);
}
return run;
}
}
private sealed class PersistedScenarioState
{
public List<PersistedRun>? Runs { get; set; }
}
private sealed class PersistedRun
{
public string RunId { get; set; } = string.Empty;
public ExecutionPlan Plan { get; set; } = null!;
public List<ActiveNodeState>? ActiveNodes { get; set; }
public List<string>? CompletedHandles { get; set; }
}
private sealed class ActiveNodeState
{
public string NodeId { get; set; } = string.Empty;
public DateTimeOffset? WaitDeadlineUtc { get; set; }
}
}
@@ -1,341 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Scenarios;
namespace RudderSdk.Core;
/// <summary>
/// Thrown when a boundary HTTP call fails with a transient error
/// (network failure, timeout, or server 5xx). The caller should NOT advance
/// the run; the node stays active and the handle stays pending for retry.
/// </summary>
public sealed class TransientBoundaryException : Exception
{
/// <summary>Creates the exception.</summary>
public TransientBoundaryException(string message, Exception inner)
: base(message, inner)
{
}
/// <summary>Creates the exception wrapping the transport error.</summary>
public TransientBoundaryException(Exception inner)
: base($"Transient boundary error: {inner.Message}", inner)
{
}
}
public sealed partial class ScenarioService
{
/// <summary>
/// Returns true for exceptions that may succeed on retry
/// (timeout / network failures).
/// </summary>
private static bool IsTransientException(Exception ex)
{
return ex is OperationCanceledException || ex is RudderNetworkException;
}
private IReadOnlyList<PlanRun> StartPlans(IEnumerable<ExecutionPlan>? plans)
{
var started = new List<PlanRun>();
if (plans == null)
return started;
foreach (var plan in plans)
{
var run = StartPlan(plan);
if (run != null)
started.Add(ToPlanRun(run));
}
return started;
}
private RuntimeRun? StartPlan(ExecutionPlan? plan)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return null;
// Dedup: server returned same runId — skip without restarting.
if (!string.IsNullOrEmpty(plan.RunId) && _runs.ContainsKey(plan.RunId))
return null;
if (plan.BoundaryNodes?.Count > 0 && string.IsNullOrEmpty(plan.RunId))
throw new InvalidOperationException("ExecutionPlan has boundaryNodes but missing RunId.");
var runId = plan.RunId ?? Guid.NewGuid().ToString("N");
var startNode = FindNode(plan, plan.StartNodeId) ?? plan.Nodes[0];
var run = new RuntimeRun(runId, plan);
_runs[run.RunId] = run;
ActivateNode(run, startNode.Id);
Persist();
return run;
}
/// <summary>
/// Replaces an existing run's plan with a server-provided continuation (same RunId):
/// the previous segment is done, the new segment's start node becomes active.
/// Idempotent: if the continuation was already applied, nothing is re-dispatched.
/// </summary>
private void ReplaceRun(ExecutionPlan? plan, string? fallbackRunId = null)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return;
var runId = plan.RunId ?? fallbackRunId;
if (string.IsNullOrEmpty(runId))
return;
var startNode = FindNode(plan, plan.StartNodeId) ?? plan.Nodes[0];
if (startNode == null)
return;
if (_runs.TryGetValue(runId, out var existing) && existing.ActiveNodes.ContainsKey(startNode.Id))
return; // continuation already applied (idempotent callback echo)
var run = new RuntimeRun(runId, plan);
_runs[runId] = run;
ActivateNode(run, startNode.Id);
Persist();
}
private void ActivateNode(RuntimeRun run, string nodeId, ActiveNodeState? restoredState = null)
{
var node = FindNode(run.Plan, nodeId);
if (node == null)
return;
var state = restoredState ?? new ActiveNodeState { NodeId = nodeId };
run.ActiveNodes[nodeId] = state;
DispatchActiveNode(run, state, restored: restoredState != null);
}
internal Task CompleteNodeAsync(
string runId,
string nodeId,
string handle,
CancellationToken cancellationToken = default)
{
return CompleteNodeAsync(runId, nodeId, handle, continueOnBoundary: true, cancellationToken);
}
private async Task CompleteNodeAsync(
string runId,
string nodeId,
string handle,
bool continueOnBoundary,
CancellationToken cancellationToken = default)
{
if (!_runs.TryGetValue(runId, out var run))
return;
var key = CompletedHandleKey(nodeId, handle);
if (run.CompletedHandles.Contains(key))
return; // idempotent
try
{
var continuedOnBoundary = false;
if (continueOnBoundary)
{
try
{
continuedOnBoundary = await ContinueBoundaryAsync(
run, nodeId, handle, cancellationToken).ConfigureAwait(false);
}
catch (TransientBoundaryException)
{
// Transient error — don't advance the run.
// Node stays active, handle stays pending for retry on reconnect.
return;
}
}
if (continuedOnBoundary)
{
// The boundary may have replaced or removed the run (continuation plan
// or reconcile). The transferred run owns the state — touching the stale
// object here would complete or delete the new run.
if (!_runs.TryGetValue(runId, out var currentRun) || !ReferenceEquals(currentRun, run))
return;
}
// Only now — after the server has confirmed — mark the handle and node.
run.CompletedHandles.Add(key);
run.ActiveNodes.Remove(nodeId);
Persist();
if (!continuedOnBoundary)
{
foreach (var edge in MatchingEdges(run.Plan, nodeId, handle))
ActivateNode(run, edge.Target);
}
CheckRunCompleted(run);
Persist();
}
catch (Exception ex)
{
FailRun(run, nodeId, ex);
}
}
private async Task<bool> ContinueBoundaryAsync(
RuntimeRun run,
string nodeId,
string handle,
CancellationToken cancellationToken)
{
var boundaries = MatchingBoundaryNodes(run.Plan, nodeId, handle).ToList();
if (boundaries.Count == 0)
return false;
foreach (var boundary in boundaries)
{
try
{
var response = await _client.SendAsync<HandleScenarioCallbackRequest, HandleScenarioCallbackResponse>(
"POST",
"/sdk/v1/scenarios/callback",
new HandleScenarioCallbackRequest
{
ScenarioId = run.Plan.ScenarioId,
NodeId = boundary.SourceNodeId,
Handle = boundary.SourceHandle,
RunId = run.RunId
},
cancellationToken).ConfigureAwait(false);
if (response?.Plan != null)
{
// Continuation of the current run (server keeps the RunId) replaces
// the run's plan; fresh/terminal plans start as new runs.
if (_runs.ContainsKey(response.Plan.RunId ?? string.Empty))
ReplaceRun(response.Plan);
else
StartPlan(response.Plan);
}
}
catch (Exception ex)
{
// Boundary call failed — try to reconcile with server.
var reconciled = false;
try
{
var reconcile = await _client.SendAsync<GetScenarioRunRequest, GetScenarioRunResponse>(
"POST",
"/sdk/v1/scenarios/run",
new GetScenarioRunRequest { RunId = run.RunId },
cancellationToken).ConfigureAwait(false);
if (reconcile?.Status == "unknown_run" || reconcile?.Status == "expired")
{
_runs.Remove(run.RunId);
Persist();
reconciled = true;
}
if (reconcile?.Plan != null)
{
ReplaceRun(reconcile.Plan, run.RunId);
reconciled = true;
}
}
catch
{
// Reconciliation also failed.
}
if (!reconciled)
{
// Reconcile did not resolve — distinguish transient from terminal.
if (IsTransientException(ex))
throw new TransientBoundaryException(ex);
// Terminal error — let the caller fail the run.
throw;
}
// If reconciled, the boundary was handled (run corrected or removed).
// Fall through to continue to the next boundary.
}
}
return true;
}
private void CheckRunCompleted(RuntimeRun run)
{
if (run.ActiveNodes.Count > 0)
return;
_runs.Remove(run.RunId);
Persist();
OnScenarioCompleted?.Invoke(ToPlanRun(run));
}
private void FailRun(RuntimeRun run, string nodeId, Exception ex)
{
_client.Options.Logger?.Log(RudderLogLevel.Error, $"[Rudder] Scenario run {run.RunId} failed at node {nodeId}: {ex.Message}");
_runs.Remove(run.RunId);
Persist();
OnScenarioFailed?.Invoke(new ScenarioFailedEvent(ToPlanRun(run), nodeId, ex));
}
private static PlanRun ToPlanRun(RuntimeRun run)
{
return new PlanRun(
run.RunId,
run.Plan.PlanId,
run.Plan.ScenarioId,
run.Plan.UserId,
run.ActiveNodes.Keys.ToList(),
run.Plan);
}
private static ExecutionPlanNode? FindNode(ExecutionPlan? plan, string? nodeId)
{
if (plan?.Nodes == null || plan.Nodes.Count == 0)
return null;
if (!string.IsNullOrEmpty(nodeId))
{
foreach (var node in plan.Nodes)
{
if (node.Id == nodeId)
return node;
}
}
return null;
}
private static IEnumerable<PlanEdge> MatchingEdges(ExecutionPlan plan, string sourceNodeId, string sourceHandle)
{
if (plan?.Edges == null)
yield break;
foreach (var edge in plan.Edges)
{
if (edge.Source == sourceNodeId && string.Equals(edge.SourceHandle ?? string.Empty, sourceHandle ?? string.Empty, StringComparison.Ordinal))
yield return edge;
}
}
private static IEnumerable<BoundaryNode> MatchingBoundaryNodes(ExecutionPlan plan, string sourceNodeId, string sourceHandle)
{
if (plan?.BoundaryNodes == null)
yield break;
foreach (var boundary in plan.BoundaryNodes)
{
if (boundary.SourceNodeId == sourceNodeId && string.Equals(boundary.SourceHandle ?? string.Empty, sourceHandle ?? string.Empty, StringComparison.Ordinal))
yield return boundary;
}
}
private static string CompletedHandleKey(string nodeId, string? handle) => nodeId + ":" + (handle ?? string.Empty);
}
@@ -1,32 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Core;
/// <summary>
/// Session of a scenario battle-pass-level node — a single claimable tier.
/// <see cref="ClaimAsync"/> crosses onComplete, which the server accepts only
/// once the player has reached the node's configured level.
/// </summary>
public sealed class BattlePassLevelSession
{
internal BattlePassLevelSession(ScenarioNodeContext context) => Context = context;
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>The tier level this node claims.</summary>
public int Level => Context.Get("levelNumber", 0);
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Claims this tier; crosses onComplete (the server checks the level was reached).</summary>
public Task ClaimAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
/// <summary>Claims this tier (fire-and-forget).</summary>
public void Claim() => Context.Complete("onComplete");
}
@@ -1,95 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Models.BattlePass;
namespace RudderSdk.Core;
/// <summary>
/// Session of a scenario battle-pass node. Exposes the battle pass operations
/// bound to this node's scenario/node/run ids, plus explicit boundary crossings
/// the game drives from its UI; the server validates each crossing.
/// </summary>
public sealed class BattlePassSession
{
private readonly BattlePassService _battlePass;
internal BattlePassSession(ScenarioNodeContext context, BattlePassService battlePass)
{
Context = context;
_battlePass = battlePass;
}
/// <summary>Underlying node context.</summary>
public ScenarioNodeContext Context { get; }
/// <summary>Node id.</summary>
public string Id => Context.NodeId;
/// <summary>Reads a typed value from the node data.</summary>
public T Get<T>(string key, T defaultValue = default!) => Context.Get(key, defaultValue);
/// <summary>Reads current progress (xp, level, premium ownership, claimed tiers) for this node.</summary>
public Task<GetBattlePassProgressResponse> GetProgressAsync(CancellationToken cancellationToken = default)
=> _battlePass.GetProgressAsync(Context.ScenarioId, Context.NodeId, cancellationToken);
/// <summary>Credits xp from a configured source.</summary>
public Task<AddBattlePassXpResponse> AddXpAsync(string source, long amount, CancellationToken cancellationToken = default)
=> _battlePass.AddXpAsync(new AddBattlePassXpRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
Source = source,
Amount = amount
}, cancellationToken);
/// <summary>
/// Claims a tier reward at a reached level.
/// <paramref name="track"/> is <see cref="BattlePassService.TrackFree"/> or <see cref="BattlePassService.TrackPremium"/>.
/// </summary>
public Task<ClaimBattlePassRewardResponse> ClaimRewardAsync(int level, string track, CancellationToken cancellationToken = default)
=> _battlePass.ClaimRewardAsync(new ClaimBattlePassRewardRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
Level = level,
Track = track
}, cancellationToken);
/// <summary>Purchases the premium track, then crosses onPremiumPurchase on success.</summary>
public async Task<PurchaseBattlePassPremiumResponse> PurchasePremiumAsync(CancellationToken cancellationToken = default)
{
var response = await _battlePass.PurchasePremiumAsync(new PurchaseBattlePassPremiumRequest
{
ScenarioId = Context.ScenarioId,
NodeId = Context.NodeId,
RunId = Context.RunId,
IdempotencyKey = Guid.NewGuid().ToString()
}, cancellationToken).ConfigureAwait(false);
if (response != null && response.Success)
await Context.CompleteAsync("onPremiumPurchase", cancellationToken).ConfigureAwait(false);
return response!;
}
/// <summary>Advances the run through the onLevelUp handle.</summary>
public Task LevelUpAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onLevelUp", cancellationToken);
/// <summary>Advances the run through the onLevelUp handle (fire-and-forget).</summary>
public void LevelUp() => Context.Complete("onLevelUp");
/// <summary>Advances the run through the onMaxLevel handle.</summary>
public Task MaxLevelAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onMaxLevel", cancellationToken);
/// <summary>Advances the run through the onMaxLevel handle (fire-and-forget).</summary>
public void MaxLevel() => Context.Complete("onMaxLevel");
/// <summary>Advances the run through the onComplete handle.</summary>
public Task CompleteAsync(CancellationToken cancellationToken = default) => Context.CompleteAsync("onComplete", cancellationToken);
/// <summary>Advances the run through the onComplete handle (fire-and-forget).</summary>
public void Complete() => Context.Complete("onComplete");
}

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