chore(m2): 保存功能验收及后续清理前检查点
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
@@ -9,287 +9,691 @@ using asap.core;
|
||||
using GameCore;
|
||||
using Newtonsoft.Json;
|
||||
using UniRx;
|
||||
using DG.Tweening;
|
||||
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<string> VerifyOriginalFishingForEditorAsync()
|
||||
{
|
||||
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll<GContext>().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<string>();
|
||||
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<Exception>();
|
||||
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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var unexpectedLogs = new List<string>();
|
||||
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("<UnitySynchronizationContext>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 == "<GetAwaiterReturnVoid>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<IPlayerSaveStore>(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<ILocalNotificationService>(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<string, string>(player.PlayerDataDic);
|
||||
expected["energy"] = (initialEnergy - cost).ToString();
|
||||
string expectedJson = JsonConvert.SerializeObject(expected);
|
||||
var order = new List<string>();
|
||||
int consumed = 0;
|
||||
playerSubscription = GContext.OnEvent<PlayerData>().Subscribe(_ => order.Add("player"));
|
||||
consumeSubscription = GContext.OnEvent<ConditionTypeEvent>().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<string, string>(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<Dictionary<string, string>>(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<FishingBehaviorB>();
|
||||
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<string, string> 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<Dictionary<string, string>>(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<Dictionary<string, string>>(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<string> VerifyFishingForEditorAsync()
|
||||
{
|
||||
if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready ||
|
||||
Resources.FindObjectsOfTypeAll<GContext>().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad ||
|
||||
Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null)
|
||||
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || Resources.FindObjectsOfTypeAll<GContext>().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,onClose,collecting,mainThread,failed,mainContext,fishingAction);
|
||||
var oldContainer=GContext.container; var oldEvents=FishingContextAccess.Events;
|
||||
var folder=Path.Combine(Root,"fishing-fixed-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N"));
|
||||
EquipmentFixture fixture=null; Task pending=null; Container container=null; PlayerData player=null; IDisposable subscription=null;
|
||||
var oldLanguages=LocalizationMgr.enumLanguage;
|
||||
var textTable=typeof(LocalizationMgr).GetField("table",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
var oldTextTable=textTable.GetValue(null);
|
||||
GameObject saveShell=null;
|
||||
var pfField=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
var oldPF=pfField.GetValue(null);
|
||||
FishingSubscribers subscribers=null;
|
||||
int checks=0,groups=0; var passed=new List<string>(); string result=null;
|
||||
void Check(bool yes,string reason) { checks++; if(!yes) throw new InvalidOperationException("Fishing validation: "+reason); }
|
||||
}
|
||||
|
||||
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>();
|
||||
string result = null;
|
||||
void Check(bool yes, string reason)
|
||||
{
|
||||
checks++;
|
||||
if (!yes)
|
||||
{
|
||||
throw new InvalidOperationException("Fishing validation: " + reason);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Start(string name)
|
||||
{
|
||||
subscribers?.Dispose(); subscribers=null;
|
||||
player?.StopRecoveryEnergy(); player=null; subscription?.Dispose(); subscription=null;
|
||||
if(fixture!=null) await fixture.Close();
|
||||
if(pending!=null) { try { await pending; } catch { } pending=null; }
|
||||
pfField.SetValue(null,oldPF);if(saveShell!=null)UnityEngine.Object.DestroyImmediate(saveShell);saveShell=null;
|
||||
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); container=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++,fixture.Close); Publish(owner);
|
||||
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)
|
||||
|
||||
(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);
|
||||
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)
|
||||
|
||||
async Task Reject(Task task, string label)
|
||||
{
|
||||
bool rejected=false; try { await task; } catch(InvalidOperationException) { rejected=true; }
|
||||
Check(rejected && !Ready && fixture.Faults==1,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");
|
||||
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;
|
||||
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();
|
||||
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);
|
||||
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"never substitute later ticket");
|
||||
|
||||
await Start("lost-response-after-commit"); b=Batch(); fixture.LoseResponse=true;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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");
|
||||
|
||||
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))
|
||||
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");
|
||||
}
|
||||
|
||||
foreach(bool complete in new[]{false,true})
|
||||
{
|
||||
await Start(complete?"actual-tween-autoKill-after-complete":"actual-tween-kill-before-complete");
|
||||
var shell=new GameObject("KV inactive fishing tween fixture"); shell.SetActive(false); shell.hideFlags=HideFlags.HideAndDontSave;
|
||||
Tween tween=null;
|
||||
// DOTween.Init deliberately does nothing in EditMode; Kill otherwise returns
|
||||
// before reaching the real tween engine. Scope this fixed fixture gate to this
|
||||
// synchronous block and restore it even when an assertion fails.
|
||||
var initialized=typeof(DOTween).GetField("initialized",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic|System.Reflection.BindingFlags.Public);
|
||||
var wasInitialized=initialized.GetValue(null);
|
||||
bool storage = false;
|
||||
try
|
||||
{
|
||||
initialized.SetValue(null,true);
|
||||
var behavior=shell.AddComponent<FishingBehaviorB>();
|
||||
var action=BeginFishing(behavior);
|
||||
typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action);
|
||||
float value=0; tween=DOTween.To(()=>value,v=>value=v,1,100).SetAutoKill(true).SetUpdate(UpdateType.Manual);
|
||||
typeof(FishingBehaviorB).GetMethod("TrackKVCastTween",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,new object[]{tween});
|
||||
Check(tween.onKill!=null && tween.onComplete!=null,"actual callbacks installed");
|
||||
if(complete) tween.Complete(); else tween.Kill(false);
|
||||
Check(action.Cancelled!=complete && !action.Continued,"actual Tween Kill boundary complete="+complete+" cancelled="+action.Cancelled+" enabled="+Enabled+" active="+tween.IsActive());
|
||||
Check(fixture.Faults==(complete?0:1) && fixture.Sets==0,"autoKill is not a cancellation");
|
||||
Write("NewPlayerDataDic", "locked", true);
|
||||
}
|
||||
finally
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
try { tween?.Kill(false); UnityEngine.Object.DestroyImmediate(shell); }
|
||||
finally { initialized.SetValue(null,wasInitialized); }
|
||||
storage = e.Message.Contains("StorageError");
|
||||
}
|
||||
|
||||
Check(storage && !Ready && fixture.Faults == 1 && fixture.Sets == 0, "storage failure cannot be accepted or retried");
|
||||
}
|
||||
|
||||
await Start("completed-action-normal-disable-is-not-failure");
|
||||
var completedShell=new GameObject("KV completed disable fixture");completedShell.SetActive(false);completedShell.hideFlags=HideFlags.HideAndDontSave;
|
||||
|
||||
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
|
||||
{
|
||||
var behavior=completedShell.AddComponent<FishingBehaviorB>();
|
||||
var action=BeginFishing(behavior);Check(ClaimFishingConsumption(action),"completed action admitted");
|
||||
var batch=BeginFishingWrites(action);var primary=Write("NewPlayerDataDic","completed",true);batch.Dispose();
|
||||
await ConfirmFishingAsync(action,batch,primary);ContinueFishing(action,()=>{});
|
||||
typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action);
|
||||
typeof(FishingBehaviorB).GetMethod("OnDisable",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,null);
|
||||
Check(action.Cancelled && action.Continued && Ready && fixture.Faults==0,"normal disable after completed consumption must not Fault");
|
||||
Check(!FishingCurrent(action) && BeginFishing(behavior)==null,"normal disable still revokes callbacks and never restores quota");
|
||||
BeginFishing(new object ());
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(completedShell); }
|
||||
|
||||
await Start("observation-timeout-does-not-replay"); b=Batch(); fixture.HoldSet=true;
|
||||
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");
|
||||
|
||||
foreach(var scenario in new[]{"above-limit","cross-limit","below-limit","recovery-during-pending"})
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
await Start("actual-PlayerData-"+scenario);
|
||||
int initialEnergy=scenario=="above-limit"?400:scenario=="cross-limit"?300:250;
|
||||
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);
|
||||
saveShell=new GameObject("KV inactive consumption storage fixture");saveShell.SetActive(false);saveShell.hideFlags=HideFlags.HideAndDontSave;
|
||||
var pf=saveShell.AddComponent<PlayFabMgr>();Check(pf.IsUnstartedForKVLoad,"consumption fixture never starts PF lifecycle");pfField.SetValue(null,pf);
|
||||
var store=new PlayFabPlayerSaveStore(pf,CancellationToken.None);
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList();
|
||||
textTable.SetValue(null,null);
|
||||
player=new PlayerData(tables,null); var small=new SmallGameData(tables,null,null); var fish=new PlayerFishData(tables,null);
|
||||
var achievements=new AchievementDataManager(tables); var box=new FishingBoxData(); var events=new FishingEventData(tables);
|
||||
container.RegisterInstance(player); container.RegisterInstance(small); container.RegisterInstance(fish);
|
||||
container.RegisterInstance(achievements); container.RegisterInstance(box); container.RegisterInstance(events);
|
||||
player.playerSaveStore=store;fish.playerSaveStore=store;small.playerSaveStore=store;events.playerSaveStore=store;
|
||||
container.RegisterInstance<ILocalNotificationService>(new FishingNotifications());
|
||||
fish.ResetForKVLoad(); small.ResetForKVLoad(); achievements.ResetForKVLoad(); box.ResetForKVLoad();
|
||||
achievements.Init();
|
||||
int rod=tables.TbRodData.DataMap.Keys.First(),map=tables.TbMapData.DataMap.Keys.First();
|
||||
var core=new PlayerKVCoreState(new object[]{1,10UL,0,initialEnergy,5,0,rod,new DateTime(2026,1,1),0,7,10UL,2,map,map,0,0,0,0,0});
|
||||
player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary<string,string>(),new Dictionary<int,int>{{-1,1}},new List<int>(),0,false,"0"));
|
||||
// Fixed backing property only, no caller-controlled reflection or gameplay registration.
|
||||
typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player,300);
|
||||
typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player,1);
|
||||
if(scenario=="above-limit")
|
||||
{
|
||||
Permit.AllowedKeys=Permit.AllowedKeys.Concat(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}).ToArray();
|
||||
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 config excludes async activation and trigger-pack writes");
|
||||
denied = true;
|
||||
}
|
||||
int consumed=0,notified=0;
|
||||
subscription=GContext.OnEvent<ConditionTypeEvent>().Subscribe(e=>{if(e.type==cfg.ConditionType.ConsumeEnergy){consumed+=e.count; Write("k","task-"+e.count,true);}});
|
||||
using(GContext.OnEvent<PlayerData>().Subscribe(_=>notified++))
|
||||
{
|
||||
var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"real action claimed");
|
||||
fixture.HoldSet=scenario=="recovery-during-pending";
|
||||
var consumption=player.ConsumeEnergyForKVLoopAsync(3,action); pending=consumption;
|
||||
if(fixture.HoldSet)
|
||||
{
|
||||
await fixture.SetEntered.Task;
|
||||
Check(!action.Confirmed && !action.Continued,"real player pending cannot continue");
|
||||
// Drive the actual due recovery tick while the original Set is still held.
|
||||
// No replacement serialization, no paused production recovery algorithm.
|
||||
player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed*60);
|
||||
Check((int)player.Energy==initialEnergy-2 && !action.Confirmed,"real recovery can write during pending");
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
}
|
||||
await consumption; pending=null;
|
||||
Check((int)player.Energy==initialEnergy-(fixture.HoldSet?2:3) && player.GetEnergyRM()==10 && consumed==3,"one real deduction and event");
|
||||
Check(notified==(scenario=="cross-limit"?2:fixture.HoldSet?3:1),"original notification count preserved");
|
||||
var modern=JsonConvert.DeserializeObject<Dictionary<string,string>>(fixture.Remote["NewPlayerDataDic"]);
|
||||
Check(modern["newData"].Split(',')[3]==(initialEnergy-3).ToString() && !modern.ContainsKey("energy"),"one canonical representation");
|
||||
Check(fixture.Remote["k"]=="task-3" && action.Confirmed,"synchronous task write confirmed");
|
||||
Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy)==3 && modern["ad"].Contains(":") && modern["ad"]==achievements.AchievementDataToJson(),"actual achievement subscriber captured after event");
|
||||
var savedTime=DateTime.Parse(modern["newData"].Split(',')[7]);
|
||||
Check(scenario=="cross-limit"?savedTime>new DateTime(2026,1,1):savedTime==new DateTime(2026,1,1),"recovery timestamp crossing behavior preserved");
|
||||
if(fixture.HoldSet)
|
||||
{
|
||||
await KVMgr.FlushKVAsync(action.Owner);
|
||||
var recovered=JsonConvert.DeserializeObject<Dictionary<string,string>>(fixture.Remote["NewPlayerDataDic"]);
|
||||
Check(recovered["newData"].Split(',')[3]==(initialEnergy-2).ToString(),"later recovery has separate flush, not original action confirmation");
|
||||
}
|
||||
if(subscribers!=null)
|
||||
{
|
||||
Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single()==3 && subscribers.Lucky.DicTasks.Values.Single()==3 && subscribers.Magic.DicTasks.Values.Single()==3,"actual task subscriptions each receive one consume event");
|
||||
Check(subscribers.Mini.Data.Exp==3,"actual mini BP subscription receives the event");
|
||||
Check(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}.All(fixture.Remote.ContainsKey),"all actual subscriber save methods confirmed in original action");
|
||||
}
|
||||
var parsed=new PlayerKVSchemaSnapshot("fixed-fishing",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,
|
||||
new Dictionary<string,int>(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24,0,0,0,DateTimeKind.Utc),0);
|
||||
var errors=new PlayerKVSchemaDiagnostics();
|
||||
Check(PlayerKVSaveSchema.DecodeCore(new Dictionary<string,string>(),modern,parsed,errors)!=null && errors.TotalErrors==0,"actual serialized core loads without alias conflict");
|
||||
}
|
||||
}
|
||||
result=JsonConvert.SerializeObject(new{success=true,groups,assertions=checks,passed,formalRequests=0,synthetic=true,restored=true,folder});
|
||||
|
||||
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
|
||||
{
|
||||
subscribers?.Dispose(); subscribers=null;
|
||||
player?.StopRecoveryEnergy(); subscription?.Dispose();
|
||||
if(fixture!=null) await fixture.Close();
|
||||
if(pending!=null) { try { await pending; } catch(Exception e) { Debug.LogWarning("Observed fishing test failure: "+e.GetType().Name); } }
|
||||
pfField.SetValue(null,oldPF);if(saveShell!=null)UnityEngine.Object.DestroyImmediate(saveShell);
|
||||
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose();
|
||||
LocalizationMgr.enumLanguage=oldLanguages; textTable.SetValue(null,oldTextTable);
|
||||
(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction)=saved;
|
||||
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);
|
||||
}
|
||||
catch { validationCleanupFailure=fixture; throw; }
|
||||
finally { Interlocked.Exchange(ref validationRunning,0); }
|
||||
}
|
||||
File.WriteAllText(Path.Combine(folder,"result.json"),result); return result;
|
||||
|
||||
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 PF=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
readonly object oldRed=Red.GetValue(null),oldPF=PF.GetValue(null);
|
||||
GameObject shell; LuckyTaskData magicTask; MiniBattlePassDataProvider provider;
|
||||
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());
|
||||
shell=new GameObject("KV inactive subscriber storage fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave;
|
||||
var pf=shell.AddComponent<PlayFabMgr>();
|
||||
if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture.");
|
||||
PF.SetValue(null,pf);
|
||||
var store=container.Resolve<IPlayerSaveStore>();
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
container.Resolve<PlayerData>().playerSaveStore=store;
|
||||
@@ -322,10 +726,40 @@ namespace game.PlayerDataStorage
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
Battle?.Dispose();Lucky?.Dispose();magicTask?.Dispose();
|
||||
if(Mini!=null)(typeof(MiniBattlePassDataModel).GetField("disposables",Fields).GetValue(Mini) as IDisposable)?.Dispose();
|
||||
PF.SetValue(null,oldPF);Red.SetValue(null,oldRed);
|
||||
if(shell!=null)UnityEngine.Object.DestroyImmediate(shell);
|
||||
var errors = new List<Exception>();
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user