using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Threading.Tasks; using RudderSdk.Core; using RudderSdk.Core.Models.Inventory; using RudderSdk.Core.Models.Leaderboards; using RudderSdk.Core.Models.Player; using RudderSdk.Core.Models.Quests; using RudderSdk.Core.Models.Storage; using RudderSdk.Core.Models.Stores; using RudderSdk.Unity; using UnityEngine; namespace LiveOpsExamples { [Serializable] public class CozyIdentity { public string PlayerId; public string Nickname; public string DeviceId; public string ProjectKey; public string Region; } [Serializable] public class CozyWallet { public string Currency; public double Balance; } [Serializable] public class CozyTuning { public float RoundSeconds; public float PlayerSpeed; public float SpawnIntervalMs; public float CollectibleScore; } [Serializable] public class CozySave { public int BestScore; public int LastScore; public int RoundsPlayed; public int TotalCollected; } [Serializable] public class CozyLogEntry { public string Capability; public string Message; public string Detail; public DateTime Timestamp; } [Serializable] public class CozyShopOffer { public string StoreSlug; public string OfferId; public string Name; public double Price; public string Currency; } [Serializable] public class CozyBackpackItem { public string Slug; public string Name; public int Amount; } [Serializable] public class CozyLeaderboardEntry { public string PlayerName; public int Rank; public int Score; } [Serializable] public class CozyRoundResult { public int Score; public int Best; public int Collected; public List TopEntries; } [Serializable] public class CozyOfferInfo { public string StoreSlug; public string StoreName; public List Offers; } public enum CozyPhase { Booting, Connecting, Ready, Setup } public enum CozyModal { None, Shop, Backpack, Offer, RoundResult } public enum CozyToastTone { Info, Success, Error } /// /// Session/bootstrap hub for the Cozy Collector demo — the Unity port of the /// Phaser demo's liveops.ts + store.ts. Owns every SDK call: login, remote /// config (initial load, 10 s poll and scenario overrides), storage save, /// profile/wallets, store catalog, inventory, leaderboard and scenario effects. /// The UI layer binds to the public properties/events. /// public class CozyCollectorController : MonoBehaviour { private const int MaxLogEntries = 200; private const float ConfigPollSeconds = 10f; private const float NotificationAutoCompleteSeconds = 2.6f; [SerializeField] private GameWorld world; private readonly List _wallets = new List(); private readonly List _logEntries = new List(); private readonly List _shopOffers = new List(); private readonly List _backpackItems = new List(); private readonly CozyTuning _tuning = new CozyTuning { RoundSeconds = CozyCollectorConsts.DefaultRoundSeconds, PlayerSpeed = CozyCollectorConsts.DefaultPlayerSpeed, SpawnIntervalMs = CozyCollectorConsts.DefaultSpawnIntervalMs, CollectibleScore = CozyCollectorConsts.DefaultCollectibleScore }; private readonly CozySave _save = new CozySave(); private CozyPhase _phase = CozyPhase.Booting; private string _statusMessage = "Starting…"; private string _setupError; private CozyIdentity _identity; private CozyModal _activeModal = CozyModal.None; private CozyRoundResult _roundResult; private CozyOfferInfo _currentOffer; private string _deviceId; private string _nickname; private StoreOfferSession _storeSession; private LeaderboardSession _leaderboardSession; private bool _scenarioHandlersRegistered; public CozyPhase Phase => _phase; public string StatusMessage => _statusMessage; public string SetupError => _setupError; public CozyIdentity Identity => _identity; public IReadOnlyList Wallets => _wallets; public CozyTuning Tuning => _tuning; public CozySave Save => _save; public IReadOnlyList LogEntries => _logEntries; public CozyModal ActiveModal => _activeModal; public IReadOnlyList ShopOffers => _shopOffers; public IReadOnlyList BackpackItems => _backpackItems; public CozyRoundResult RoundResult => _roundResult; public CozyOfferInfo CurrentOffer => _currentOffer; public event Action PhaseChanged; public event Action ModalChanged; public event Action WalletsChanged; public event Action TuningChanged; public event Action LogAdded; public event Action ShopChanged; public event Action BackpackChanged; public event Action RoundResultChanged; public event Action OfferChanged; public event Action ToastRequested; // --------------------------------------------------------------- // Boot phase machine // --------------------------------------------------------------- private void Awake() { world.RoundFinished += OnRoundFinished; } private void Start() { _ = BootAsync(); } private void OnDestroy() { world.RoundFinished -= OnRoundFinished; UnregisterScenarioHandlers(); } private async Task BootAsync() { SetPhase(CozyPhase.Booting, "Starting…"); try { await Rudder.Ready; } catch (Exception exception) { _setupError = "Rudder is not initialized. Add the Rudder component to the scene " + "and assign a RudderConfiguration asset with a valid ProjectKey. " + exception.Message; SetPhase(CozyPhase.Setup, "Setup required"); Log(CozyCollectorConsts.CapabilitySystem, "Setup required", _setupError); return; } SetPhase(CozyPhase.Connecting, "Connecting to LiveOps…"); RegisterScenarioHandlers(); try { _deviceId = SystemInfo.deviceUniqueIdentifier; _nickname = GetOrCreateNickname(_deviceId); Log(CozyCollectorConsts.CapabilityAuth, "loginWithDevice", _nickname + " · " + ShortId(_deviceId)); await Rudder.Client.Auth.LoginWithDeviceAsync( CozyCollectorConsts.Region, CozyCollectorConsts.Language, _nickname); await Rudder.Client.RemoteConfig.LoadAsync(); RefreshTuning(false); await LoadSaveAsync(); await RefreshProfileAsync(); await RefreshStoresAsync(); await RefreshInventoryAsync(); await RefreshQuestsAsync(); try { await Rudder.Client.Scenario.TriggerAsync("player_login"); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityScenario, "Trigger failed", exception.Message); } SetPhase(CozyPhase.Ready, "Connected"); world.RestartRound(); StartCoroutine(PollConfigLoop()); } catch (Exception exception) { _setupError = exception.Message; SetPhase(CozyPhase.Setup, "Setup required"); Log(CozyCollectorConsts.CapabilitySystem, "Login failed", exception.Message); } } // --------------------------------------------------------------- // Remote config // --------------------------------------------------------------- private IEnumerator PollConfigLoop() { var wait = new WaitForSeconds(ConfigPollSeconds); while (true) { yield return wait; _ = PollConfigAsync(); } } private async Task PollConfigAsync() { try { await Rudder.Client.RemoteConfig.LoadAsync(); RefreshTuning(true); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityConfig, "Config poll failed", exception.Message); } } private void RefreshTuning(bool announce) { var next = new CozyTuning { RoundSeconds = ReadConfigFloat(CozyCollectorConsts.ConfigRoundSeconds, CozyCollectorConsts.DefaultRoundSeconds), PlayerSpeed = ReadConfigFloat(CozyCollectorConsts.ConfigPlayerSpeed, CozyCollectorConsts.DefaultPlayerSpeed), SpawnIntervalMs = ReadConfigFloat(CozyCollectorConsts.ConfigSpawnIntervalMs, CozyCollectorConsts.DefaultSpawnIntervalMs), CollectibleScore = ReadConfigFloat(CozyCollectorConsts.ConfigCollectibleScore, CozyCollectorConsts.DefaultCollectibleScore) }; var changedKeys = new List(); if (!Mathf.Approximately(_tuning.RoundSeconds, next.RoundSeconds)) changedKeys.Add(CozyCollectorConsts.ConfigRoundSeconds); if (!Mathf.Approximately(_tuning.PlayerSpeed, next.PlayerSpeed)) changedKeys.Add(CozyCollectorConsts.ConfigPlayerSpeed); if (!Mathf.Approximately(_tuning.SpawnIntervalMs, next.SpawnIntervalMs)) changedKeys.Add(CozyCollectorConsts.ConfigSpawnIntervalMs); if (!Mathf.Approximately(_tuning.CollectibleScore, next.CollectibleScore)) changedKeys.Add(CozyCollectorConsts.ConfigCollectibleScore); _tuning.RoundSeconds = next.RoundSeconds; _tuning.PlayerSpeed = next.PlayerSpeed; _tuning.SpawnIntervalMs = next.SpawnIntervalMs; _tuning.CollectibleScore = next.CollectibleScore; if (!announce || changedKeys.Count > 0) { Log( CozyCollectorConsts.CapabilityConfig, "Remote config loaded", $"speed {Mathf.RoundToInt(_tuning.PlayerSpeed)} · spawn {Mathf.RoundToInt(_tuning.SpawnIntervalMs)}ms · " + $"+{Mathf.RoundToInt(_tuning.CollectibleScore)} · {Mathf.RoundToInt(_tuning.RoundSeconds)}s"); } foreach (var key in changedKeys) { TuningChanged?.Invoke(key); if (announce) { Log(CozyCollectorConsts.CapabilityConfig, "Config changed live", key); Toast("Remote config updated", key + " applied live", CozyToastTone.Success); } } } private static float ReadConfigFloat(string key, float fallback) { return Rudder.Client.RemoteConfig.Get(key, fallback); } // --------------------------------------------------------------- // Profile / wallets / stores / inventory (pull-based freshness) // --------------------------------------------------------------- private async Task RefreshProfileAsync() { var profile = await Rudder.Client.Player.GetProfileAsync(); var player = profile?.Player; _identity = new CozyIdentity { PlayerId = player?.Id ?? "unknown", Nickname = _nickname, DeviceId = _deviceId, ProjectKey = Rudder.Client.ProjectKey, Region = player?.Region }; Log( CozyCollectorConsts.CapabilityAuth, "Authenticated", _identity.Nickname + " · " + ShortId(_identity.PlayerId)); ApplyWallets(profile); } private async Task RefreshWalletsAsync() { var profile = await Rudder.Client.Player.GetProfileAsync(); ApplyWallets(profile); } private void ApplyWallets(PlayerProfile profile) { _wallets.Clear(); if (profile?.Wallets != null) { foreach (var wallet in profile.Wallets) { _wallets.Add(new CozyWallet { Currency = wallet.Currency ?? string.Empty, Balance = wallet.Balance }); } } Log(CozyCollectorConsts.CapabilityWallet, "Wallet loaded", WalletSummary()); WalletsChanged?.Invoke(); } private async Task RefreshStoresAsync() { var stores = await Rudder.Client.Stores.ListAsync(); _shopOffers.Clear(); if (stores != null) { foreach (var store in stores) { if (store?.Offers == null) continue; foreach (var offer in store.Offers) { _shopOffers.Add(MapOffer(store.Slug, offer)); } } } Log(CozyCollectorConsts.CapabilityStore, "Store catalog loaded", _shopOffers.Count + " offer(s)"); ShopChanged?.Invoke(); } private async Task RefreshInventoryAsync() { var items = await Rudder.Client.Inventory.GetAsync(); _backpackItems.Clear(); if (items != null) { foreach (var item in items) { var slug = item.Slug ?? string.Empty; _backpackItems.Add(new CozyBackpackItem { Slug = slug, Name = string.IsNullOrEmpty(item.NameOverride) ? slug : item.NameOverride, Amount = (int)item.Amount }); } } Log(CozyCollectorConsts.CapabilityInventory, "Inventory loaded", _backpackItems.Count + " item(s)"); BackpackChanged?.Invoke(); } private async Task RefreshPurchasablesAsync() { // Purchases mutate wallets, catalog stock and inventory server-side; // the C# SDK has no onChange, so freshness is pull-based. try { await RefreshWalletsAsync(); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityWallet, "Wallet refresh failed", exception.Message); } try { await RefreshStoresAsync(); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityStore, "Store refresh failed", exception.Message); } try { await RefreshInventoryAsync(); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityInventory, "Inventory refresh failed", exception.Message); } // Purchase metrics (purchase.offer:* / purchase.item:*) are reported // server-side by the purchase fan-out; re-list to pick up new progress. try { await RefreshQuestsAsync(); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityQuests, "Quest refresh failed", exception.Message); } } // --------------------------------------------------------------- // Storage save // --------------------------------------------------------------- // Storage payload mirrors the Phaser saveRound JSON shape (camelCase keys). [Serializable] private class SavePayload { public int bestScore; public int lastScore; public int roundsPlayed; public int totalCollected; public string updatedAt; } private async Task LoadSaveAsync() { var response = await Rudder.Client.Storage.GetAsync(); var item = response?.Items?.Find( candidate => candidate.Type == CozyCollectorConsts.StorageType && candidate.Id == CozyCollectorConsts.StorageId); if (item == null || string.IsNullOrEmpty(item.Data)) { Log(CozyCollectorConsts.CapabilityStorage, "No saved progress yet", CozyCollectorConsts.StorageType); return; } try { var payload = JsonUtility.FromJson(item.Data); _save.BestScore = payload.bestScore; _save.LastScore = payload.lastScore; _save.RoundsPlayed = payload.roundsPlayed; _save.TotalCollected = payload.totalCollected; Log( CozyCollectorConsts.CapabilityStorage, "Loaded progress", $"best {_save.BestScore} · rounds {_save.RoundsPlayed}"); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityStorage, "Save data unreadable", exception.Message); } } private async Task SaveRoundAsync(int score, int collected) { _save.BestScore = Math.Max(_save.BestScore, score); _save.LastScore = score; _save.RoundsPlayed += 1; _save.TotalCollected += collected; var payload = new SavePayload { bestScore = _save.BestScore, lastScore = _save.LastScore, roundsPlayed = _save.RoundsPlayed, totalCollected = _save.TotalCollected, updatedAt = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture) }; await Rudder.Client.Storage.SaveAsync(new[] { new StorageItem { Type = CozyCollectorConsts.StorageType, Id = CozyCollectorConsts.StorageId, Data = JsonUtility.ToJson(payload) } }); Log( CozyCollectorConsts.CapabilityStorage, "Saved progress", $"best {_save.BestScore} · rounds {_save.RoundsPlayed}"); } // --------------------------------------------------------------- // Leaderboard // --------------------------------------------------------------- private async Task> SubmitLeaderboardAsync(int score) { var leaderboard = Rudder.Client.Leaderboards.FindBySlug(CozyCollectorConsts.LeaderboardSlug); await leaderboard.SubmitAsync(score); var entries = await leaderboard.ListAsync(10); Log(CozyCollectorConsts.CapabilityLeaderboard, $"Submitted score {score}", CozyCollectorConsts.LeaderboardSlug); return MapEntries(entries); } private async Task> ListLeaderboardAsync() { var entries = await Rudder.Client.Leaderboards.FindBySlug(CozyCollectorConsts.LeaderboardSlug).ListAsync(10); return MapEntries(entries); } private static List MapEntries(IReadOnlyList entries) { var mapped = new List(); if (entries == null) return mapped; foreach (var entry in entries) { mapped.Add(new CozyLeaderboardEntry { PlayerName = entry.PlayerName ?? entry.PlayerId ?? "(unknown)", Rank = (int)entry.Rank, Score = (int)entry.Score }); } return mapped; } // --------------------------------------------------------------- // Global quests (distinct from scenario quest nodes / QuestSession) // --------------------------------------------------------------- private async Task RefreshQuestsAsync() { var quests = await Rudder.Client.Quests.ListAsync(); var activeCount = 0; if (quests != null) { foreach (var quest in quests) { if (quest.Status == "claimed") continue; activeCount += 1; Log(CozyCollectorConsts.CapabilityQuests, "Quest " + (quest.Status ?? "active"), QuestProgressSummary(quest)); if (quest.Status == "completed" && !string.IsNullOrEmpty(quest.Id)) await ClaimQuestAsync(quest); } } Log(CozyCollectorConsts.CapabilityQuests, "Quests loaded", activeCount + " active"); } private async Task ClaimQuestAsync(Quest quest) { var result = await Rudder.Client.Quests.ClaimAsync(quest.Id); if (result == null || !result.Success) { Log(CozyCollectorConsts.CapabilityQuests, "Quest claim rejected", result?.Error); return; } if (!result.AlreadyClaimed) Toast("Quest complete", quest.Name ?? quest.Id, CozyToastTone.Success); Log(CozyCollectorConsts.CapabilityQuests, "Quest claimed", quest.Name ?? quest.Id); await RefreshWalletsAsync(); } private static string QuestProgressSummary(Quest quest) { var name = quest.Name ?? quest.Id; if (quest.Objectives == null || quest.Objectives.Count == 0) return name; var parts = new List(); foreach (var objective in quest.Objectives) { parts.Add(objective.Metric + " " + objective.Current + "/" + objective.Target); } return name + " · " + string.Join(" · ", parts); } // --------------------------------------------------------------- // Round flow // --------------------------------------------------------------- private void OnRoundFinished(int score, int collected) { SyncWorldPause(); _ = FinishRoundAsync(score, collected); } private async Task FinishRoundAsync(int score, int collected) { try { await SaveRoundAsync(score, collected); } catch (Exception exception) { Toast("Save failed", exception.Message, CozyToastTone.Error); Log(CozyCollectorConsts.CapabilityStorage, "Save failed", exception.Message); } var entries = new List(); try { entries = await SubmitLeaderboardAsync(score); } catch (Exception exception) { Toast("Leaderboard failed", exception.Message, CozyToastTone.Error); Log(CozyCollectorConsts.CapabilityLeaderboard, "Submit failed", exception.Message); } _roundResult = new CozyRoundResult { Score = score, Best = _save.BestScore, Collected = collected, TopEntries = entries }; RoundResultChanged?.Invoke(); SetModal(CozyModal.RoundResult); try { await Rudder.Client.Scenario.TriggerAsync("demo_round_finished"); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityScenario, "Trigger failed", exception.Message); } try { var completedIds = await Rudder.Client.Quests.ReportProgressAsync( CozyCollectorConsts.QuestMetricCollectibles, collected); Log( CozyCollectorConsts.CapabilityQuests, "Quest progress reported", CozyCollectorConsts.QuestMetricCollectibles + " +" + collected + (completedIds.Count > 0 ? " · completed " + completedIds.Count + " quest(s)" : string.Empty)); await RefreshQuestsAsync(); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityQuests, "Quest progress failed", exception.Message); } } public void PlayAgain() { SetModal(CozyModal.None); world.RestartRound(); SyncWorldPause(); } // --------------------------------------------------------------- // Modals // --------------------------------------------------------------- public void OpenModal(CozyModal modal) { SetModal(modal); } public void CloseModal() { // Scenario offers must be bought or declined, same as the Phaser store. if (_activeModal == CozyModal.Offer) return; SetModal(CozyModal.None); } private void SetModal(CozyModal modal) { if (_activeModal == modal) return; _activeModal = modal; ModalChanged?.Invoke(); SyncWorldPause(); } private void SyncWorldPause() { world.SetPaused(_activeModal != CozyModal.None || !world.RoundActive); } // --------------------------------------------------------------- // Shop // --------------------------------------------------------------- public void BuyShopOffer(string offerId) { _ = BuyShopOfferAsync(offerId); } private async Task BuyShopOfferAsync(string offerId) { var offer = _shopOffers.Find(candidate => candidate.OfferId == offerId); if (offer == null) { Toast("Purchase failed", "Unknown offer " + offerId, CozyToastTone.Error); return; } try { var response = await Rudder.Client.Stores.PurchaseAsync( offer.StoreSlug, offer.OfferId, "demo-shop-" + offer.OfferId + "-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); if (response != null && !response.Success) { Toast("Purchase failed", response.Error ?? "The store rejected the purchase.", CozyToastTone.Error); Log(CozyCollectorConsts.CapabilityStore, "Purchase rejected", response.Error); return; } } catch (Exception exception) { Toast("Purchase failed", exception.Message, CozyToastTone.Error); Log(CozyCollectorConsts.CapabilityStore, "Purchase failed", exception.Message); return; } Toast("Purchase complete", offer.Name, CozyToastTone.Success); Log(CozyCollectorConsts.CapabilityStore, "Purchased offer", offer.Name + " · " + offer.StoreSlug); await RefreshPurchasablesAsync(); } // --------------------------------------------------------------- // Scenario store offer // --------------------------------------------------------------- public void BuyScenarioOffer() { _ = BuyScenarioOfferAsync(); } private async Task BuyScenarioOfferAsync() { var session = _storeSession; var offerInfo = _currentOffer; if (session == null || offerInfo == null || offerInfo.Offers == null || offerInfo.Offers.Count == 0) return; // Mirror the Phaser OfferModal: prefer the configured offer, fall back to the first. var offer = offerInfo.Offers.Find(candidate => candidate.OfferId == CozyCollectorConsts.OfferId) ?? offerInfo.Offers[0]; try { var response = await Rudder.Client.Stores.PurchaseAsync( offer.StoreSlug, offer.OfferId, "demo-offer-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); if (response != null && !response.Success) { Toast("Offer failed", response.Error ?? "The store rejected the purchase.", CozyToastTone.Error); Log(CozyCollectorConsts.CapabilityStore, "Offer purchase rejected", response.Error); return; } session.Purchase(); } catch (Exception exception) { Toast("Offer failed", exception.Message, CozyToastTone.Error); Log(CozyCollectorConsts.CapabilityStore, "Offer purchase failed", exception.Message); return; } _storeSession = null; _currentOffer = null; OfferChanged?.Invoke(); SetModal(CozyModal.None); Toast("Offer purchased", "Waiting for the scenario reward…", CozyToastTone.Success); Log(CozyCollectorConsts.CapabilityStore, "Scenario offer purchased", offer.Name); await RefreshPurchasablesAsync(); } public void DeclineScenarioOffer() { var session = _storeSession; if (session == null) return; session.Decline(); _storeSession = null; _currentOffer = null; OfferChanged?.Invoke(); SetModal(CozyModal.None); Toast("Offer declined", "The camp merchant will return later.", CozyToastTone.Info); Log(CozyCollectorConsts.CapabilityStore, "Scenario offer declined", null); } // --------------------------------------------------------------- // Scenario leaderboard (dormant path, driven from the result modal) // --------------------------------------------------------------- public void ClaimScenarioLeaderboardReward() { if (_leaderboardSession == null) return; _leaderboardSession.RewardClaimed(); _leaderboardSession = null; Log(CozyCollectorConsts.CapabilityLeaderboard, "Scenario reward claimed", null); } public void EndScenarioLeaderboard() { if (_leaderboardSession == null) return; _leaderboardSession.End(); _leaderboardSession = null; Log(CozyCollectorConsts.CapabilityLeaderboard, "Scenario leaderboard ended", null); } // --------------------------------------------------------------- // Scenario handlers // --------------------------------------------------------------- private void RegisterScenarioHandlers() { if (_scenarioHandlersRegistered || Rudder.State != RudderState.Ready) return; Rudder.Client.Scenario.OnNotification += OnScenarioNotification; Rudder.Client.Scenario.OnWait += OnScenarioWait; Rudder.Client.Scenario.OnStoreOffer += OnScenarioStoreOffer; Rudder.Client.Scenario.OnConfigChanged += OnScenarioConfigChanged; Rudder.Client.Scenario.OnQuest += OnScenarioQuest; Rudder.Client.Scenario.OnLeaderboard += OnScenarioLeaderboard; Rudder.Client.Scenario.OnBattlePass += OnScenarioBattlePass; Rudder.Client.Scenario.OnBattlePassLevel += OnScenarioBattlePassLevel; Rudder.Client.Scenario.OnScenarioCompleted += OnScenarioCompleted; Rudder.Client.Scenario.OnScenarioFailed += OnScenarioFailed; _scenarioHandlersRegistered = true; } private void UnregisterScenarioHandlers() { if (!_scenarioHandlersRegistered || Rudder.State != RudderState.Ready) return; Rudder.Client.Scenario.OnNotification -= OnScenarioNotification; Rudder.Client.Scenario.OnWait -= OnScenarioWait; Rudder.Client.Scenario.OnStoreOffer -= OnScenarioStoreOffer; Rudder.Client.Scenario.OnConfigChanged -= OnScenarioConfigChanged; Rudder.Client.Scenario.OnQuest -= OnScenarioQuest; Rudder.Client.Scenario.OnLeaderboard -= OnScenarioLeaderboard; Rudder.Client.Scenario.OnBattlePass -= OnScenarioBattlePass; Rudder.Client.Scenario.OnBattlePassLevel -= OnScenarioBattlePassLevel; Rudder.Client.Scenario.OnScenarioCompleted -= OnScenarioCompleted; Rudder.Client.Scenario.OnScenarioFailed -= OnScenarioFailed; _scenarioHandlersRegistered = false; } private void OnScenarioNotification(NotificationSession session) { var title = session.Get("title", ""); var message = session.Get("message", ""); Log(CozyCollectorConsts.CapabilityScenario, "Notification effect", string.IsNullOrEmpty(title) ? message : title); Toast(string.IsNullOrEmpty(title) ? "LiveOps" : title, message, CozyToastTone.Info); StartCoroutine(CompleteNotificationAfterDelay(session)); } private IEnumerator CompleteNotificationAfterDelay(NotificationSession session) { yield return new WaitForSeconds(NotificationAutoCompleteSeconds); session.Complete(); _ = RefreshWalletsAfterNotificationAsync(); } private async Task RefreshWalletsAfterNotificationAsync() { // Notification rewards may have landed on the wallet; pull fresh state. try { await RefreshWalletsAsync(); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityWallet, "Wallet refresh failed", exception.Message); } } private void OnScenarioWait(WaitSession session) { Log(CozyCollectorConsts.CapabilityScenario, "Scenario wait", session.DeadlineUtc.ToString("O")); } private void OnScenarioStoreOffer(StoreOfferSession session) { var storeSlug = session.Get("storeSlug", CozyCollectorConsts.StoreSlug); Log(CozyCollectorConsts.CapabilityScenario, "Store-offer effect", storeSlug); _ = PresentStoreOfferAsync(session, storeSlug); } private async Task PresentStoreOfferAsync(StoreOfferSession session, string storeSlug) { try { var store = await Rudder.Client.Stores.GetAsync(storeSlug); var offers = new List(); if (store?.Offers != null) { foreach (var offer in store.Offers) { offers.Add(MapOffer(store.Slug, offer)); } } _storeSession = session; _currentOffer = new CozyOfferInfo { StoreSlug = store?.Slug ?? storeSlug, StoreName = store?.Name ?? storeSlug, Offers = offers }; OfferChanged?.Invoke(); OpenModal(CozyModal.Offer); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityStore, "Store offer unavailable", exception.Message); Toast("Offer unavailable", exception.Message, CozyToastTone.Error); session.Decline(); } } private void OnScenarioConfigChanged(ConfigChangedSession session) { // The SDK dispatch already applied the patches to the remote-config cache. Log(CozyCollectorConsts.CapabilityScenario, "Remote config override applied", session.Context.NodeId); RefreshTuning(true); } private void OnScenarioQuest(QuestSession session) { Log(CozyCollectorConsts.CapabilityScenario, "Scenario quest node", session.Context.NodeId); } private void OnScenarioLeaderboard(LeaderboardSession session) { // Dormant until a scenario emits a leaderboard node; shares the round-result modal. _leaderboardSession = session; Log(CozyCollectorConsts.CapabilityLeaderboard, "Leaderboard effect (scenario)", session.Context.NodeId); _ = PresentScenarioLeaderboardAsync(); } private async Task PresentScenarioLeaderboardAsync() { var entries = new List(); try { entries = await ListLeaderboardAsync(); } catch (Exception exception) { Log(CozyCollectorConsts.CapabilityLeaderboard, "List failed", exception.Message); } _roundResult = new CozyRoundResult { Score = _save.LastScore, Best = _save.BestScore, Collected = 0, TopEntries = entries }; RoundResultChanged?.Invoke(); SetModal(CozyModal.RoundResult); } private void OnScenarioBattlePass(BattlePassSession session) { Log(CozyCollectorConsts.CapabilityScenario, "Scenario battlepass node", session.Context.NodeId); } private void OnScenarioBattlePassLevel(BattlePassLevelSession session) { Log(CozyCollectorConsts.CapabilityScenario, "Scenario battlepass level node", session.Context.NodeId); } private void OnScenarioCompleted(PlanRun run) { Log(CozyCollectorConsts.CapabilityScenario, "Scenario run completed", run.RunId); } private void OnScenarioFailed(ScenarioFailedEvent failed) { Log(CozyCollectorConsts.CapabilityScenario, "Scenario run failed", failed.NodeId + ": " + failed.Exception.Message); } // --------------------------------------------------------------- // Inspector log / toasts // --------------------------------------------------------------- public void Log(string capability, string message, string detail = null) { _logEntries.Insert(0, new CozyLogEntry { Capability = capability, Message = message, Detail = detail, Timestamp = DateTime.Now }); if (_logEntries.Count > MaxLogEntries) _logEntries.RemoveAt(_logEntries.Count - 1); LogAdded?.Invoke(); } public void Toast(string title, string message, CozyToastTone tone) { ToastRequested?.Invoke(title, message, tone); } // --------------------------------------------------------------- // Helpers // --------------------------------------------------------------- private void SetPhase(CozyPhase phase, string statusMessage) { if (_phase == phase && _statusMessage == statusMessage) return; _phase = phase; _statusMessage = statusMessage; PhaseChanged?.Invoke(); } private string WalletSummary() { if (_wallets.Count == 0) return "empty"; var parts = new List(); foreach (var wallet in _wallets) { parts.Add((wallet.Balance + " " + wallet.Currency).Trim()); } return string.Join(" · ", parts); } private static CozyShopOffer MapOffer(string storeSlug, Offer offer) { return new CozyShopOffer { StoreSlug = storeSlug, OfferId = offer.Id, Name = string.IsNullOrEmpty(offer.Name) ? offer.Id : offer.Name, Price = offer.Price?.Amount ?? 0, Currency = offer.Price?.Currency ?? string.Empty }; } private static string GetOrCreateNickname(string deviceId) { var existing = PlayerPrefs.GetString(CozyCollectorConsts.NicknamePrefsKey, string.Empty); if (!string.IsNullOrEmpty(existing)) return existing; // Nickname is generated locally once and passed to LoginWithDeviceAsync. var compact = deviceId.Replace("-", string.Empty); var suffix = compact.Length > 4 ? compact.Substring(0, 4) : compact; var nickname = "Player-" + suffix; PlayerPrefs.SetString(CozyCollectorConsts.NicknamePrefsKey, nickname); PlayerPrefs.Save(); return nickname; } private static string ShortId(string value) { return value != null && value.Length > 10 ? value.Substring(0, 8) + "…" : value; } } }