Files
2026-10-20 13:21:04 +08:00

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;
}
}
}