Files
ft/Client/Assets/Scripts/Services/PlayerData/Module02OriginalLoadTests.cs

610 lines
35 KiB
C#

#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using asap.core;
using GameCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace game.PlayerDataStorage
{
// Editor-only probes of the existing business methods; no production decoding or reset graph.
internal static class Module02OriginalLoadTests
{
private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
private static bool running;
private static bool ContainsPayload(JToken actual, JToken expected)
{
if (expected is JObject obj)
{
return actual is JObject && obj.Properties().All(p => ContainsPayload(actual[p.Name], p.Value));
}
if (expected is JArray array)
{
return actual is JArray other && array.Count == other.Count && Enumerable.Range(0, array.Count).All(i => ContainsPayload(other[i], array[i]));
}
return JToken.DeepEquals(actual, expected);
}
internal static T Field<T>(object target, string name)
{
return (T)target.GetType().GetField(name, InstanceFlags).GetValue(target);
}
private static void Invoke(object target, string name, params object[] arguments)
{
try
{
target.GetType().GetMethod(name, InstanceFlags).Invoke(target, arguments);
}
catch (TargetInvocationException ex)when (ex.InnerException != null)
{
ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
throw;
}
}
internal static void LoadCore(PlayerData player, IDictionary<string, string> modern)
{
typeof(PlayerData).GetField("NewPlayerDataDic", InstanceFlags).SetValue(player, new Dictionary<string, string>(modern));
Invoke(player, "SetPlayerData");
}
internal static void LoadCore(PlayerData player, object[] slots)
{
LoadCore(player, new Dictionary<string, string> { ["newData"] = string.Join(",", slots.Select(value => Convert.ToString(value, CultureInfo.CurrentCulture))), ["vipL"] = "-1,1" });
}
internal static void LoadGuide(GuideDataCenter guide, IDictionary<string, string> raw)
{
if (raw.TryGetValue("GuideSaveData", out var legacy))
{
guide.GuideSaveDataDic = JsonConvert.DeserializeObject<Dictionary<string, GuideSaveData>>(legacy);
}
if (raw.TryGetValue("GuideSaveDataNew", out var modern))
{
guide.SetData(modern);
}
}
private sealed class ContextAccess : GContext
{
protected override Task SetupAsync()
{
throw new InvalidOperationException("Validation context cannot start gameplay.");
}
internal static void Use(Container value)
{
container = value;
}
internal static IEventAggregator Events { get => eventAggregator; set => eventAggregator = value; }
}
private sealed class MemoryStore : IPlayerSaveStore
{
private readonly Dictionary<string, string> data = new Dictionary<string, string>();
internal readonly List<string> Writes = new List<string>();
public bool IsCurrent => true;
public PlayerSaveReadResult Read(string key)
{
return new PlayerSaveReadResult(data.TryGetValue(key, out var value), value);
}
public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true)
{
data[key] = value;
Writes.Add(key);
return new PlayerSaveWriteResult(new PlayerSaveReceipt(this, null));
}
public Task<bool> SyncDataImmidiate(string key, string value)
{
Write(key, value);
return Task.FromResult(true);
}
}
private sealed class FaceObserver : IFaceUIService
{
internal readonly List<string> Calls = new List<string>();
public void AddFaceCustomUIData(UIType type, bool first = false)
{
}
public void ShowFaceUI(string value = "")
{
}
public void AddGiftFaceUIForce(int id, UIType type)
{
Calls.Add("force:" + id + ":" + type);
}
public void AddGiftFaceUIOnLogin(int id, UIType type, int priority)
{
}
public void AddGiftFaceUI(int id, UIType type, int priority = 0)
{
Calls.Add("gift:" + id + ":" + type + ":" + priority);
}
public int GetFaceUICount() => Calls.Count;
public void StopFaceUI()
{
}
public void AddPostFaceUI(UIType type)
{
}
public void ShowPostFaceUI()
{
}
public int GetPostFaceUICount() => 0;
public void StopPostFaceUI()
{
}
}
private sealed class Fixture : IDisposable
{
private readonly Container previous = GContext.container;
private readonly IEventAggregator previousEvents = ContextAccess.Events;
private readonly Container isolated = new Container();
private readonly string previousPinataPath = UITypes.PinataPack_PonyPanel.Path;
private readonly string previousPinataName = UITypes.PinataPack_PonyPanel.Name;
private readonly Dictionary<string, UIType> previousUITypes = new Dictionary<string, UIType>(UITypes.UITypesDic);
private readonly object previousRedPoints = typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null);
internal readonly cfg.Tables Tables;
internal readonly PlayerData Player;
internal readonly PlayerFishData Fish;
internal readonly FishingEventData Events;
internal readonly PlayerShopData Shop;
internal readonly GuideDataCenter Guide;
internal readonly BattlePassData Battle;
internal readonly MemoryStore Store = new MemoryStore();
internal readonly FaceObserver Face = new FaceObserver();
internal Fixture()
{
var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../cfgData"));
Tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory, name + ".json"))));
ContextAccess.Use(isolated);
ContextAccess.Events = new EventAggregator();
typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, new RedPointManager());
Register(Tables);
isolated.RegisterInstance<IFaceUIService>(Face);
Player = Register(new PlayerData(Tables, null) { playerSaveStore = Store });
Register(new SmallGameData(Tables, null, null));
Fish = Register(new PlayerFishData(Tables, null));
Events = Register(new FishingEventData(Tables));
Shop = Register(new PlayerShopData { _tables = Tables, SaveStore = Store });
Register(new BuffDataCenter(Tables));
Register(new CampDataMM(Tables, null));
Register(new AchievementDataManager(Tables));
Register(new FishingBoxData());
Guide = Register(new GuideDataCenter(Tables, Player));
Battle = Register(new BattlePassData(Tables) { playerSaveStore = Store });
Register(new SigninData(Tables));
Register(new TriggerPackData(Tables));
Register(new ClubService(false));
Register(new FishingChallengeManager());
Register(new DataCenter.OfferChainsChestManager());
Register(new DataCenter.OfferDiscoPartyManager());
Register(new DataCenter.EventPartnerGatherManager());
Register(new EventCubeMeltManager());
Register(new EventPackData());
Register(new PiggyBankPackData());
Register(new BargainPackData());
isolated.RegisterInstance<IFootPrintService>(new FootPrintService());
Register(new EventShootingRangeData());
Register(new EventPotionDataManager());
Register(new EventPinballDataManager());
Register(new EventScratchTicketDataManager());
Register(new Script.RuntimeScript.DiggingGameManager());
Register(new PlayerItemData(Tables));
// Original constructor, after its existing dependencies are available.
Register(new EventPinataPackDataManager());
}
private T Register<T>(T instance)
where T : class
{
isolated.RegisterInstance<T>(instance);
return instance;
}
internal T Get<T>()
where T : class
{
return isolated.Resolve<T>();
}
public void Dispose()
{
ContextAccess.Use(previous);
ContextAccess.Events = previousEvents;
typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, previousRedPoints);
UITypes.PinataPack_PonyPanel.SetType(previousPinataPath);
UITypes.PinataPack_PonyPanel.SetName(previousPinataName);
UITypes.UITypesDic.Clear();
foreach (var pair in previousUITypes)
{
UITypes.UITypesDic[pair.Key] = pair.Value;
}
isolated.Dispose();
}
}
internal static string InspectRaw(Dictionary<string, string> raw)
{
using (var fixture = new Fixture())
{
fixture.Player.LoadPlayerData(raw);
return JsonConvert.SerializeObject(new { originalLoad = true, keys = raw.Count, level = (int)fixture.Player.lv, writes = fixture.Store.Writes, faceCalls = fixture.Face.Calls, formalRequests = 0 });
}
}
internal static Task<string> RunAsync(string caseId)
{
if (running || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayerKVGameplayLoop.Enabled)
{
throw new InvalidOperationException("Original load validation requires an idle isolated Editor.");
}
running = true;
var previousContainer = GContext.container;
var previousEvents = ContextAccess.Events;
var previousDiff = ZZTimeHelper.timeDiff;
var previousOffset = ZZTimeHelper.LocalTimeOffsetInSec;
var traces = new List<object>();
int assertions = 0;
string failure = null;
void Check(bool condition, string name)
{
assertions++;
if (!condition)
{
throw new InvalidOperationException(name);
}
}
try
{
// Baseline executes the unchanged original method; target executes the KV private common entry.
// The third path deliberately reproduces the former pre-adjust load for a visible counterexample.
foreach (var seconds in new[]
{
-1,
1
}
)
{
foreach (var offset in new[]
{
-3600,
3600
}
)
{
var outputs = new List<(int eventId, DateTime energyTime, int limitedCount)>();
foreach (var path in new[]
{
"original-after-adjust",
"kv-after-adjust",
"previous-before-adjust"
}
)
{
using (var fixture = new Fixture())
{
var boundary = new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc);
var selected = fixture.Tables.TbFishingEvent.DataList.First(e => e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPackManager.GetOrDefault(e.RedirectID)?.PackType == 3);
var time = (cfg.LimitedTime)selected.TimeDefinition;
var globalOffset = offset < 0 ? -9 : 9;
typeof(cfg.GlobalConfig).GetProperty("GlobalTimeOffset").SetValue(Field<cfg.GlobalConfig>(fixture.Tables.TbGlobalConfig, "_data"), globalOffset);
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(time, boundary.AddDays(-1).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(time, boundary.ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
var raw = new Dictionary<string, string>
{
["MMTest"] = "MMFailParam#0",
["EventPackData"] = JsonConvert.SerializeObject(new { currentEventID = selected.ID, purchaseCount = 2, redirectID = selected.RedirectID }),
["LimitedTimeEventDic"] = JsonConvert.SerializeObject(new Dictionary<int, object> { [900001] = new { id = 900001, duration = 2, startTime = boundary.AddHours(-globalOffset).AddMinutes(-2).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture) } })
};
ZZTimeHelper.LocalTimeOffsetInSec = offset;
var desired = boundary.AddSeconds(seconds);
ZZTimeHelper.AdjustTime((path == "previous-before-adjust" ? boundary.AddDays(2) : desired).AddSeconds(-offset));
if (path == "kv-after-adjust")
{
var user = new KVUserService
{
SaveStore = fixture.Store
};
Invoke(user, "InitializePlayerData", raw);
}
else
{
fixture.Player.LoadPlayerData(raw);
}
var eventId = fixture.Get<EventPackData>().CurrentEventID;
var energyTime = Field<DateTime>(fixture.Player, "_lastEnergyRecoveryTime");
var limitedCount = ((System.Collections.IDictionary)Field<object>(fixture.Events, "limitedTimeEventDic")).Count;
outputs.Add((eventId, energyTime, limitedCount));
traces.Add(new { path, seconds, offset, globalOffset, desired, eventId, energyTime, limitedCount, faceCalls = fixture.Face.Calls.ToArray(), writes = fixture.Store.Writes.ToArray() });
Check(fixture.Face.Calls.Count == 0, "M20 original commented UI remains inactive");
}
}
Check(outputs[0].eventId == outputs[1].eventId, "Original and KV activity boundary match");
Check(outputs[0].limitedCount == outputs[1].limitedCount && outputs[0].limitedCount == (seconds < 0 ? 1 : 0), "LimitedTime expiry uses original global offset on both paths");
Check(Math.Abs((outputs[0].energyTime - outputs[1].energyTime).TotalSeconds) < 1, "Original and KV energy clock match");
Check(outputs[0].eventId == (seconds < 0 ? 0 : outputs[2].eventId), "M20 original expiry/reset semantics");
Check(Math.Abs((outputs[2].energyTime - outputs[1].energyTime).TotalHours) > 24, "Old load-before-adjust clock counterexample is real");
}
}
// Existing refresh lifecycle is tested separately; login loading must not invoke it early.
foreach (var globalOffset in new[]
{
-9,
9
}
)
{
foreach (var seconds in new[]
{
-1,
1
}
)
{
using (var fixture = new Fixture())
{
typeof(cfg.GlobalConfig).GetProperty("GlobalTimeOffset").SetValue(Field<cfg.GlobalConfig>(fixture.Tables.TbGlobalConfig, "_data"), globalOffset);
var eventData = fixture.Tables.TbFishingEvent.DataList.First(e => fixture.Tables.TbBattlePassMain.GetOrDefault(e.RedirectID) != null);
fixture.Battle.BattlePassRecord.ID = eventData.ID;
using (var provider = new BattlePassDataProvider(fixture.Tables, fixture.Battle, fixture.Get<PlayerItemData>()))
{
GContext.container.RegisterInstance(provider);
ZZTimeHelper.LocalTimeOffsetInSec = 0;
var desired = new DateTime(2026, 9, 27, 0, 0, 0, DateTimeKind.Utc).AddHours(globalOffset).AddSeconds(seconds);
ZZTimeHelper.AdjustTime(desired);
fixture.Battle.RefreshDaiyTask();
fixture.Battle.RefreshWeeklyTask();
var offsetNow = ZZTimeHelper.UtcNow().UtcNowOffset();
Check(fixture.Battle.BattleTask.DailyTaskEndTime == ConvertTools.GetDateTimeYMD(offsetNow).AddDays(1), "BP original daily lifecycle boundary");
Check(fixture.Battle.BattleTask.WeeklyTaskEndTime == ConvertTools.GetDateTimeSunDay(offsetNow).AddDays(1), "BP original weekly lifecycle boundary");
Check(fixture.Store.Writes.Contains("BattlePassRecordTask"), "BP original lifecycle saves through existing store");
traces.Add(new { path = "original-BP-refresh-lifecycle", globalOffset, seconds, desired, dailyEnd = fixture.Battle.BattleTask.DailyTaskEndTime, weeklyEnd = fixture.Battle.BattleTask.WeeklyTaskEndTime, writes = fixture.Store.Writes.ToArray() });
}
}
}
}
using (var fixture = new Fixture())
{
var boundary = new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc);
fixture.Player.LoadPlayerData(new Dictionary<string, string>());
ZZTimeHelper.LocalTimeOffsetInSec = 0;
ZZTimeHelper.AdjustTime(boundary.AddSeconds(-1));
var selected = fixture.Tables.TbFishingEvent.DataList.First(e => e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPackManager.GetOrDefault(e.RedirectID)?.PackType == 3);
var limit = (cfg.LimitedTime)selected.TimeDefinition;
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(limit, boundary.AddDays(-1).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(limit, boundary.ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
var bargain = fixture.Get<BargainPackData>();
var pack = fixture.Tables.TbEventPackManager[selected.RedirectID];
var packId = pack.VIPPackList[0][0];
var saved = new GeneralEventPackData(eventID: selected.ID, redirectID: selected.RedirectID, vip: 0, purchaseCount: 0, isTriggered: true, savingProgress: int.MaxValue, discountLvl: fixture.Tables.TbSpecialPack[packId].IAPID.Count - 1, inflation: 1);
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1 && fixture.Face.Calls[0].StartsWith("force:" + selected.ID + ":"), "M22 original full active bargain registers original UI once");
saved.isTriggered = false;
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1, "M22 untriggered bargain does not register UI");
saved.isTriggered = true;
ZZTimeHelper.AdjustTime(boundary.AddSeconds(1));
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1, "M22 expired bargain does not register UI");
traces.Add(new { path = "original-bargain-side-effects", calls = fixture.Face.Calls.ToArray() });
fixture.Face.Calls.Clear();
var pinataEvent = fixture.Tables.TbFishingEvent.DataList.First(e => e.Type == 9 && e.SubType == 6 && e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPinataPackMain.GetOrDefault(e.RedirectID) != null);
foreach (var ev in fixture.Tables.TbFishingEvent.DataList.Where(e => e.Type == 9 && e.SubType == 6))
{
if (ev.TimeDefinition is cfg.LimitedTime other)
{
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(other, boundary.AddDays(-2).ToString("s"));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(other, boundary.AddDays(-1).ToString("s"));
}
}
var pinataTime = (cfg.LimitedTime)pinataEvent.TimeDefinition;
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(pinataTime, boundary.AddDays(-1).ToString("s"));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(pinataTime, boundary.ToString("s"));
typeof(cfg.FishingEvent).GetProperty("ConditionList").SetValue(pinataEvent, new List<cfg.EventCondition> { new cfg.EventCondition(cfg.ConditionType.AccountLevel, new List<string> { "1" }, "") });
ZZTimeHelper.AdjustTime(boundary.AddSeconds(-1));
var pinata = fixture.Get<EventPinataPackDataManager>();
pinata.LoadData("{\"EventID\":0,\"PinataMainID\":0,\"PurchasedCount\":7}");
var target = fixture.Get<EventPinataPackData>();
Check(target.EventID == pinataEvent.ID && target.PurchasedCount == 0 && fixture.Face.Calls.Count == 1, "M29 original changed event refreshes purchase state and registers UI");
pinata.LoadData(JsonConvert.SerializeObject(new { target.EventID, target.PinataMainID, PurchasedCount = 1 }));
Check(target.PurchasedCount == 1 && fixture.Face.Calls.Count == 1, "M29 same event preserves purchase count without repeated UI");
ZZTimeHelper.AdjustTime(boundary.AddSeconds(1));
pinata.GetFreshPinataPackData();
Check(fixture.Face.Calls.Count == 1, "M29 expired event does not add UI");
traces.Add(new { path = "original-pinata-side-effects", calls = fixture.Face.Calls.ToArray(), target.EventID, target.PinataMainID, target.PurchasedCount });
}
using (var fixture = new Fixture())
{
var raw = PlayerKVEditorValidationHost.FullRaw(fixture.Tables, true);
fixture.Player.LoadPlayerData(raw);
Check((int)fixture.Player.lv == 3 && (ulong)fixture.Player.gold == 999 && (int)fixture.Player.Energy == 5, "Original newData wins over defaults");
Check(fixture.Shop.Voucher == 6 && fixture.Shop.shopPackDailyBuyCount["x"] == 1, "Shop numeric dictionaries");
Check(fixture.Events.TurntableCoupon == 7 && fixture.Events.WheelLevelCount[5] == 6, "Event original defaults and saved counts");
Check(fixture.Guide.GuideSaveDataDic["A"].guideSubIndex == 3 && fixture.Guide.GuideSaveDataDic["A"].isFinish, "Guide original modern format");
Check(fixture.Battle.BattlePassRecord.Level == 2 && fixture.Battle.BattleTask.dic_dailyTasks[9] == 10, "BP legacy values");
var small = fixture.Get<SmallGameData>();
Check(small.HeistGameCount == 12 && small.BombGameCount == 13 && small.FishHuntGameCount == 14, "Original small game slots");
Check(Field<Dictionary<string, int>>(fixture.Get<CampDataMM>(), "CampDataDic")["CampId"] == 205, "Original Camp dictionary");
Check(Field<Dictionary<int, FishBuffTimeData>>(fixture.Get<BuffDataCenter>(), "mapBuffDataList")[7].buffID == 3, "Original buff deserialization");
Check(fixture.Get<FishingBoxData>().GetString() == "1,2", "Original modern box replaces legacy");
Check(fixture.Get<SigninData>().monthlySignEvent.index == 4 && fixture.Get<SigninData>().dailySignEvent.index == 2, "Original monthly sign history");
Check(fixture.Get<TriggerPackData>().triggerPackList[0].vipLevel == 4, "Original trigger pack history");
foreach (var sample in new[]
{
("FishingChallengeData", (object)fixture.Get<FishingChallengeManager>().fishingChallengeData),
("OfferChainsChestData", (object)fixture.Get<DataCenter.OfferChainsChestManager>().OfferChainsData),
("OfferDiscoPartyManager", (object)fixture.Get<DataCenter.OfferDiscoPartyManager>().OfferDiscoPartyData),
("EventPartnerGatherData", (object)fixture.Get<DataCenter.EventPartnerGatherManager>().GatherData),
("EventCubeMeltData", (object)fixture.Get<EventCubeMeltManager>().EventData),
("epdk", (object)fixture.Get<EventPotionDataManager>().PotionData),
("estdk", (object)fixture.Get<EventScratchTicketDataManager>().ScratchTicketData)
}
)
{
var expected = JToken.Parse(raw[sample.Item1]);
if (sample.Item1 == "OfferDiscoPartyManager")
{
((JObject)expected).Remove("ChestReward");
((JObject)expected).Remove("Finished");
}
Check(ContainsPayload(JToken.FromObject(sample.Item2), expected), "Original numeric/history payload: " + sample.Item1);
}
var pinball = fixture.Get<EventPinballDataManager>();
Check(pinball.PinballData.Token == 11 && pinball.RewardList.SequenceEqual(fixture.Tables.TbEventPinballReward.DataList.Select(r => (float)r.SpinPoint)), "Original Pinball initializes reward configuration");
Check(!Field<bool>(pinball, "m_IsSimulatorMode"), "Original Pinball exits simulator mode");
Check(fixture.Events.ThanksGivingPackData.EventId == 1 && fixture.Events.ThanksGivingPackData.ChainProgress == 4 && fixture.Events.ChainPackWithProgressMigrationData.EventId == 5 && fixture.Events.ChainPackWithProgressMigrationData.ChainProgress == 8, "Two original progress keys remain independent");
var achievement = fixture.Get<AchievementDataManager>();
achievement.LoadAchievementDataToObject("{\"0\":\"7|18446744073709551615|9\"}");
Check(Field<Dictionary<cfg.ConditionType, AchievementData>>(achievement, "_achievementDatas")[(cfg.ConditionType)0].Target == ulong.MaxValue, "Original achievement preserves ulong maximum");
Check(fixture.Get<ClubService>().IsOpenClubRed == false, "Club key existence rule");
Check(fixture.Get<EventPackData>().PurchaseCount == 2, "Unknown EventPack preserves parsed data");
Check(fixture.Get<BargainPackData>().PurchaseCount == 11, "Bargain original data");
Check(fixture.Get<PiggyBankPackData>().PurchaseCount == 11, "Piggy original data");
Check(fixture.Get<IFootPrintService>().FootPrint == (EFootPrint)ulong.Parse(raw["FootPrint"]), "Original FootPrint enum conversion");
var model = fixture.Get<Script.RuntimeScript.DiggingGameManager>().DataModel;
fixture.Player.LoadPlayerData(new Dictionary<string, string>());
Check(ReferenceEquals(model, fixture.Get<Script.RuntimeScript.DiggingGameManager>().DataModel), "Digging original existing model retained");
Check(fixture.Shop.shopPackDailyBuyCount["x"] == 1 && fixture.Get<BargainPackData>().PurchaseCount == 11, "Retry missing keys retains original existing objects");
traces.Add(new { path = "full-original-and-missing-key-retry", keys = raw.Keys.ToArray(), writes = fixture.Store.Writes.ToArray(), faceCalls = fixture.Face.Calls.ToArray() });
}
using (var fixture = new Fixture())
{
var rod = fixture.Tables.TbGlobalConfig.InitRodID;
foreach (var count in new[]
{
19,
20
}
)
{
var slots = new object[]
{
9,
23UL,
0,
-4,
5,
6,
-999,
new DateTime(2026, 1, 1),
0,
8,
7UL,
0,
0,
0,
0,
0,
2,
3,
4,
99
}.Take(count).ToArray();
var raw = new Dictionary<string, string>
{
["Lv"] = "2",
["Energy"] = "10",
["EquipRodID"] = rod.ToString(),
["NewPlayerDataDic"] = JsonConvert.SerializeObject(new Dictionary<string, string> { ["LV"] = "4", ["newData"] = string.Join(",", slots), ["vipL"] = "-1,1" })
};
fixture.Player.LoadPlayerData(raw);
Check((int)fixture.Player.lv == 9 && fixture.Player.Energy == 0 && fixture.Player.equipRodID == rod, "19 and 20 slots original override/clamp/rod fallback");
}
fixture.Shop.CouponData.LoadData("2026,9,23,1,2,3,14");
Check(fixture.Shop.CouponData.RedirectId == 0 && !fixture.Shop.CouponData.IsBought, "Seven-field coupon history");
fixture.Shop.CouponData.LoadData("2026,9,23,1,2,3,14,15,1");
Check(fixture.Shop.CouponData.RedirectId == 15 && fixture.Shop.CouponData.IsBought, "Nine-field coupon history");
var compressed = fixture.Battle.BattlePassRecord.SerializeObject() + "#" + fixture.Battle.BattleTask.SerializeObject();
fixture.Battle.InitData(new Dictionary<string, string> { ["BattlePassRecordTask"] = compressed, ["BattlePassRecord"] = "bad JSON", ["BattleTask"] = "bad JSON" });
Check(fixture.Battle.BattlePassRecord != null && fixture.Battle.BattleTask != null, "Compressed BP takes precedence without parsing conflicting legacy");
var realFish = fixture.Tables.TbFishData.DataList.First(f => fixture.Tables.TbMapData.DataMap.ContainsKey(f.AreaID));
fixture.Fish.InitData(new Dictionary<string, string> { ["Map_5_" + realFish.AreaID] = JsonConvert.SerializeObject(new Dictionary<int, string> { [realFish.ID] = "0|7|2|3|True" }), ["Map_" + realFish.AreaID] = "bad legacy JSON" });
var parsedFish = Field<Dictionary<int, Dictionary<int, FishWeightData>>>(fixture.Fish, "mapFishData")[realFish.AreaID][realFish.ID];
Check(parsedFish.TotalNum == 3 && parsedFish.NewFish, "Map_5 takes precedence over broken legacy map with original weight correction");
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["GuideSaveData"] = "bad JSON", ["GuideSaveDataNew"] = "K|bad|1", ["LimitedTimeEventDic"] = "bad JSON", ["epdk"] = "", ["epbdk"] = "bad JSON", ["estdk"] = "bad JSON", ["Lv"] = "17" });
Check((int)fixture.Player.lv == 17 && fixture.Guide.GuideSaveDataDic["K"].guideSubIndex == 0, "Original local catches continue and Guide TryParse default");
Exception thrown = null;
try
{
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["IsFix"] = "invalid-bool" });
}
catch (FormatException ex)
{
thrown = ex;
}
Check(thrown != null && thrown.StackTrace.Contains("InitData"), "Original external parse exception is not Schema rejection");
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["Lv"] = "19" });
Check((int)fixture.Player.lv == 19, "Original load can retry after failure without uniform reset");
}
}
catch (Exception ex)
{
failure = ex.ToString();
}
finally
{
ZZTimeHelper.timeDiff = previousDiff;
ZZTimeHelper.LocalTimeOffsetInSec = previousOffset;
ContextAccess.Use(previousContainer);
ContextAccess.Events = previousEvents;
running = false;
}
var restored = ReferenceEquals(GContext.container, previousContainer) && ReferenceEquals(ContextAccess.Events, previousEvents) && ZZTimeHelper.timeDiff == previousDiff && ZZTimeHelper.LocalTimeOffsetInSec == previousOffset;
return Task.FromResult(JsonConvert.SerializeObject(new { success = failure == null && restored, caseId, assertions, traces, failure, restored, formalRequests = 0, baseline = "f40a816cc414fc4f30d67626d060d90fdc5eaca3 original business methods (source comparison required)", playerFabNetworkLoginExecuted = false }));
}
}
}
#endif