#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using asap.core; using GameCore; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UniRx; using UnityEngine; namespace game.PlayerDataStorage { internal static partial class PlayerKVGameplayLoop { // Fixed original data-method fragments; does not execute the reward panel or formulas. internal static async Task VerifyOriginalFishingRewardForEditorAsync() { if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle != null || File.Exists(Path.Combine(Root, "permit.json")) || validationCleanupFailure != null) { throw new InvalidOperationException("Idle editor without a real permit required."); } if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0) { throw new InvalidOperationException("Validation running."); } var oldContainer = GContext.container; var oldEvents = FishingContextAccess.Events; var oldLanguages = LocalizationMgr.enumLanguage; var textTable = typeof(LocalizationMgr).GetField("table", BindingFlags.Static | BindingFlags.NonPublic); var oldTextTable = textTable.GetValue(null); var folder = Path.Combine(Root, "reward-original-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N")); var passed = new List(); int checks = 0; void Check(bool value, string message) { checks++; if (!value) { throw new InvalidOperationException("Original reward data: " + message); } } try { foreach (var scenario in new[] { "fish-non-record", "fish-new-record", "fish-new-fish", "main-cash-0", "main-cash-122" }) { var errors = new List(); var fixture = new EquipmentFixture(Path.Combine(folder, scenario)); KVPlayerSaveStore store = null; Container container = null; PlayerData player = null; AchievementDataManager achievements = null; RewardSubscribers subscribers = null; IDisposable subscription = null; var unexpectedLogs = new List(); void Clean(Action action) { try { action(); } catch (Exception error) { errors.Add(error); validationCleanupFailure = fixture; } } void Log(string message, string stack, LogType type) { if (type == LogType.Error || type == LogType.Exception || type == LogType.Assert) { unexpectedLogs.Add(message + "\n" + stack); } } Application.logMessageReceived += Log; try { var initialMap = new Dictionary { { 10241, "2|20|30|40|False" } }; if (scenario != "fish-new-fish") { initialMap[10211] = "0|10|20|20|False"; } string initialMapJson = JsonConvert.SerializeObject(initialMap); fixture.Remote["Map_5_102"] = initialMapJson; var owner = await fixture.Open(); store = new KVPlayerSaveStore(); store.Bind(owner, CancellationToken.None); var tables = new cfg.Tables(n => SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData", n + ".json"))))); container = new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events = new EventAggregator(); container.RegisterInstance(tables); container.RegisterInstance(store); player = new PlayerData(tables, null); var fish = new PlayerFishData(tables, null); var small = new SmallGameData(tables, null, null); achievements = new AchievementDataManager(tables); var events = new FishingEventData(tables); container.RegisterInstance(player); container.RegisterInstance(fish); container.RegisterInstance(small); container.RegisterInstance(achievements); container.RegisterInstance(new FishingBoxData()); container.RegisterInstance(events); player.playerSaveStore = store; fish.playerSaveStore = store; small.playerSaveStore = store; events.playerSaveStore = store; achievements.Init(); LocalizationMgr.enumLanguage = tables.TbLanguageConfig.DataList.Select(x => x.ID).ToList(); textTable.SetValue(null, null); Module02OriginalLoadTests.LoadCore(player, new object[] { 60, 100UL, 0, 393, 5, 4, 30003, new DateTime(2026, 1, 1), 0, 112, 7UL, 2, 102, 102, 13, 102, 0, 0, 0 }); var seen = new List(); subscription = GContext.OnEvent().Subscribe(e => seen.Add(e.type)); if (scenario.StartsWith("fish-", StringComparison.Ordinal)) { var data = initialMap.ToDictionary(pair => pair.Key, pair => FishWeightData.Deserialize(pair.Value)); var fields = BindingFlags.Instance | BindingFlags.NonPublic; typeof(PlayerFishData).GetField("fishData", fields).SetValue(fish, data); typeof(PlayerFishData).GetField("mapFishData", fields).SetValue(fish, new Dictionary> { { 102, data } }); fish.SetGetFishCount(10211); fish.SetWeight(10211, scenario == "fish-new-record" ? 12 : 5); var actual = data[10211]; Check(actual.TotalNum == (scenario == "fish-new-fish" ? 10 : 30), "original in-memory catch count"); Check(actual.MaxG == (scenario == "fish-new-record" ? 12 : scenario == "fish-new-fish" ? 5 : 10), "original maximum weight rule"); Check(actual.TotalCard == (scenario == "fish-new-fish" ? 0 : 20) && actual.Level == 0 && data[10241].Serialize() == "2|20|30|40|False", "original other fish and card data"); Check(seen.SequenceEqual(new[] { cfg.ConditionType.GetFishWeight, cfg.ConditionType.GetFishID }), "two original fish events without final-save replay"); string expectedMap = store.Read("Map_5_102").ValueOrThrow(); Check(fixture.Sets == 0 && fixture.Remote["Map_5_102"] == initialMapJson, "fish data waits for ordinary save"); Check(scenario == "fish-non-record" ? expectedMap == initialMapJson : expectedMap != initialMapJson, "original non-record does not force a map write"); var flushed = await KVMgr.SyncKVAsync(owner); Check(flushed.TransportError == PlayerKVError.None && flushed.Blocked == 0 && flushed.Unknown == 0 && fixture.Sets == (scenario == "fish-non-record" ? 0 : 1), "original map ordinary save result"); Check(fixture.Remote["Map_5_102"] == expectedMap && fixture.Remote["NewPlayerDataDic"] == "original-save", "exact original map payload without invented main write"); var savedMap = JsonConvert.DeserializeObject>(expectedMap); var savedFish = FishWeightData.Deserialize(savedMap[10211]); Check(savedFish.TotalNum == (scenario == "fish-non-record" ? 20 : scenario == "fish-new-fish" ? 10 : 30), "persisted count follows original save timing"); Check(savedMap[10241] == "2|20|30|40|False" && savedFish.TotalCard == actual.TotalCard && savedFish.Level == actual.Level && savedFish.NewFish == actual.NewFish, "saved fish fields and neighbor preserved"); var reloaded = new PlayerFishData(tables, null); reloaded.InitData(new Dictionary { { "Map_5_102", expectedMap } }); var reloadedMap = Module02OriginalLoadTests.Field>>(reloaded, "mapFishData"); var config = tables.TbFishData[10211]; float cap = Mathf.Round(config.BaseWeight * config.PointExtraParam * (1 + config.WeightRandom) * 100) / 100; float expectedWeight = config.Quality <= 5 ? Mathf.Min(savedFish.MaxG, cap) : savedFish.MaxG; Check(reloadedMap[102][10211].TotalNum == savedFish.TotalNum && reloadedMap[102][10211].MaxG == expectedWeight && reloadedMap[102][10211].TotalCard == savedFish.TotalCard && reloadedMap[102][10211].NewFish == savedFish.NewFish, "original fish parser and original maximum-weight correction"); } else { int cash = scenario == "main-cash-122" ? 122 : 0; subscribers = new RewardSubscribers(); subscribers.Bind(tables, events, container, store); GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.PerfectStrikeCount, 1)); player.SaveFishingFrequency(); string expectedMain = store.Read("NewPlayerDataDic").ValueOrThrow(); string expectedAchievements = achievements.AchievementDataToJson(); GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.CashFromFishing, cash)); GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.MaxCashFromFishing, cash / 10)); if (cash > 0) { player.AddGold((ulong)cash); expectedMain = store.Read("NewPlayerDataDic").ValueOrThrow(); expectedAchievements = achievements.AchievementDataToJson(); } foreach (var condition in new[] { cfg.ConditionType.GetFishWeight, cfg.ConditionType.GetFishID, cfg.ConditionType.GetBoosFish, cfg.ConditionType.GetBiggerThanHugeFishCount }) { GContext.Publish(new ConditionTypeEvent(condition, 2)); } Check(store.Read("NewPlayerDataDic").ValueOrThrow() == expectedMain, "later original events do not invent a final main save"); var modern = JsonConvert.DeserializeObject>(expectedMain); var core = modern["newData"].Split(','); Check(core[1] == (100 + cash).ToString() && core[14] == "14" && core[15] == "112", "original cash and frequency snapshot"); Check(core[3] == "393" && core[9] == "112" && seen.Count(x => x == cfg.ConditionType.GetFishCount) == 1, "reward fragment never consumes energy twice"); Check(modern["ad"] == expectedAchievements && achievements.GetAchievementDatas(cfg.ConditionType.GetFishCount) == 1, "achievement snapshot follows last original save"); Check(modern["ld"].Split('|').Length == 3, "original luck serialization preserved"); Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Any(pair => tables.TbBattlePassTask.GetOrDefault(pair.Key).Type == cfg.ConditionType.PerfectStrikeCount && pair.Value == 1), "actual battle task subscription"); Check(subscribers.Lucky.DicTasks.All(pair => pair.Value == 0) && subscribers.Magic.DicTasks.All(pair => pair.Value == 0) && subscribers.Mini.Data.Exp == 0, "unrelated actual task configuration stays unchanged"); foreach (var condition in rewardConditions.Where(x => x != cfg.ConditionType.ConsumeEnergy)) { Check(seen.Count(x => x == condition) == 1, "original fragment event once: " + condition); } string expectedTask = store.Read("BattlePassRecordTask").ValueOrThrow(); Check(!string.IsNullOrEmpty(expectedTask) && new[] { "MiniBPKeyNew", "EventLuckGameRecord", "EventLuckMagicPlayfabData" }.All(key => store.Read(key).Success && store.Read(key).Found == false), "only applicable original task writes exist"); Check(fixture.Sets == 0 && fixture.Remote["NewPlayerDataDic"] == "original-save", "original reward data uses ordinary save timing"); var flushed = await KVMgr.SyncKVAsync(owner); Check(flushed.TransportError == PlayerKVError.None && flushed.Blocked == 0 && flushed.Unknown == 0 && fixture.Sets == 1, "ordinary reward data save succeeds once"); Check(fixture.Remote["NewPlayerDataDic"] == expectedMain && fixture.Remote["BattlePassRecordTask"] == expectedTask && fixture.Remote["Map_5_102"] == initialMapJson, "exact actual main and task payloads without forced fish write"); var reloaded = new PlayerData(tables, null); Module02OriginalLoadTests.LoadCore(reloaded, JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"])); Check(reloaded.Energy == player.Energy && (ulong)reloaded.gold == (ulong)player.gold && reloaded.equipRodID == player.equipRodID, "original main parser reads last original save"); } Check(fixture.Auth == 1 && !Enabled && !Ready && handle == null, "isolated data methods never enable temporary gameplay"); } catch (Exception error) { errors.Add(error); } finally { Clean(() => subscription?.Dispose()); Clean(() => subscribers?.Dispose()); Clean(() => achievements?.Dispose()); Clean(() => player?.StopRecoveryEnergy()); Clean(() => store?.Dispose()); try { await fixture.Close(); } catch (Exception error) { errors.Add(error); validationCleanupFailure = fixture; } Clean(() => container?.Dispose()); Clean(() => FishingContextAccess.Use(oldContainer)); Clean(() => FishingContextAccess.Events = oldEvents); Clean(() => LocalizationMgr.enumLanguage = oldLanguages); Clean(() => textTable.SetValue(null, oldTextTable)); Clean(() => Check(ReferenceEquals(GContext.container, oldContainer) && ReferenceEquals(FishingContextAccess.Events, oldEvents) && ReferenceEquals(LocalizationMgr.enumLanguage, oldLanguages) && ReferenceEquals(textTable.GetValue(null), oldTextTable), "original fixture globals restored")); Application.logMessageReceived -= Log; if (unexpectedLogs.Count != 0) { errors.Add(new InvalidOperationException(string.Join("\n", unexpectedLogs))); } } if (errors.Count != 0) { Directory.CreateDirectory(folder); File.WriteAllText(Path.Combine(folder, "failure.json"), JsonConvert.SerializeObject(new { scenario, errors = errors.Select(x => x.ToString()), formalRequests = 0 })); throw new AggregateException(errors); } passed.Add(scenario); } } finally { Interlocked.Exchange(ref validationRunning, 0); } string result = JsonConvert.SerializeObject(new { success = true, groups = passed.Count, assertions = checks, passed, coverage = "original-data-fragments-only", formalRequests = 0, synthetic = true, restored = true, folder }); Directory.CreateDirectory(folder); File.WriteAllText(Path.Combine(folder, "result.json"), result); return result; } internal static async Task VerifyFishingRewardForEditorAsync() { if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll().Length != 0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle != null || File.Exists(Path.Combine(Root, "permit.json")) || validationCleanupFailure != null) { throw new InvalidOperationException("Idle editor without a real permit required."); } if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0) { throw new InvalidOperationException("Validation running."); } var saved = (Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction, rewardPlan, rewardAction); var oldContainer = GContext.container; var oldEvents = FishingContextAccess.Events; var folder = Path.Combine(Root, "reward-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N")); EquipmentFixture fixture = null; Task pending = null; Container container = null; PlayerData player = null; var oldLanguages = LocalizationMgr.enumLanguage; var textTable = typeof(LocalizationMgr).GetField("table", BindingFlags.Static | BindingFlags.NonPublic); var oldTextTable = textTable.GetValue(null); KVPlayerSaveStore guideStore = null; int checks = 0, groups = 0; var passed = new List(); string result = null; var ioDiagnostics = new System.Collections.Concurrent.ConcurrentQueue(); EventHandler observeIO = (_, e) => { if (e.Exception is IOException || e.Exception is UnauthorizedAccessException) { ioDiagnostics.Enqueue(e.Exception.ToString()); } }; AppDomain.CurrentDomain.FirstChanceException += observeIO; void Check(bool yes, string reason) { checks++; if (!yes) { throw new InvalidOperationException("Reward validation: " + reason); } } async Task Start(string name) { player?.StopRecoveryEnergy(); player = null; Exception guideDisposeError = null; try { guideStore?.Dispose(); } catch (Exception error) { guideDisposeError = error; } finally { guideStore = null; } if (fixture != null) { await fixture.Close(); } if (pending != null) { try { await pending; } catch { } pending = null; } FishingContextAccess.Use(oldContainer); FishingContextAccess.Events = oldEvents; container?.Dispose(); container = null; if (guideDisposeError != null) { throw guideDisposeError; } fixture = new EquipmentFixture(Path.Combine(folder, (++groups).ToString())); fixture.Remote["Map_5_102"] = "{\"10211\":\"0|10|20|20|False\",\"10241\":\"2|20|30|40|False\"}"; var owner = await fixture.Open(); Enabled = true; Ready = false; Error = null; failed = 0; handle = null; collecting = null; Permit = new PlayerKVLoopPermit { Version = 3, Operation = "fishing-reward-once", PlayerId = "101", AllowedKeys = new[] { "NewPlayerDataDic", "Map_5_102", "BattlePassRecordTask", "late" } }; RunPath = folder; mainThread = Thread.CurrentThread.ManagedThreadId; mainContext = SynchronizationContext.Current; Attach(_ => fixture.Faults++); Publish(owner); fishingAction = new FishingAction(new object (), owner) { Consuming = true, Confirmed = true, Continued = true }; rewardPlan = new FishingRewardPlan { Fish = 10211, Area = 102 }; rewardAction = new FishingRewardAction(fishingAction, new object (), rewardPlan); passed.Add(name); } PlayerKVEditResult Batch(bool localFinal = false) { using (BeginRewardSegment("strike")) Write("BattlePassRecordTask", "strike", true); rewardAction.Ending = true; using (BeginRewardSegment("ending")) { } rewardAction.Applying = true; PlayerKVEditResult primary; using (BeginRewardSegment("reward")) { Write("BattlePassRecordTask", "reward-task", true); Write("Map_5_102", "fish", !localFinal); Write("NewPlayerDataDic", "intermediate", false); primary = Write("NewPlayerDataDic", "reward-main", true); } rewardAction.GuideNotifications = 1; return primary; } async Task Reject(Task task, string label) { bool rejected = false; try { await task; } catch (InvalidOperationException) { rejected = true; } Check(rejected && !Ready && fixture.Faults == 1, label); } void RejectSync(Action body, string label) { bool rejected = false; try { body(); } catch (InvalidOperationException) { rejected = true; } Check(rejected, label); } try { Directory.CreateDirectory(folder); // Exercise the real async Init path, using isolated services and the actual loaded guide asset. var potionSource = JObject.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T082052Z/101.json"))))["data"].ToObject>(); var guideAssetHandle = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync("GuidanceConfig"); var guideAsset = (GuidanceDefineSetting)guideAssetHandle.WaitForCompletion(); var savedGuideGroups = guideAsset.guidanceGroupDefines; var awaitAssembly = typeof(IEnumeratorAwaitExtensions).Assembly; var syncType = awaitAssembly.GetType("UnityAsyncAwaitUtil.SyncContextUtil", true); var syncField = syncType.GetField("k__BackingField", BindingFlags.Static | BindingFlags.NonPublic); var threadField = syncType.GetField("k__BackingField", BindingFlags.Static | BindingFlags.NonPublic); var runnerField = awaitAssembly.GetType("UnityAsyncAwaitUtil.AsyncCoroutineRunner", true).GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic); var savedSync = syncField.GetValue(null); var savedThread = threadField.GetValue(null); var savedRunner = runnerField.GetValue(null); syncField.SetValue(null, SynchronizationContext.Current); threadField.SetValue(null, Thread.CurrentThread.ManagedThreadId); try { foreach (var scenario in new[] { "unavailable-real-101", "available", "query-failure", "node-panel-mismatch", "other-group", "finished", "index-zero", "missing-save", "out-of-range", "preserve-other-pointer" } ) { await Start("potion-actual-init-" + scenario); rewardPlan = null; rewardAction = null; Permit.AllowedKeys = Permit.AllowedKeys.Concat(new[] { "GuideSaveDataNew" }).ToArray(); var tables = new cfg.Tables(n => SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData", n + ".json"))))); container = new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events = new EventAggregator(); player = new PlayerData(tables, null); player.lv = 62; container.RegisterInstance(tables); container.RegisterInstance(player); var eventData = new FishingEventData(tables); if (scenario != "query-failure") { container.RegisterInstance(eventData); } if (scenario == "available") { var ev = tables.TbFishingEvent.DataList.First(e => e.Type == 4 && e.SubType == 5); typeof(cfg.FishingEvent).GetProperty("TimeDefinition").SetValue(ev, new cfg.LimitedTime(ZZTimeHelper.UtcNow().AddHours(-1).ToString("yyyy/MM/dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture), ZZTimeHelper.UtcNow().AddHours(1).ToString("yyyy/MM/dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture))); typeof(cfg.FishingEvent).GetProperty("ConditionList").SetValue(ev, new List { new cfg.EventCondition(cfg.ConditionType.AccountLevel, new List { "1" }, "") }); Check(eventData.GetEvent(4, 5) == ev.ID, "isolated actual query sees available potion event"); } else if (scenario != "query-failure") { Check(eventData.GetEvent(4, 5) == -1, "isolated actual query sees no potion event"); } var guideState = new GuideDataCenter(tables, player); container.RegisterInstance(guideState); Module02OriginalLoadTests.LoadGuide(guideState, potionSource); Check(guideState.GuideSaveDataDic.Count > 0, "real 101 guide source loaded through original parser"); var potionGroup = JsonUtility.FromJson(JsonUtility.ToJson(savedGuideGroups.First(g => g.groupName == GroupName.EventPotion01))); Check(potionGroup.guidanceDefines[4].isKey && potionGroup.guidanceDefines[4].JumpIndex == 4, "original recovery asset boundary: " + scenario); guideAsset.guidanceGroupDefines = savedGuideGroups.Select(g => g.groupName == GroupName.EventPotion01 ? potionGroup : g).ToArray(); var key = GroupName.EventPotion01.ToString(); var entry = guideState.GuideSaveDataDic[key]; Check(entry.guideSubIndex == 4 && !entry.isFinish, "real historical potion progress is unfinished at four"); if (scenario == "node-panel-mismatch") { potionGroup.guidanceDefines[entry.guideSubIndex].panelName = "DifferentPanel"; } if (scenario == "other-group") { guideState.GuideSaveDataDic.Remove(key); key = GroupName.EventPotion02.ToString(); entry.groupName = key; guideState.GuideSaveDataDic[key] = entry; guideAsset.guidanceGroupDefines = guideAsset.guidanceGroupDefines.Where(g => g.groupName != GroupName.EventPotion02).ToArray(); potionGroup.groupName = GroupName.EventPotion02; } if (scenario == "finished") { entry.isFinish = true; } if (scenario == "index-zero") { entry.guideSubIndex = 0; } if (scenario == "missing-save") { guideState.GuideSaveDataDic.Remove(key); } if (scenario == "out-of-range") { entry.guideSubIndex = potionGroup.guidanceDefines.Length; } var otherGroup = savedGuideGroups.First(g => g.groupName == GroupName.Fish_01); if (scenario == "preserve-other-pointer") { guideState.curGroupDefine = otherGroup; guideState.curDefine = otherGroup.guidanceDefines[0]; guideState.curSaveData = guideState.GuideSaveDataDic[otherGroup.groupName.ToString()]; } guideStore = new KVPlayerSaveStore(); guideStore.Bind(handle, CancellationToken.None); var store = guideStore; container.RegisterInstance(store); player.playerSaveStore = store; eventData.playerSaveStore = store; guideState.playerSaveStore = store; var initErrors = new List(); var initWarnings = new List(); int completeEvents = 0; Application.LogCallback onLog = (message, stack, type) => { if (type == LogType.Exception || type == LogType.Error) { initErrors.Add(message); } if (type == LogType.Warning) { initWarnings.Add(message); } }; Application.logMessageReceived += onLog; var completion = GContext.OnEvent().Subscribe(e => { if (e.type == cfg.ConditionType.CompleteGuide) { completeEvents++; } }); bool initStarted = false; try { // Asset is preloaded above: Init has no outstanding asset operation when teardown begins. initStarted = true; guideState.Init(); var deadline = System.Diagnostics.Stopwatch.StartNew(); while (guideState.guidanceGroupDefines == null && deadline.ElapsedMilliseconds < 10000) await Task.Delay(10); await Task.Delay(1); Check(guideState.guidanceGroupDefines != null && initErrors.Count == 0, "actual Init completed without asynchronous errors: " + scenario + "; " + string.Join(" | ", initErrors)); if (scenario == "unavailable-real-101" || scenario == "available" || scenario == "query-failure" || scenario == "node-panel-mismatch" || scenario == "other-group" || scenario == "preserve-other-pointer") { Check(ReferenceEquals(guideState.curGroupDefine, potionGroup) && ReferenceEquals(guideState.curDefine, potionGroup.guidanceDefines[4]) && ReferenceEquals(guideState.curSaveData, entry) && entry.guideSubIndex == 4 && !entry.isFinish, "original Init resumes saved key independently of availability or old pointer: " + scenario); Check(handle.ReadLocalSnapshot().Data.TryGetValue("GuideSaveDataNew", out var savedGuide) && savedGuide.Split(';').Contains(key + "|4|0"), "original Init accepts resumed guide ordinary write: " + scenario); Check(fixture.Sets == 0 && completeEvents == 0, "original recovery neither forces HTTP nor completes guide: " + scenario); } else { Check(guideState.curGroupDefine == null && guideState.curDefine == null, "original non-resuming boundary retained: " + scenario); } if (scenario != "missing-save") { Check(ReferenceEquals(guideState.GuideSaveDataDic[key], entry), "saved progress object retained: " + scenario); } if (scenario == "query-failure") { Check(!initWarnings.Any(w => w.Contains("Potion guide availability")), "original Init does not query potion availability"); } } finally { completion.Dispose(); Application.logMessageReceived -= onLog; guideState.Dispose(); guideAsset.guidanceGroupDefines = savedGuideGroups; if (initStarted) { UnityEngine.AddressableAssets.Addressables.Release(guideAsset); } } } } finally { guideAsset.guidanceGroupDefines = savedGuideGroups; UnityEngine.AddressableAssets.Addressables.Release(guideAssetHandle); var runner = runnerField.GetValue(null) as MonoBehaviour; if (runner != null && !ReferenceEquals(runner, savedRunner)) { UnityEngine.Object.DestroyImmediate(runner.gameObject); } runnerField.SetValue(null, savedRunner); syncField.SetValue(null, savedSync); threadField.SetValue(null, savedThread); } await Start("original-tickets-cross-segment-supersession"); var primary = Batch(); Check(collecting == null && rewardAction.Segment == null && rewardAction.Records.Count == 5, "three scopes disposed and all original writes captured"); _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. await ConfirmRewardAsync(rewardAction, primary); int shows = 0; PresentReward(rewardAction, () => shows++); PresentReward(rewardAction, () => shows++); Check(shows == 1 && fixture.Remote["Map_5_102"] == "fish" && fixture.Remote["BattlePassRecordTask"] == "reward-task", "final values and one presentation"); await Start("pending-cannot-present"); primary = Batch(); fixture.HoldSet = true; _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. pending = ConfirmRewardAsync(rewardAction, primary); await fixture.SetEntered.Task; Check(!rewardAction.Confirmed && !rewardAction.Presented, "pending is not success"); fixture.ReleaseSet.TrySetResult(true); await pending; pending = null; Check(rewardAction.Confirmed, "response confirms original action"); await Start("localOnly-map-final-rejected"); await Reject(ConfirmRewardAsync(rewardAction, Batch(true)), "local map not remote confirmed"); await Start("outside-segment-before-final-save-rejected"); using (BeginRewardSegment("strike")) Write("BattlePassRecordTask", "strike", true); RejectSync(() => Write("NewPlayerDataDic", "external", true), "external changed write denied before later reward can hide it"); Check(fixture.Sets == 0 && ReadRejected(), "stopped facade and no outgoing request"); await Start("same-value-external-supersession-still-rejected"); primary = Batch(); Write("NewPlayerDataDic", "reward-main", true); _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. await Reject(ConfirmRewardAsync(rewardAction, primary), "same-value external ticket is not the original final ticket"); await Start("unexpected-backed-up-key-before-admission"); using (BeginRewardSegment("strike")) RejectSync(() => Write("late", "unrelated", true), "backed-up but unrelated reward key rejected"); Check(rewardAction.Records.Count == 0 && fixture.Sets == 0, "no unrelated admission"); await Start("lost-response-no-reward-replay"); primary = Batch(); fixture.LoseResponse = true; _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. await Reject(ConfirmRewardAsync(rewardAction, primary), "unknown result stops reward"); Check(fixture.Sets == 1 && !rewardAction.Presented, "single request only"); await Start("unauthorized-no-reauthentication"); primary = Batch(); fixture.Unauthorized = true; _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. await Reject(ConfirmRewardAsync(rewardAction, primary), "401 stops reward"); Check(fixture.Auth == 1 && fixture.Sets == 1, "no relogin or replay"); await Start("partial-commit-is-not-success"); primary = Batch(); fixture.PartialCommit = true; fixture.LoseResponse = true; _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. await Reject(ConfirmRewardAsync(rewardAction, primary), "partial result remains failure"); Check(fixture.Sets == 1 && !rewardAction.Confirmed, "no refund or second write"); await Start("cancel-after-consume-before-reward-confirmed"); primary = Batch(); fixture.HoldSet = true; _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. pending = ConfirmRewardAsync(rewardAction, primary); await fixture.SetEntered.Task; CancelFishing(fishingAction, "actual consumed action cancellation"); fixture.ReleaseSet.TrySetResult(true); await Reject(pending, "C1 Continued cannot exempt pending reward"); pending = null; Check(rewardAction.Cancelled && !rewardAction.Presented, "old callbacks revoked"); await Start("confirmed-normal-cancel-no-second-exit"); primary = Batch(); _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. await ConfirmRewardAsync(rewardAction, primary); CancelReward(rewardAction.Controller, "normal disable"); CancelFishing(fishingAction, "normal actor destroy"); Check(rewardAction.Cancelled && fishingAction.Cancelled && fixture.Faults == 0 && Ready, "confirmed normal disable never faults"); await Start("battle-controller-disable-still-cancels"); CancelReward(rewardAction.Controller, "controller disabled"); Check(rewardAction.Cancelled && !Ready && fixture.Faults == 1, "battle controller disable still cancels"); await Start("ending-teardown-pre-confirm-not-cancel"); primary = Batch(); CancelReward(rewardAction.Controller, "controller disabled"); CancelReward(rewardAction.Controller, "controller destroyed"); Check(!rewardAction.Cancelled && Ready && fixture.Faults == 0, "ending teardown window is not a cancellation"); _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. await ConfirmRewardAsync(rewardAction, primary); shows = 0; PresentReward(rewardAction, () => shows++); Check(shows == 1 && rewardAction.Confirmed && rewardAction.Presented, "reward completes and presents after teardown window"); await Start("repeated-segment-and-sealed-action-rejected"); using (BeginRewardSegment("strike")) { } RejectSync(() => BeginRewardSegment("strike"), "strike cannot repeat"); await Start("reward-storage-error-before-send"); var file = Directory.GetFiles(Path.Combine(folder, groups.ToString()), "state.json", SearchOption.AllDirectories).Single(); using (var locked = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.None)) using (BeginRewardSegment("strike")) RejectSync(() => Write("NewPlayerDataDic", "locked", true), "real state-file storage failure"); Check(fixture.Sets == 0 && !Ready, "storage fault before network"); await Start("actual-reentry-and-old-permit-gates"); Check(!BeginRewardDrawing(rewardAction.Controller), "duplicate drawing never reaches original battle"); rewardAction.Ending = true; Check(!BeginRewardEnding(rewardAction.Controller, true), "duplicate fish end never opens another panel"); rewardAction.Applying = true; Check(BindRewardPanel(new object ()) == null, "second panel never reapplies rewards"); Permit.Version = 2; Permit.Operation = "fishing-consume-once"; RejectSync(() => BeginRewardDrawing(new object ()), "v2 cannot draw"); await Start("v1-cannot-draw"); Permit.Version = 1; Permit.Operation = null; RejectSync(() => BeginRewardDrawing(new object ()), "v1 cannot draw"); await Start("natural-failure-stops-without-reward"); RejectSync(() => BeginRewardEnding(rewardAction.Controller, false), "natural failure not converted to catch"); Check(fixture.Sets == 0 && rewardAction.Records.Count == 0 && !rewardAction.Applying, "failure has no reward or refund"); await Start("real-backup-no-special-fish-guide-preflight"); container = new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events = new EventAggregator(); var guide = new GuideDataCenter(null, null); container.RegisterInstance(guide); var backup = JsonConvert.DeserializeObject>(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T084836Z/102.json")))); var raw = JsonConvert.DeserializeObject>(backup["data"].ToString()); Module02OriginalLoadTests.LoadGuide(guide, raw); guide.guidanceGroupDefines = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset").guidanceGroupDefines; Check(guide.GuideSaveDataDic.Count > 0, "real guide backup loads through original parser"); var gateTables = new cfg.Tables(n => SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData", n + ".json"))))); container.RegisterInstance(gateTables); rewardPlan.Data = new FishingData { curFishData = gateTables.TbFishData.GetOrDefault(10211) }; var guideBefore = JsonConvert.SerializeObject(guide.GuideSaveDataDic); CheckRewardGuide(); Check(Ready && fixture.Sets == 0 && guideBefore == JsonConvert.SerializeObject(guide.GuideSaveDataDic), "ordinary fish admitted without guide writes or consumption"); var originalGroups = guide.guidanceGroupDefines; foreach (var kind in new[] { "ordinary-incomplete", "active-node", "active-group", "last-node", "extra-node", "missing-node", "trigger", "parameter", "unknown-group", "positive-skill", "last-skill", "escape-skill", "active-skill", "active-dash", "active-dirty" } ) { // Read-only fault probes share an isolated fixture; no real run is reset. var guideCopy = JsonUtility.FromJson(JsonUtility.ToJson(originalGroups.First(g => g.groupName.ToString() == "FishSkill01"))); var baseFish = gateTables.TbFishData.GetOrDefault(10211); var fishJson = SimpleJSON.JSON.Parse(baseFish.json.ToString()); var testFish = new cfg.FishData(fishJson); var testData = new FishingData { curFishData = testFish }; guide.guidanceGroupDefines = originalGroups.Select(g => g.groupName == guideCopy.groupName ? guideCopy : g).ToArray(); guide.curDefine = null; guide.curGroupDefine = null; guide.GuideSaveDataDic["Fish_01"].isFinish = true; switch (kind) { case "ordinary-incomplete": guide.GuideSaveDataDic["Fish_01"].isFinish = false; break; case "active-node": guide.curDefine = guideCopy.guidanceDefines[0]; break; case "active-group": guide.curGroupDefine = guideCopy; break; case "last-node": guideCopy.guidanceDefines.Last().panelName = "FishingRewardPanel"; break; case "extra-node": guideCopy.guidanceDefines = guideCopy.guidanceDefines.Concat(new[] { new GuidanceDefine { panelName = "FishingPanel_Advanced" } }).ToArray(); break; case "missing-node": guideCopy.guidanceDefines = Array.Empty(); break; case "trigger": guideCopy.TriggerConditionsType = GuideTriggerConditions.Next; break; case "parameter": guideCopy.TriggerConditionsParameter = "changed"; break; case "unknown-group": guideCopy.groupName = (GroupName)999999; break; case "positive-skill": case "last-skill": case "escape-skill": var skillJson = SimpleJSON.JSON.Parse(baseFish.FishEscape.json.ToString()); skillJson["SpSkillID"] = 123456789; if (kind == "escape-skill") { fishJson["FishEscape"] = skillJson; } else { var list = new SimpleJSON.JSONArray(); if (kind == "last-skill") { list.Add(SimpleJSON.JSON.Parse(baseFish.FishEscape.json.ToString())); } list.Add(skillJson); fishJson["FishSKillList"] = list; } testData.curFishData = new cfg.FishData(fishJson); break; case "active-skill": testData.fishSpSkillData = new CorruptSpSkillData(); break; case "active-dash": testData.superDashSpSkillData = new SuperDashSpSkillData(); break; case "active-dirty": testData.dirtyStuckSpSkillData = new DirtyStuckSpSkillData(); break; } // Stop() is idempotent; all cases are read-only gate probes, not fresh consumption attempts. RejectSync(() => CheckRewardGuide(testData), "guide/skill drift denied: " + kind); Check(fixture.Sets == 0, "gate drift never writes: " + kind); } guide.guidanceGroupDefines = originalGroups; guide.curDefine = null; guide.curGroupDefine = null; guide.GuideSaveDataDic["Fish_01"].isFinish = true; foreach (var scenario in new[] { "ordinary", "zero-cruise", "cruise", "invalid-cruise", "free", "retry", "fixed", "duel", "special-item", "missing-map", "wrong-area", "event-field", "event-count", "trigger-pack", "activity-drift", "guide-before-S1", "guide-before-S2", "guide-before-S3" } ) { await Start("actual-full-reward-preflight-" + scenario); var tables = new cfg.Tables(n => SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData", n + ".json"))))); container = new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events = new EventAggregator(); container.RegisterInstance(tables); player = new PlayerData(tables, null); var fish = new PlayerFishData(tables, null); var events = new FishingEventData(tables); var guideState = new GuideDataCenter(tables, player); Module02OriginalLoadTests.LoadGuide(guideState, raw); guideState.guidanceGroupDefines = originalGroups; guideState.guidanceDefineSetting = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset"); var scopes = new Activity.ActivityScopeManager(); var manager = new Activity.ActivityManager(tables, scopes); container.RegisterInstance(player); container.RegisterInstance(new SmallGameData(tables, null, null)); container.RegisterInstance(fish); container.RegisterInstance(events); container.RegisterInstance(guideState); container.RegisterInstance(new CollectingData()); container.RegisterInstance(new PiggyBankPackData()); container.RegisterInstance(new BargainPackData()); container.RegisterInstance(new FishingChallengeManager()); container.RegisterInstance(scopes); container.RegisterInstance(manager); Module02OriginalLoadTests.LoadCore(player, new object[] { 60, 100UL, 0, 393, 5, 4, 30003, new DateTime(2026, 1, 1), 0, 112, 7UL, 2, 102, 102, 13, 102, 0, 0, 0 }); player.IsOpenMagnification = true; fish.IsOpenPiercing = true; var data = new FishingData { curFishData = tables.TbFishData.GetOrDefault(10211), fishItem = tables.TbItem.DataList.First(x => x.RedirectID == 10211 && x.SubType == 1), RodId = 30003, MapId = 102 }; rewardPlan = null; rewardAction = null; switch (scenario) { case "zero-cruise": case "cruise": case "invalid-cruise": typeof(PlayerFishData).GetField("fishSwimToMap", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(fish, new Dictionary { { 102, scenario == "zero-cruise" ? 0 : scenario == "cruise" ? 10211 : -2 } }); break; case "free": data.IsFree = true; break; case "retry": data.IsRetry = true; break; case "duel": data.IsDuel = true; break; case "fixed": data.fixedFishList = tables.TbFixedFishList.DataList.First(); break; case "special-item": data.fishItem = tables.TbItem.DataList.First(x => x.SubType == 11); break; case "missing-map": Permit.AllowedKeys = new[] { "NewPlayerDataDic" }; break; case "wrong-area": var fj = SimpleJSON.JSON.Parse(data.curFishData.json.ToString()); fj["AreaID"] = 999999; data.curFishData = new cfg.FishData(fj); break; case "event-field": var ev = tables.TbFishingEvent.DataList.First(x => x.ID == 10100000); typeof(cfg.FishingEvent).GetProperty("PopupPriority").SetValue(ev, ev.PopupPriority + 1); break; case "event-count": tables.TbFishingEvent.DataList.Remove(tables.TbFishingEvent.DataList.First(x => x.ID == 10100000)); break; case "trigger-pack": typeof(cfg.TriggerPackManager).GetProperty("Condition").SetValue(tables.TbTriggerPackManager.DataList.First(), cfg.ConditionType.GetFishCount); break; } if (scenario == "ordinary" || scenario == "zero-cruise" || scenario == "activity-drift" || scenario.StartsWith("guide-before-")) { PrepareFishingReward(fishingAction, data); Check(Ready && rewardPlan.Data == data && fixture.Sets == 0, "actual full preflight admitted before any consumption"); if (scenario == "activity-drift") { scopes.CreateActivityScope((1, 1)); RejectSync(CheckRewardContext, "actual activity scope drift denied"); } else if (scenario.StartsWith("guide-before-")) { var actor = new object (); if (scenario != "guide-before-S1") { Check(BeginRewardDrawing(actor), "first drawing"); using (BeginRewardSegment("strike")) { } } if (scenario == "guide-before-S3") { Check(BeginRewardEnding(actor, true), "first ending"); using (BeginRewardSegment("ending")) { } } guideState.curDefine = originalGroups.First().guidanceDefines.First(); if (scenario == "guide-before-S1") { RejectSync(() => BeginRewardDrawing(actor), "active guide before drawing"); } else if (scenario == "guide-before-S2") { RejectSync(() => BeginRewardEnding(actor, true), "active guide before ending"); } else { RejectSync(() => BindRewardPanel(new object ()), "active guide before reward data"); } } else { var actor = new object (); Check(BeginRewardDrawing(actor), "actual prepare-draw"); using (BeginRewardSegment("strike")) { } Check(BeginRewardEnding(actor, true), "actual ending"); using (BeginRewardSegment("ending")) { } Check(BindRewardPanel(new object ()) != null, "actual panel binding with before count"); using (GContext.OnEvent().Subscribe(guideState.TriggerGuideNext)) using (BeginRewardSegment("reward")) { GContext.Publish(new OnEventTriggerGuide("FishingRewardPanel")); RecordRewardGuideNotification(); } Check(rewardAction.GuideNotifications == 1 && guideState.curDefine == null && guideState.curGroupDefine == null, "one original guide notification and no activated guide"); } } else { RejectSync(() => PrepareFishingReward(fishingAction, data), "full gate rejects " + scenario); } Check(fixture.Sets == 0, "preflight case no network or consumed energy: " + scenario); } await Start("reward-confirmation-timeout-no-replay"); primary = Batch(); fixture.HoldSet = true; _ = KVMgr.SyncKVAsync(rewardAction.Consumption.Owner); // One simulated ordinary timer tick. pending = ConfirmRewardAsync(rewardAction, primary); await fixture.SetEntered.Task; await Reject(pending, "actual 30-second timeout"); pending = null; fixture.ReleaseSet.TrySetResult(true); Check(fixture.Sets == 1 && !rewardAction.Confirmed && !rewardAction.Presented, "timeout never repeats reward"); result = JsonConvert.SerializeObject(new { success = true, groups, assertions = checks, passed, formalRequests = 0, synthetic = true, folder }); } finally { try { LocalizationMgr.enumLanguage = oldLanguages; textTable.SetValue(null, oldTextTable); player?.StopRecoveryEnergy(); Exception guideDisposeError = null; try { guideStore?.Dispose(); } catch (Exception error) { guideDisposeError = error; } finally { guideStore = null; } if (fixture != null) { await fixture.Close(); } if (pending != null) { try { await pending; } catch { } } FishingContextAccess.Use(oldContainer); FishingContextAccess.Events = oldEvents; container?.Dispose(); (Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction, rewardPlan, rewardAction) = saved; if (guideDisposeError != null) { throw guideDisposeError; } } catch { validationCleanupFailure = fixture; throw; } finally { AppDomain.CurrentDomain.FirstChanceException -= observeIO; if (Directory.Exists(folder)) { File.WriteAllText(Path.Combine(folder, "storage-diagnostics.json"), JsonConvert.SerializeObject(ioDiagnostics.ToArray())); } Interlocked.Exchange(ref validationRunning, 0); } } File.WriteAllText(Path.Combine(folder, "result.json"), result); return result; } sealed class RewardSubscribers:IDisposable { const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic; static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic); private readonly object oldRed = Red.GetValue(null); private LuckyTaskData magicTask; private MiniBattlePassDataProvider provider; internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky; internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini; internal void Bind(cfg.Tables tables, FishingEventData events, Container container, IPlayerSaveStore store) { Red.SetValue(null,new RedPointManager()); container.RegisterInstance(store); container.Resolve().playerSaveStore=store; container.Resolve().playerSaveStore=store; container.Resolve().playerSaveStore=store; events.playerSaveStore=store; var items=new PlayerItemData(tables);container.RegisterInstance(items); var data=new BattlePassData(tables){playerSaveStore=store}; data.BattlePassRecord.ID=tables.TbFishingEvent.DataList.First(e=>tables.TbBattlePassMain.GetOrDefault(e.RedirectID)!=null).ID; data.BattlePassRecord.Level=1; foreach(var t in tables.TbBattlePassTask.DataList.Where(t=>rewardConditions.Contains(t.Type)))data.BattleTask.dic_dailyTasks.Add(t.TaskID,0); Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle); var common=tables.TbCommonTask.DataList.Where(t=>rewardConditions.Contains(t.TaskType)).Select(t=>t.Id).ToArray(); Lucky=new EventLuckyGameModel(events,tables);foreach(var id in common)Lucky.DicTasks.Add(id,0); Magic=new EventLuckMagicModel{DicTasks=common.ToDictionary(x=>x,x=>0),HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()}; var rings=(List)typeof(EventLuckMagicModel).GetField("_rings",Fields).GetValue(Magic); for(int i=1;i<=3;i++)rings.Add(EventLuckMagicRing.CreateEmpty(1,0,(EEventLuckMagicRingType)i)); magicTask=new LuckyTaskData(Magic);typeof(EventLuckMagicModel).GetField("_taskData",Fields).SetValue(Magic,magicTask); provider=new MiniBattlePassDataProvider(tables,items); var table=tables.TbMiniBattlePass.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy && t.Param==0); var activity=tables.TbFishingEvent.DataList.First(t=>t.Type==9 && t.RedirectID==table.Id); var type=(MiniBattlePassType)table.Type; Mini=provider.GetModel(type); provider.GetMiniBattlePassRecord("BP_"+type).Id=activity.ID; typeof(MiniBattlePassDataModel).GetField("fishingEvent",Fields).SetValue(Mini,activity); typeof(MiniBattlePassDataModel).GetMethod("InitData",Fields).Invoke(Mini,new object[]{table.Id}); // Synthetic fixture has an active window; real-run time is never modified. Mini.startTime=ZZTimeHelper.UtcNow().AddDays(-1); typeof(MiniBattlePassDataModel).GetProperty("endTime").SetValue(Mini,ZZTimeHelper.UtcNow().AddDays(1)); } public void Dispose() { var errors = new List(); void Release(Action action) { try { action(); } catch (Exception error) { errors.Add(error); } } Release(() => Battle?.Dispose()); Release(() => Lucky?.Dispose()); Release(() => magicTask?.Dispose()); Release(() => { if (Mini != null) { (typeof(MiniBattlePassDataModel).GetField("disposables", Fields).GetValue(Mini) as IDisposable)?.Dispose(); } }); Release(() => Red.SetValue(null, oldRed)); Release(() => { if (!ReferenceEquals(Red.GetValue(null), oldRed)) { throw new InvalidOperationException("Subscriber fixture statics did not restore."); } }); if (errors.Count != 0) { throw new AggregateException(errors); } } } static bool ReadRejected() {try{Read("NewPlayerDataDic");return false;}catch(InvalidOperationException){return true;}} } } #endif