1066 lines
33 KiB
C#
1066 lines
33 KiB
C#
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|