Files
ft/Client/Assets/Scripts/Services/UserService.KVFishingValidation.cs

781 lines
46 KiB
C#

#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<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)
{
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>();
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<IPlayerSaveStore>();
container.RegisterInstance<IPlayerSaveStore>(store);
container.Resolve<PlayerData>().playerSaveStore=store;
container.Resolve<PlayerFishData>().playerSaveStore=store;
container.Resolve<SmallGameData>().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<int,int>{{common,0}},HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()};
var rings=(List<EventLuckMagicRing>)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<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
{
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