227 lines
11 KiB
C#
227 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace BlockOutReplica.Gameplay
|
|
{
|
|
[Serializable]
|
|
public sealed class BlockOutJourneyReward
|
|
{
|
|
// CurrencyType constants recovered from IL2CPP v31 field default values;
|
|
// reproduce with tools/recover_currency_enum.py (not Dummy script order).
|
|
public int CurrencyType, Amount, ChestType, CollectionPackType;
|
|
public string TimeSpan;
|
|
public BlockOutJourneyRewardContent[] Rewards;
|
|
public bool IsCoins => CurrencyType == 1;
|
|
public bool IsTimedLives => CurrencyType == 2;
|
|
public BoosterType Booster => CurrencyType==3?BoosterType.Ufo:CurrencyType==4?BoosterType.Rocket:
|
|
CurrencyType==5?BoosterType.Freeze:BoosterType.None;
|
|
public bool IsSupported => IsCoins || IsTimedLives || Booster!=BoosterType.None;
|
|
public double DurationSeconds => System.TimeSpan.TryParse(TimeSpan, out var duration)
|
|
? Math.Max(0, duration.TotalSeconds) : 0;
|
|
}
|
|
|
|
// All nested Rewards arrays in this shipped config are empty. A distinct leaf
|
|
// type retains their schema without Unity's recursive serialization depth error.
|
|
[Serializable]
|
|
public sealed class BlockOutJourneyRewardContent
|
|
{
|
|
public int CurrencyType,Amount,ChestType,CollectionPackType;
|
|
public string TimeSpan;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class BlockOutJourneyRewardNode
|
|
{
|
|
public int Level;
|
|
public BlockOutJourneyReward[] Rewards;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class BlockOutJourneyPoster
|
|
{
|
|
public int Level, LevelRange;
|
|
public string PosterId, PosterName;
|
|
public int EndLevel => Level + LevelRange;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class BlockOutJourneyConfig
|
|
{
|
|
public bool IsEnabled;
|
|
public BlockOutJourneyRewardNode[] JourneyRewardsConfigs;
|
|
public BlockOutJourneyRewardNode[] JourneyRewardsConfigsWithCollection;
|
|
public BlockOutJourneyPoster[] JourneyPosterConfigs;
|
|
|
|
public static BlockOutJourneyConfig Load()
|
|
{
|
|
var asset = Resources.Load<TextAsset>("OriginalFlow/JourneyConfigModel");
|
|
if (asset == null) throw new InvalidOperationException("Missing original JourneyConfigModel resource.");
|
|
return JsonUtility.FromJson<BlockOutJourneyConfig>(asset.text);
|
|
}
|
|
}
|
|
|
|
public sealed class BlockOutJourneyClaim
|
|
{
|
|
public int Level { get; }
|
|
public BlockOutJourneyReward[] Rewards { get; }
|
|
public BlockOutJourneyClaim(int level, BlockOutJourneyReward[] rewards)
|
|
{ Level = level; Rewards = rewards; }
|
|
}
|
|
|
|
// Native WinState increments progress before Journey matching; configured
|
|
// levels use currentLevel = CompletedLevels + 1, not completed-level count.
|
|
// See Docs/iterations/2026-10-19-journey-native-evidence.md. Normal wins queue
|
|
// equality matches. Explicit QueueAvailable is only a migration/test utility.
|
|
// This ledger does not pay a wallet. The caller must apply the returned rewards
|
|
// once; cross-save crash atomicity requires a shared wallet/ledger transaction.
|
|
public sealed class BlockOutJourney
|
|
{
|
|
private const string Key = "BlockOutReplica.Journey.v1";
|
|
[Serializable] private sealed class Data
|
|
{
|
|
public List<int> claimedLevels = new List<int>();
|
|
public List<int> pendingLevels = new List<int>();
|
|
public string selectedPosterId;
|
|
}
|
|
private readonly BlockOutJourneyConfig config;
|
|
private readonly Action<string> writer;
|
|
private readonly List<BlockOutJourneyRewardNode> nodes;
|
|
private readonly List<BlockOutJourneyPoster> posters;
|
|
private readonly Data data;
|
|
public string SelectedPosterId => data.selectedPosterId;
|
|
public IReadOnlyList<BlockOutJourneyRewardNode> RewardNodes => nodes.AsReadOnly();
|
|
public IReadOnlyList<BlockOutJourneyPoster> Posters => posters.AsReadOnly();
|
|
public bool IsEnabled => config.IsEnabled;
|
|
public IReadOnlyList<int> PendingLevels => data.pendingLevels.AsReadOnly();
|
|
public static long CurrentLevel(int completedLevels) => (long)Math.Max(0, completedLevels) + 1;
|
|
|
|
public BlockOutJourney(BlockOutJourneyConfig config, string json = null, Action<string> save = null)
|
|
{
|
|
this.config = config ?? throw new ArgumentNullException(nameof(config));
|
|
writer = save ?? (value => { PlayerPrefs.SetString(Key, value); PlayerPrefs.Save(); });
|
|
if (json == null && save == null) json = PlayerPrefs.GetString(Key, "");
|
|
nodes = new List<BlockOutJourneyRewardNode>(config.JourneyRewardsConfigs ?? Array.Empty<BlockOutJourneyRewardNode>());
|
|
nodes.RemoveAll(node => node == null || node.Level <= 0);
|
|
nodes.Sort((a, b) => a.Level.CompareTo(b.Level));
|
|
posters = new List<BlockOutJourneyPoster>(config.JourneyPosterConfigs ?? Array.Empty<BlockOutJourneyPoster>());
|
|
posters.RemoveAll(poster => poster == null || string.IsNullOrEmpty(poster.PosterId));
|
|
posters.Sort((a, b) => a.Level.CompareTo(b.Level));
|
|
Data loaded = null;
|
|
try { if (!string.IsNullOrEmpty(json)) loaded = JsonUtility.FromJson<Data>(json); }
|
|
catch (ArgumentException) { }
|
|
data = loaded ?? new Data();
|
|
if (data.claimedLevels == null) data.claimedLevels = new List<int>();
|
|
var unique = new HashSet<int>();
|
|
data.claimedLevels.RemoveAll(level => FindNode(level) == null || !unique.Add(level));
|
|
data.claimedLevels.Sort();
|
|
if (data.pendingLevels == null) data.pendingLevels = new List<int>();
|
|
unique.Clear();
|
|
data.pendingLevels.RemoveAll(level => FindNode(level) == null || IsClaimed(level) || !unique.Add(level));
|
|
data.pendingLevels.Sort();
|
|
if (FindPoster(data.selectedPosterId) == null)
|
|
data.selectedPosterId = posters.Count > 0 ? posters[0].PosterId : "";
|
|
}
|
|
|
|
public string ToJson() => JsonUtility.ToJson(data);
|
|
public bool IsClaimed(int level) => data.claimedLevels.Contains(level);
|
|
public bool CanClaim(int level, int completedLevels) => IsEnabled && FindNode(level) != null
|
|
&& CurrentLevel(completedLevels) >= level && !IsClaimed(level);
|
|
public bool IsQueued(int level) => data.pendingLevels.Contains(level);
|
|
public int QueueAvailable(int completedLevels) => QueueNodes(completedLevels, false);
|
|
public int QueueWinReward(int completedLevels) => QueueNodes(completedLevels, true);
|
|
private int QueueNodes(int completedLevels, bool exactLevel)
|
|
{
|
|
var added = new List<int>();
|
|
foreach (var node in nodes)
|
|
if (CanClaim(node.Level, completedLevels) && !IsQueued(node.Level)
|
|
&& (!exactLevel || node.Level == CurrentLevel(completedLevels)))
|
|
{ data.pendingLevels.Add(node.Level); added.Add(node.Level); }
|
|
if (added.Count == 0) return 0;
|
|
data.pendingLevels.Sort();
|
|
try { writer(ToJson()); }
|
|
catch { foreach (int level in added) data.pendingLevels.Remove(level); throw; }
|
|
return added.Count;
|
|
}
|
|
public BlockOutJourneyRewardNode NextReward(int completedLevels)
|
|
{
|
|
foreach (var node in nodes) if (!IsClaimed(node.Level)) return node;
|
|
return null;
|
|
}
|
|
public int NodeTarget(int completedLevels)
|
|
{
|
|
var next = NextReward(completedLevels);
|
|
return next == null ? 0 : next.Level - PreviousNodeLevel(next.Level);
|
|
}
|
|
public int NodeProgress(int completedLevels)
|
|
{
|
|
var next = NextReward(completedLevels);
|
|
if (next == null) return 0;
|
|
int previous = PreviousNodeLevel(next.Level);
|
|
return (int)Math.Max(0, Math.Min(NodeTarget(completedLevels), CurrentLevel(completedLevels) - previous));
|
|
}
|
|
public float NodeProgress01(int completedLevels) => NodeTarget(completedLevels) == 0 ? 1f
|
|
: NodeProgress(completedLevels) / (float)NodeTarget(completedLevels);
|
|
|
|
public BlockOutJourneyPoster CurrentPoster(int completedLevels)
|
|
{
|
|
BlockOutJourneyPoster result = posters.Count > 0 ? posters[0] : null;
|
|
foreach (var poster in posters)
|
|
if (IsPosterUnlocked(poster.PosterId, completedLevels)) result = poster;
|
|
return result;
|
|
}
|
|
public bool IsPosterUnlocked(string id, int completedLevels)
|
|
{
|
|
var poster = FindPoster(id);
|
|
return IsEnabled && poster != null && (poster == posters[0]
|
|
|| CurrentLevel(completedLevels) >= poster.Level);
|
|
}
|
|
public BlockOutJourneyPoster SelectedPoster(int completedLevels)
|
|
{
|
|
var selected = FindPoster(SelectedPosterId);
|
|
return selected != null && IsPosterUnlocked(selected.PosterId, completedLevels)
|
|
? selected : (posters.Count > 0 ? posters[0] : null);
|
|
}
|
|
public bool TrySelectPoster(string id, int completedLevels)
|
|
{
|
|
if (!IsPosterUnlocked(id, completedLevels)) return false;
|
|
if (SelectedPosterId == id) return true;
|
|
string previous = data.selectedPosterId;
|
|
data.selectedPosterId = id;
|
|
try { writer(ToJson()); }
|
|
catch { data.selectedPosterId = previous; throw; }
|
|
return true;
|
|
}
|
|
public bool TryClaim(int level, int completedLevels, out BlockOutJourneyReward[] rewards)
|
|
{
|
|
rewards = Array.Empty<BlockOutJourneyReward>();
|
|
if (!CanClaim(level, completedLevels)) return false;
|
|
// Clone before committing, so consumers cannot mutate the original config.
|
|
var clone = JsonUtility.FromJson<BlockOutJourneyRewardNode>(JsonUtility.ToJson(FindNode(level)));
|
|
data.claimedLevels.Add(level);
|
|
data.claimedLevels.Sort();
|
|
bool wasQueued = data.pendingLevels.Remove(level);
|
|
try { writer(ToJson()); }
|
|
catch { data.claimedLevels.Remove(level); if (wasQueued) data.pendingLevels.Add(level); throw; }
|
|
rewards = clone.Rewards ?? Array.Empty<BlockOutJourneyReward>();
|
|
return true;
|
|
}
|
|
public List<BlockOutJourneyClaim> ClaimAvailable(int completedLevels)
|
|
{
|
|
var result = new List<BlockOutJourneyClaim>();
|
|
foreach (var node in nodes)
|
|
if (TryClaim(node.Level, completedLevels, out var rewards))
|
|
result.Add(new BlockOutJourneyClaim(node.Level, rewards));
|
|
return result;
|
|
}
|
|
private BlockOutJourneyRewardNode FindNode(int level) => nodes.Find(node => node.Level == level);
|
|
private BlockOutJourneyPoster FindPoster(string id) => posters.Find(poster => poster.PosterId == id);
|
|
private int PreviousNodeLevel(int level)
|
|
{
|
|
int result = 0;
|
|
foreach (var node in nodes) { if (node.Level >= level) break; result = node.Level; }
|
|
return result;
|
|
}
|
|
}
|
|
}
|