#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using asap.core; using GameCore; using Newtonsoft.Json; using UniRx; using UnityEngine; namespace game.PlayerDataStorage { internal static partial class PlayerKVGameplayLoop { // Observes the original async-void method; never enables the temporary gameplay path. internal static async Task VerifyOriginalFishingForEditorAsync() { 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 fields = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; var textTable = typeof(LocalizationMgr).GetField("table", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); var oldTextTable = textTable.GetValue(null); var folder = Path.Combine(Root, "fishing-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 fishing: " + message); } } try { foreach (var scenario in new[] { "ordinary", "retry", "pack-21", "pack-22", "unauthorized", "held-recovery", "below-limit", "subscribers", "cross-limit-boundary" }) { var errors = new List(); var fixture = new EquipmentFixture(Path.Combine(folder, scenario)); KVPlayerSaveStore store = null; Container container = null; PlayerData player = null; AchievementDataManager achievements = null; FishingSubscribers subscribers = null; GameObject shell = null; IDisposable consumeSubscription = null; IDisposable playerSubscription = null; bool invoked = false; int completionLogs = 0; int saveFailureLogs = 0; var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var unexpectedLogs = new List(); void Log(string condition, string stack, LogType type) { const string prefix = "[FishBehaviourB::ConsumeBaitOnPlayfab] "; if (condition == prefix + "True" || condition == prefix + "False") { completionLogs++; completed.TrySetResult(condition == prefix + "True"); } if (scenario == "unauthorized" && type == LogType.Error && condition == "[KVMgr::SyncDataImmidiate] Save failed.") { saveFailureLogs++; } else if (type == LogType.Exception || type == LogType.Error || type == LogType.Assert) { unexpectedLogs.Add(condition + "\n" + stack); } } void Clean(Action action) { try { action(); } catch (Exception ex) { errors.Add(ex); validationCleanupFailure = fixture; } } void ObserveRecovery(Action startRecovery) { var awaitAssembly = typeof(IEnumeratorAwaitExtensions).Assembly; var staticFields = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic; var allFields = fields | System.Reflection.BindingFlags.Public; var syncField = awaitAssembly.GetType("UnityAsyncAwaitUtil.SyncContextUtil", true).GetField("k__BackingField", staticFields); var runnerField = awaitAssembly.GetType("UnityAsyncAwaitUtil.AsyncCoroutineRunner", true).GetField("_instance", staticFields); var savedSync = syncField.GetValue(null); var savedRunner = runnerField.GetValue(null); var savedCurrent = SynchronizationContext.Current; var observer = new OriginalFishingTestContext(); var scheduler = new OriginalFishingTestContext(); System.Collections.IEnumerator instructionRun = null; IEnumeratorAwaitExtensions.SimpleCoroutineAwaiter recoveryAwaiter = null; bool instructionYielded = false; bool instructionEnded = false; try { SynchronizationContext.SetSynchronizationContext(observer); syncField.SetValue(null, scheduler); startRecovery(); Check(scheduler.Count == 1 && observer.Started == 1 && observer.Completed == 0, "original recovery suspended at one instruction"); var posted = scheduler.Peek(); Check(posted.State == null && posted.Callback.Target.GetType().FullName == "IEnumeratorAwaitExtensions+<>c__DisplayClass12_0", "exact original scheduler dispatch"); var action = (Action)posted.Callback.Target.GetType().GetField("action", allFields).GetValue(posted.Callback.Target); Check(action.Target.GetType().FullName == "IEnumeratorAwaitExtensions+<>c__DisplayClass10_0" && action.Method.Name == "b__0", "exact original void wait closure"); recoveryAwaiter = (IEnumeratorAwaitExtensions.SimpleCoroutineAwaiter)action.Target.GetType().GetField("awaiter", allFields).GetValue(action.Target); var instruction = action.Target.GetType().GetField("instruction", allFields).GetValue(action.Target); Check(instruction is WaitForSeconds && !recoveryAwaiter.IsCompleted, "actual incomplete seconds awaiter"); var wrapper = typeof(IEnumeratorAwaitExtensions).GetNestedType("InstructionWrappers", System.Reflection.BindingFlags.NonPublic); instructionRun = (System.Collections.IEnumerator)wrapper.GetMethod("ReturnVoid", staticFields | System.Reflection.BindingFlags.Public).Invoke(null, new[] { (object)recoveryAwaiter, instruction }); scheduler.Take(); instructionYielded = instructionRun.MoveNext(); Check(instructionYielded && ReferenceEquals(instructionRun.Current, instruction), "library iterator yielded original instruction"); player.StopRecoveryEnergy(); SynchronizationContext.SetSynchronizationContext(scheduler); instructionEnded = !instructionRun.MoveNext(); Check(instructionEnded && recoveryAwaiter.IsCompleted && observer.Completed == 1, "manual-await-boundary exits original async void"); } finally { Clean(() => player.StopRecoveryEnergy()); Clean(() => { SynchronizationContext.SetSynchronizationContext(scheduler); if (instructionRun != null && !instructionEnded) { if (!instructionYielded) { instructionYielded = instructionRun.MoveNext(); } instructionEnded = !instructionRun.MoveNext(); } }); Clean(() => { SynchronizationContext.SetSynchronizationContext(observer); while (observer.Count != 0) { var posted = observer.Take(); Clean(() => posted.Callback(posted.State)); } }); Clean(() => Check(observer.Started == 1 && observer.Completed == 1 && observer.Count == 0 && scheduler.Count == 0 && instructionEnded && recoveryAwaiter != null && recoveryAwaiter.IsCompleted, "recovery state machine and queues drained")); Clean(() => Check(ReferenceEquals(runnerField.GetValue(null), savedRunner), "no coroutine runner created or replaced")); Clean(() => syncField.SetValue(null, savedSync)); Clean(() => SynchronizationContext.SetSynchronizationContext(savedCurrent)); } } Application.logMessageReceived += Log; try { 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"))))); Check(tables.TbEnergyDef.DataList.Count > 4 && tables.TbEnergyDef.DataList[4].Mag == 10 && tables.TbEnergyDef.DataList[4].RetryCost == 5, "distinct real normal/retry costs"); container = new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events = new EventAggregator(); container.RegisterInstance(tables); container.RegisterInstance(store); player = new PlayerData(tables, null); var small = new SmallGameData(tables, null, null); var fish = new PlayerFishData(tables, null); achievements = new AchievementDataManager(tables); var events = new FishingEventData(tables); container.RegisterInstance(player); container.RegisterInstance(small); container.RegisterInstance(fish); container.RegisterInstance(achievements); container.RegisterInstance(new FishingBoxData()); container.RegisterInstance(events); container.RegisterInstance(new FishingNotifications()); 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); int initialEnergy = scenario == "cross-limit-boundary" ? 300 : scenario == "held-recovery" || scenario == "below-limit" ? 250 : 400; int rod = tables.TbRodData.DataMap.Keys.First(); int map = tables.TbMapData.DataMap.Keys.First(); Module02OriginalLoadTests.LoadCore(player, new object[] { 1, 10UL, 0, initialEnergy, 5, 4, rod, new DateTime(2026, 1, 1), 0, 7, 10UL, 2, map, map, 0, 0, 0, 0, 0 }); typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player, 300); typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player, 1); if (scenario == "subscribers") { subscribers = new FishingSubscribers(); subscribers.Bind(tables, events, container); Check(!tables.TbFishingEvent.DataList.Any(e => e.ConditionList.Any(c => c.Condition == cfg.ConditionType.ConsumeEnergy)) && !tables.TbTriggerPackManager.DataList.Any(t => t.Condition == cfg.ConditionType.ConsumeEnergy), "real configuration excludes additional async event and pack activation"); } player.SavePlayerData(false); int subtype = scenario == "pack-21" ? 21 : scenario == "pack-22" ? 22 : 1; var item = tables.TbItem.DataList.FirstOrDefault(x => x.SubType == subtype); Check(item != null, "real item subtype " + subtype); var data = new FishingData { fishItem = item, IsRetry = scenario == "retry" }; int cost = scenario == "retry" ? 5 : 10; int energyRM = player.GetEnergyRM(); var expected = new Dictionary(player.PlayerDataDic); expected["energy"] = (initialEnergy - cost).ToString(); string expectedJson = JsonConvert.SerializeObject(expected); var order = new List(); int consumed = 0; playerSubscription = GContext.OnEvent().Subscribe(_ => order.Add("player")); consumeSubscription = GContext.OnEvent().Subscribe(e => { if (e.type == cfg.ConditionType.ConsumeEnergy) { Check(player.Energy == initialEnergy - cost, "event observes deducted energy"); order.Add("consume"); consumed += e.count; store.Write("k", "event-" + e.count).RequireAccepted(); } }); if (scenario == "cross-limit-boundary") { ObserveRecovery(() => player.ReduceEnergy(cost, false)); Check(player.Energy == 290 && player.GetEnergyRM() == energyRM + cost, "original crossing deducts once"); Check(order.SequenceEqual(new[] { "player", "player" }) && consumed == 0, "crossing publishes twice without inventing consume event"); var crossingData = new Dictionary(player.PlayerDataDic); Check(crossingData["newData"].Split(',')[3] == "290" && DateTime.Parse(crossingData["newData"].Split(',')[7]) > new DateTime(2026, 1, 1) && !crossingData.ContainsKey("energy"), "original crossing save has updated time and energy"); Check(fixture.Sets == 0 && fixture.Remote["NewPlayerDataDic"] == "original-save", "crossing uses ordinary save without immediate network"); var flushed = await KVMgr.SyncKVAsync(owner); Check(flushed.TransportError == PlayerKVError.None && flushed.Blocked == 0 && flushed.Unknown == 0 && fixture.Sets == 1 && fixture.Auth == 1, "crossing ordinary save succeeds once"); Check(fixture.Remote["NewPlayerDataDic"] == JsonConvert.SerializeObject(crossingData), "crossing exact original serialized snapshot"); var reloaded = new PlayerData(tables, null); Module02OriginalLoadTests.LoadCore(reloaded, JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"])); Check(reloaded.Energy == player.Energy && reloaded.equipRodID == player.equipRodID && (ulong)reloaded.gold == (ulong)player.gold, "crossing original parser retains energy rod and gold"); } else { shell = new GameObject("KV original consumption fixed fixture"); shell.SetActive(false); shell.hideFlags = HideFlags.HideAndDontSave; var behavior = shell.AddComponent(); typeof(FishingBehaviorB).GetField("_tables", fields).SetValue(behavior, tables); typeof(FishingBehaviorB).GetField("fishingData", fields).SetValue(behavior, data); typeof(FishingBehaviorB).GetField("saveStore", fields).SetValue(behavior, store); Dictionary subscriberWrites = null; fixture.Unauthorized = scenario == "unauthorized"; fixture.HoldSet = scenario == "held-recovery"; invoked = true; typeof(FishingBehaviorB).GetMethod("ConsumeBaitOnPlayfab", fields).Invoke(behavior, null); Check(player.Energy == initialEnergy - cost && player.GetEnergyRM() == energyRM + cost, "one original local deduction"); Check(consumed == (subtype == 1 ? cost : 0), "original pack exclusion"); Check(order.SequenceEqual(subtype == 1 ? new[] { "player", "consume" } : new[] { "player" }), "publish before event"); if (subscribers != null) { Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single() == cost && subscribers.Lucky.DicTasks.Values.Single() == cost && subscribers.Magic.DicTasks.Values.Single() == cost && subscribers.Mini.Data.Exp == cost, "four actual subscribers process one original consume event"); Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy) == (ulong)cost, "actual achievement receives original event"); subscriberWrites = new[] { "BattlePassRecordTask", "MiniBPKeyNew", "EventLuckGameRecord", "EventLuckMagicPlayfabData" }.ToDictionary(key => key, key => store.Read(key).Value); Check(subscriberWrites.Values.All(value => !string.IsNullOrEmpty(value)) && subscriberWrites.Keys.All(key => !fixture.Remote.ContainsKey(key)), "actual task payloads wait for ordinary save timing"); } if (fixture.HoldSet) { Check(await Task.WhenAny(fixture.SetEntered.Task, Task.Delay(5000)) == fixture.SetEntered.Task, "held request entered"); Check(!completed.Task.IsCompleted && completionLogs == 0, "async void returned while actual request pending"); ObserveRecovery(() => player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed * 60)); Check(player.Energy == initialEnergy - cost + 1, "real recovery proceeds during request"); fixture.ReleaseSet.TrySetResult(true); } Check(await Task.WhenAny(completed.Task, Task.Delay(5000)) == completed.Task, "actual completion log observed"); Check(await completed.Task == !fixture.Unauthorized && completionLogs == 1, "original success/false result"); Check(saveFailureLogs == (fixture.Unauthorized ? 1 : 0), "expected 401 save error is observed exactly once"); Check(fixture.Sets == 1 && fixture.Auth == 1, "one immediate attempt and no reauthentication"); Check(fixture.Remote["k"] == "original", "ordinary event key not sent by immediate write"); if (fixture.Unauthorized) { Check(fixture.Remote["NewPlayerDataDic"] == "original-save", "401 did not commit snapshot"); } else { Check(fixture.Remote["NewPlayerDataDic"] == expectedJson, "original exact pre-event snapshot plus energy"); Check(expected["newData"].Split(',')[3] == initialEnergy.ToString(), "copied newData retains original value"); if (subscribers != null) { var originalSnapshot = new PlayerData(tables, null); Module02OriginalLoadTests.LoadCore(originalSnapshot, JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"])); Check(originalSnapshot.Energy == initialEnergy && originalSnapshot.equipRodID == player.equipRodID && (ulong)originalSnapshot.gold == (ulong)player.gold, "original parser reads pre-event snapshot values without canonical rewrite"); } var flush = await KVMgr.SyncKVAsync(owner); Check(flush.TransportError == PlayerKVError.None && flush.Blocked == 0 && flush.Unknown == 0 && fixture.Remote["k"] == (subtype == 1 ? "event-" + cost : "original"), "subsequent ordinary event write"); if (subscriberWrites != null) { Check(subscriberWrites.All(pair => fixture.Remote.TryGetValue(pair.Key, out var value) && value == pair.Value), "ordinary tick persists exact actual task payloads"); Check(fixture.Remote["NewPlayerDataDic"] == expectedJson, "task ordinary save does not rewrite original immediate snapshot"); } if (fixture.HoldSet) { var recovered = JsonConvert.DeserializeObject>(fixture.Remote["NewPlayerDataDic"]); Check(recovered["newData"].Split(',')[3] == (initialEnergy - cost + 1).ToString() && !recovered.ContainsKey("energy"), "later recovery saved separately"); } } } Check(!Enabled && !Ready && handle == null, "no temporary gameplay activation"); } catch (Exception ex) { errors.Add(ex); } finally { fixture.ReleaseSet.TrySetResult(true); if (invoked && await Task.WhenAny(completed.Task, Task.Delay(5000)) != completed.Task) { errors.Add(new InvalidOperationException("Original async void did not drain: " + scenario)); validationCleanupFailure = fixture; } Clean(() => player?.StopRecoveryEnergy()); Clean(() => consumeSubscription?.Dispose()); Clean(() => playerSubscription?.Dispose()); Clean(() => subscribers?.Dispose()); Clean(() => achievements?.Dispose()); Clean(() => store?.Dispose()); try { await fixture.Close(); } catch (Exception ex) { errors.Add(ex); validationCleanupFailure = fixture; } Clean(() => { if (shell != null) { UnityEngine.Object.DestroyImmediate(shell); } }); Clean(() => container?.Dispose()); FishingContextAccess.Use(oldContainer); FishingContextAccess.Events = oldEvents; 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 { FishingContextAccess.Use(oldContainer); FishingContextAccess.Events = oldEvents; LocalizationMgr.enumLanguage = oldLanguages; try { textTable.SetValue(null, oldTextTable); } finally { Interlocked.Exchange(ref validationRunning, 0); } } string result = JsonConvert.SerializeObject(new { success = true, groups = passed.Count, assertions = checks, passed, recoveryTiming = "manual-await-boundary", formalRequests = 0, synthetic = true, restored = true, folder }); Directory.CreateDirectory(folder); File.WriteAllText(Path.Combine(folder, "result.json"), result); return result; } private sealed class OriginalFishingTestContext : SynchronizationContext { private readonly Queue<(SendOrPostCallback Callback, object State)> posts = new Queue<(SendOrPostCallback, object)>(); private int started; private int completed; internal int Count => posts.Count; internal int Started => started; internal int Completed => completed; public override void Post(SendOrPostCallback callback, object state) { posts.Enqueue((callback, state)); } public override void OperationStarted() { started++; } public override void OperationCompleted() { completed++; } internal (SendOrPostCallback Callback, object State) Peek() { return posts.Peek(); } internal (SendOrPostCallback Callback, object State) Take() { return posts.Dequeue(); } } // Only a fixed idle-editor test; the handler inherited from EquipmentFixture never forwards requests. internal static async Task VerifyFishingForEditorAsync() { 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); var folder = Path.Combine(Root, "fishing-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N")); EquipmentFixture fixture = null; Task pending = null; int checks = 0, groups = 0; var passed = new List(); string result = null; void Check(bool yes, string reason) { checks++; if (!yes) { throw new InvalidOperationException("Fishing validation: " + reason); } } async Task Start(string name) { if (fixture != null) { await fixture.Close(); } if (pending != null) { try { await pending; } catch { } pending = null; } fixture = new EquipmentFixture(Path.Combine(folder, (++groups).ToString())); var owner = await fixture.Open(); Enabled = true; Ready = false; Error = null; failed = 0; handle = null; collecting = null; fishingAction = null; Permit = new PlayerKVLoopPermit { Version = 2, Operation = "fishing-consume-once", PlayerId = "101", AllowedKeys = new[] { "k", "NewPlayerDataDic", "late" } }; RunPath = folder; mainThread = Thread.CurrentThread.ManagedThreadId; mainContext = SynchronizationContext.Current; Attach(_ => fixture.Faults++); Publish(owner); passed.Add(name); } (FishingAction action, WriteBatch batch, PlayerKVEditResult primary) Batch(bool duplicates = false) { var action = BeginFishing(new object ()); Check(ClaimFishingConsumption(action), "first consume admitted"); var batch = BeginFishingWrites(action); if (duplicates) { Write("NewPlayerDataDic", "intermediate-local", false); Write("NewPlayerDataDic", "intermediate-remote", true); } Write("k", "task", true); var primary = Write("NewPlayerDataDic", "final", true); batch.Dispose(); return (action, batch, 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); } try { Directory.CreateDirectory(folder); await Start("duplicate-state-writes-and-original-final-tickets"); var b = Batch(true); Check(b.batch.Records.Count == 4 && !b.batch.Records[0].Remote && b.batch.Records[0].Edit.Ticket == null, "local intermediate captured"); _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick. await ConfirmFishingAsync(b.action, b.batch, b.primary); Check(fixture.Remote["NewPlayerDataDic"] == "final" && fixture.Remote["k"] == "task", "both final values persisted"); int continued = 0; ContinueFishing(b.action, () => continued++); ContinueFishing(b.action, () => continued++); Check(continued == 1 && !ClaimFishingConsumption(b.action) && BeginFishing(new object ()) == null, "continue and consume/cast only once"); await Start("pending-response-does-not-continue"); b = Batch(); fixture.HoldSet = true; _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick. pending = ConfirmFishingAsync(b.action, b.batch, b.primary); Check(ReferenceEquals(await Task.WhenAny(fixture.SetEntered.Task, pending, Task.Delay(5000)), fixture.SetEntered.Task), "Set entered"); Check(!b.action.Confirmed && !b.action.Continued, "no continuation during pending response"); fixture.ReleaseSet.TrySetResult(true); await pending; pending = null; Check(b.action.Confirmed, "original response confirms"); await Start("final-localOnly-rejected-before-flush"); b = Batch(); // A sealed record ledger from actual writes with a localOnly final key. collecting = b.batch; Write("k", "local-tail", false); b.batch.Dispose(); await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "local final cannot report remote success"); Check(fixture.Sets == 0, "no flush of rejected batch"); await Start("external-final-supersession"); b = Batch(); Write("NewPlayerDataDic", "later-action", true); _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick. await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "never substitute later ticket"); await Start("lost-response-after-commit"); b = Batch(); fixture.LoseResponse = true; _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick. await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "Unknown stops run"); Check(fixture.Sets == 1 && fixture.Remote["NewPlayerDataDic"] == "final", "one uncertain commit, no replay"); await Start("unauthorized-does-not-reauthenticate"); b = Batch(); fixture.Unauthorized = true; _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick. await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "401 stops run"); Check(fixture.Auth == 1 && fixture.Sets == 1, "single auth and Set"); await Start("cancelled-object-does-not-continue-after-commit"); b = Batch(); fixture.HoldSet = true; _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick. pending = ConfirmFishingAsync(b.action, b.batch, b.primary); await fixture.SetEntered.Task; CancelFishing(b.action, "synthetic disable"); fixture.ReleaseSet.TrySetResult(true); await Reject(pending, "disabled original action cannot continue"); pending = null; Check(!b.action.Continued && b.action.Cancelled, "no stale continuation"); await Start("close-before-confirmation"); b = Batch(); await fixture.Close(); await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "closed owner rejected"); Check(fixture.Sets == 0, "close does not invent flush"); await Start("partial-commit-is-not-atomic-success"); b = Batch(); fixture.PartialCommit = true; fixture.LoseResponse = true; _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick. await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "partial unknown stops run"); Check(fixture.Sets == 1 && fixture.Remote["k"] == "task" && fixture.Remote["NewPlayerDataDic"] == "original-save", "partial commit retained without compensation"); await Start("storage-error-before-send"); var stateFile = Directory.GetFiles(Path.Combine(folder, groups.ToString()), "state.json", SearchOption.AllDirectories).Single(); using (var locked = new FileStream(stateFile, FileMode.Open, FileAccess.Read, FileShare.None)) { bool storage = false; try { Write("NewPlayerDataDic", "locked", true); } catch (InvalidOperationException e) { storage = e.Message.Contains("StorageError"); } Check(storage && !Ready && fixture.Faults == 1 && fixture.Sets == 0, "storage failure cannot be accepted or retried"); } await Start("observation-timeout-does-not-replay"); b = Batch(); fixture.HoldSet = true; _ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick. pending = ConfirmFishingAsync(b.action, b.batch, b.primary); await Reject(pending, "30 second observer deadline stops continuation"); pending = null; Check(fixture.Sets == 1 && !b.action.Continued, "timeout leaves one admitted write"); fixture.ReleaseSet.TrySetResult(true); await Start("equipment-permit-cannot-cast"); Permit.Version = 1; Permit.Operation = null; bool denied = false; try { BeginFishing(new object ()); } catch (InvalidOperationException) { denied = true; } Check(denied && fixture.Sets == 0, "old permit cannot consume"); result = JsonConvert.SerializeObject(new { success = true, groups, assertions = checks, passed, formalRequests = 0, synthetic = true, restored = true, folder }); } finally { try { if (fixture != null) { await fixture.Close(); } if (pending != null) { try { await pending; } catch (Exception e) { Debug.LogWarning("Observed fishing test failure: " + e.GetType().Name); } } (Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction) = saved; } catch { validationCleanupFailure = fixture; throw; } finally { Interlocked.Exchange(ref validationRunning, 0); } } File.WriteAllText(Path.Combine(folder, "result.json"), result); return result; } // Fixed real subscriber fixture. Reflection targets are compiled constants, never supplied by CLI. sealed class FishingSubscribers: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) { Red.SetValue(null,new RedPointManager()); var store=container.Resolve(); 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; data.BattleTask.dic_dailyTasks.Add(tables.TbBattlePassTask.DataList.First(t=>t.Type==cfg.ConditionType.ConsumeEnergy).TaskID,0); Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle); var common=tables.TbCommonTask.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy).Id; Lucky=new EventLuckyGameModel(events,tables);Lucky.DicTasks.Add(common,0); Magic=new EventLuckMagicModel{DicTasks=new Dictionary{{common,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); } } } sealed class FishingContextAccess:GContext { protected override Task SetupAsync()=>throw new InvalidOperationException("Test-only context."); internal static void Use(Container value) { container=value; } internal static IEventAggregator Events { get=>eventAggregator; set=>eventAggregator=value; } } sealed class FishingNotifications:ILocalNotificationService { public Task Init()=>Task.CompletedTask; public bool IsNoticeOpen=>false; public void OpenSettings(){} public void ScheduleOneTimeNotification(int id,DateTime time,string title,string text){} public void ScheduleDailyNotification(int id,DateTime time,string title,string text){} public void ClearAllNotification(){} public void ClearNotification(int id){} public void ScheduleDefaultNotification(){} } } } #endif