Files
rudder-unity-sdk/Assets/LiveOpsExamples/Scripts/CozyCollectorController.cs
T

1066 lines
33 KiB
C#
Raw Normal View History

2026-08-12 14:03:44 +03:00
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.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<CozyLeaderboardEntry> TopEntries;
}
[Serializable]
public class CozyOfferInfo
{
public string StoreSlug;
public string StoreName;
public List<CozyShopOffer> Offers;
}
public enum CozyPhase
{
Booting,
Connecting,
Ready,
Setup
}
public enum CozyModal
{
None,
Shop,
Backpack,
Offer,
RoundResult
}
public enum CozyToastTone
{
Info,
Success,
Error
}
/// <summary>
/// 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.
/// </summary>
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<CozyWallet> _wallets = new List<CozyWallet>();
private readonly List<CozyLogEntry> _logEntries = new List<CozyLogEntry>();
private readonly List<CozyShopOffer> _shopOffers = new List<CozyShopOffer>();
private readonly List<CozyBackpackItem> _backpackItems = new List<CozyBackpackItem>();
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<CozyWallet> Wallets => _wallets;
public CozyTuning Tuning => _tuning;
public CozySave Save => _save;
public IReadOnlyList<CozyLogEntry> LogEntries => _logEntries;
public CozyModal ActiveModal => _activeModal;
public IReadOnlyList<CozyShopOffer> ShopOffers => _shopOffers;
public IReadOnlyList<CozyBackpackItem> 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<string> TuningChanged;
public event Action LogAdded;
public event Action ShopChanged;
public event Action BackpackChanged;
public event Action RoundResultChanged;
public event Action OfferChanged;
public event Action<string, string, CozyToastTone> 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();
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<string>();
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); }
}
// ---------------------------------------------------------------
// 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<SavePayload>(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<List<CozyLeaderboardEntry>> 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<List<CozyLeaderboardEntry>> ListLeaderboardAsync()
{
var entries = await Rudder.Client.Leaderboards.FindBySlug(CozyCollectorConsts.LeaderboardSlug).ListAsync(10);
return MapEntries(entries);
}
private static List<CozyLeaderboardEntry> MapEntries(IReadOnlyList<RankEntry> entries)
{
var mapped = new List<CozyLeaderboardEntry>();
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;
}
// ---------------------------------------------------------------
// 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<CozyLeaderboardEntry>();
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);
}
}
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<string>("title", "");
var message = session.Get<string>("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<string>("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<CozyShopOffer>();
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<CozyLeaderboardEntry>();
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<string>();
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;
}
}
}