using System.Globalization; using game.PlayerDataStorage; using Newtonsoft.Json; using Newtonsoft.Json.Linq; static partial class Tests { static PlayerKVSchemaSnapshot PackSnapshot(IDictionary packs = null) => new("D3", new[] { 101 }, new[] { 1 }, new Dictionary(), CultureInfo.InvariantCulture, new DateTime(2026,9,23), 0, packs ?? new Dictionary { [4055001] = 2, [300] = 3 }); static async Task RunD3Tests() { await Run("M21-strict-complete-value-state", () => { var obj = JObject.FromObject(new { currentEventID = 11, purchaseCount = 11, VIPLvlWhenEventActivate = 11, savingProgress = 11, redirectID = 0, doNeedTrigger = true, isTriggered = true, inflation = 1.5f }); var raw = new Dictionary { ["PiggyBankPackData"] = obj.ToString(Formatting.None) }; var e = new PlayerKVSchemaDiagnostics(); var state = PlayerKVSaveSchema.DecodePiggyBank(raw, PackSnapshot(), e); Check(state != null && state.IsPresent && e.TotalErrors == 0, "present complete record"); Check(state.currentEventID == 11, "typed currentEventID retained"); Check(state.purchaseCount == 11, "typed purchaseCount retained"); Check(state.VIPLvlWhenEventActivate == 11, "typed VIPLvlWhenEventActivate retained"); Check(state.savingProgress == 11, "typed savingProgress retained"); Check(state.redirectID == 4055001, "typed redirectID retained"); Check(state.doNeedTrigger == true, "typed doNeedTrigger retained"); Check(state.isTriggered == true, "typed isTriggered retained"); Check(state.inflation == 1.5f, "typed inflation retained"); var missing = PlayerKVSaveSchema.DecodePiggyBank(new Dictionary(), PackSnapshot(), new PlayerKVSchemaDiagnostics()); Check(!missing.IsPresent, "missing distinguished"); Check(missing.currentEventID == 0, "missing currentEventID empty"); Check(missing.purchaseCount == 0, "missing purchaseCount empty"); Check(missing.VIPLvlWhenEventActivate == 0, "missing VIPLvlWhenEventActivate empty"); Check(missing.savingProgress == 0, "missing savingProgress empty"); Check(missing.redirectID == 0, "missing redirectID empty"); Check(missing.doNeedTrigger == false, "missing doNeedTrigger empty"); Check(missing.isTriggered == false, "missing isTriggered empty"); Check(missing.inflation == 0, "missing inflation empty"); foreach (var property in obj.Properties().ToArray()) { var bad = (JObject)obj.DeepClone(); bad.Remove(property.Name); raw["PiggyBankPackData"] = bad.ToString(Formatting.None); Check(PlayerKVSaveSchema.DecodePiggyBank(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "every field required"); foreach (var invalid in new JToken[] { JValue.CreateNull(), new JValue("coercion"), new JObject() }) { bad = (JObject)obj.DeepClone(); bad[property.Name] = invalid; raw["PiggyBankPackData"] = bad.ToString(Formatting.None); Check(PlayerKVSaveSchema.DecodePiggyBank(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "no coercion or null"); } } foreach (var invalid in new[] { "null", "[]", "{}", "{", "{\\\"duplicate\\\":1,\\\"duplicate\\\":2}" }) { raw["PiggyBankPackData"] = invalid; e = new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodePiggyBank(raw, PackSnapshot(), e) == null && e.Count(21, PlayerKVSchemaError.Malformed) == 1, "invalid optional is an error"); } return Task.CompletedTask; }); await Run("M22-strict-complete-value-state", () => { var obj = JObject.FromObject(new { currentEventID = 11, redirectID = 300, VIPLvlWhenEventActivate = 11, purchaseCount = 11, savingProgress = 11, discountLvl = 11, visualProgress = 11, visualDiscountLvl = 11, rodID = 11, lastPackIDBought = 11, packID = 11, doNeedTrigger = true, isTriggered = true, inflation = 1.5f, selections = new[] { 7, 9 } }); var raw = new Dictionary { ["GeneralEventPackData"] = obj.ToString(Formatting.None) }; var e = new PlayerKVSchemaDiagnostics(); var state = PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), e); Check(state != null && state.IsPresent && e.TotalErrors == 0, "present complete record"); Check(state.currentEventID == 11, "typed currentEventID retained"); Check(state.redirectID == 300, "typed redirectID retained"); Check(state.VIPLvlWhenEventActivate == 11, "typed VIPLvlWhenEventActivate retained"); Check(state.purchaseCount == 11, "typed purchaseCount retained"); Check(state.savingProgress == 11, "typed savingProgress retained"); Check(state.discountLvl == 11, "typed discountLvl retained"); Check(state.visualProgress == 11, "typed visualProgress retained"); Check(state.visualDiscountLvl == 11, "typed visualDiscountLvl retained"); Check(state.rodID == 11, "typed rodID retained"); Check(state.lastPackIDBought == 11, "typed lastPackIDBought retained"); Check(state.packID == 11, "typed packID retained"); Check(state.doNeedTrigger == true, "typed doNeedTrigger retained"); Check(state.isTriggered == true, "typed isTriggered retained"); Check(state.inflation == 1.5f, "typed inflation retained"); Check(state.selections.SequenceEqual(new[] { 7, 9 }), "typed selections retained"); var missing = PlayerKVSaveSchema.DecodeBargain(new Dictionary(), PackSnapshot(), new PlayerKVSchemaDiagnostics()); Check(!missing.IsPresent, "missing distinguished"); Check(missing.currentEventID == 0, "missing currentEventID empty"); Check(missing.redirectID == 0, "missing redirectID empty"); Check(missing.VIPLvlWhenEventActivate == 0, "missing VIPLvlWhenEventActivate empty"); Check(missing.purchaseCount == 0, "missing purchaseCount empty"); Check(missing.savingProgress == 0, "missing savingProgress empty"); Check(missing.discountLvl == 0, "missing discountLvl empty"); Check(missing.visualProgress == 0, "missing visualProgress empty"); Check(missing.visualDiscountLvl == 0, "missing visualDiscountLvl empty"); Check(missing.rodID == 0, "missing rodID empty"); Check(missing.lastPackIDBought == 0, "missing lastPackIDBought empty"); Check(missing.packID == 0, "missing packID empty"); Check(missing.doNeedTrigger == false, "missing doNeedTrigger empty"); Check(missing.isTriggered == false, "missing isTriggered empty"); Check(missing.inflation == 0, "missing inflation empty"); Check(missing.selections.Count == 0, "missing selections empty"); foreach (var property in obj.Properties().ToArray()) { var bad = (JObject)obj.DeepClone(); bad.Remove(property.Name); raw["GeneralEventPackData"] = bad.ToString(Formatting.None); Check(PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "every field required"); foreach (var invalid in new JToken[] { JValue.CreateNull(), new JValue("coercion"), new JObject() }) { if (property.Name == "selections" && invalid.Type == JTokenType.Null) continue; bad = (JObject)obj.DeepClone(); bad[property.Name] = invalid; raw["GeneralEventPackData"] = bad.ToString(Formatting.None); Check(PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "no coercion or null"); } } foreach (var invalid in new[] { "null", "[]", "{}", "{", "{\\\"duplicate\\\":1,\\\"duplicate\\\":2}" }) { raw["GeneralEventPackData"] = invalid; e = new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), e) == null && e.Count(22, PlayerKVSchemaError.Malformed) == 1, "invalid optional is an error"); } return Task.CompletedTask; }); await Run("M22-null-empty-list-preserves-fields-and-strictness", () => { var obj = JObject.FromObject(new { currentEventID = 1, redirectID = 300, VIPLvlWhenEventActivate = 2, purchaseCount = 3, savingProgress = 4, discountLvl = 5, visualProgress = 6, visualDiscountLvl = 7, rodID = 8, lastPackIDBought = 9, packID = 10, doNeedTrigger = true, isTriggered = false, inflation = 1.25f }); foreach (var selection in new JToken[] { JValue.CreateNull(), new JArray(), new JArray(7, 9) }) { obj["selections"] = selection; var text = obj.ToString(Formatting.None); var raw = new Dictionary { ["GeneralEventPackData"] = text }; var errors = new PlayerKVSchemaDiagnostics(); var state = PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(), errors); Check(state != null && state.IsPresent && errors.TotalErrors == 0, "all three list representations accepted"); Check(state.currentEventID == 1 && state.redirectID == 300 && state.VIPLvlWhenEventActivate == 2 && state.purchaseCount == 3 && state.savingProgress == 4 && state.discountLvl == 5 && state.visualProgress == 6 && state.visualDiscountLvl == 7 && state.rodID == 8 && state.lastPackIDBought == 9 && state.packID == 10 && state.doNeedTrigger && !state.isTriggered && state.inflation == 1.25f, "all fourteen other fields retained"); Check(selection.Type == JTokenType.Null ? state.selections == null : state.selections.SequenceEqual(selection.Values()), "null is not empty list"); Check(raw["GeneralEventPackData"] == text, "raw string unchanged"); var wrongType = new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeBargain(raw, PackSnapshot(new Dictionary { [300] = 2 }), wrongType) == null && wrongType.Count(22, PlayerKVSchemaError.UnsupportedSchema) == 1, "null does not bypass pack type check"); } foreach (var selection in new JToken[] { new JArray(JValue.CreateNull()), new JArray(1.5), new JArray("7"), new JArray(2147483648L), new JValue(false), new JValue("null"), new JObject() }) { obj["selections"] = selection; var errors = new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeBargain(new Dictionary { ["GeneralEventPackData"] = obj.ToString(Formatting.None) }, PackSnapshot(), errors) == null && errors.Count(22, PlayerKVSchemaError.Malformed) == 1, "invalid list remains malformed"); } var nullable = new PlayerKVBargainState(true,0,300,0,0,0,0,0,0,0,0,0,false,false,0,null); Check(nullable.selections == null && nullable.IsPresent, "DTO null remains null"); var source = new List { 7, 9 }; var copied = new PlayerKVBargainState(true,0,300,0,0,0,0,0,0,0,0,0,false,false,0,source); source[0] = 99; Check(copied.selections.SequenceEqual(new[] { 7,9 }) && copied.selections is not List, "non-null DTO remains an independent read-only copy"); Check(!PlayerKVBargainState.Missing().IsPresent && PlayerKVBargainState.Missing().selections.Count == 0, "missing pack keeps original empty list contract"); return Task.CompletedTask; }); await Run("M24-strict-complete-value-state", () => { var obj = JObject.FromObject(new { EventId = 11, CycleId = 11, RedirectId = 11, AmmoCount = 11, CurrentStageIdx = 11, ChainProgress = 11, RewardFlag = long.MaxValue, TargetFlag = long.MinValue, HasBeenOpened = true, Inflation = 1.5f }); var raw = new Dictionary { ["EventShootingRangeData"] = obj.ToString(Formatting.None) }; var e = new PlayerKVSchemaDiagnostics(); var state = PlayerKVSaveSchema.DecodeShootingRange(raw, PackSnapshot(), e); Check(state != null && state.IsPresent && e.TotalErrors == 0, "present complete record"); Check(state.EventId == 11, "typed EventId retained"); Check(state.CycleId == 11, "typed CycleId retained"); Check(state.RedirectId == 11, "typed RedirectId retained"); Check(state.AmmoCount == 11, "typed AmmoCount retained"); Check(state.CurrentStageIdx == 11, "typed CurrentStageIdx retained"); Check(state.ChainProgress == 11, "typed ChainProgress retained"); Check(state.RewardFlag == long.MaxValue, "typed RewardFlag retained"); Check(state.TargetFlag == long.MinValue, "typed TargetFlag retained"); Check(state.HasBeenOpened == true, "typed HasBeenOpened retained"); Check(state.Inflation == 1.5f, "typed Inflation retained"); var missing = PlayerKVSaveSchema.DecodeShootingRange(new Dictionary(), PackSnapshot(), new PlayerKVSchemaDiagnostics()); Check(!missing.IsPresent, "missing distinguished"); Check(missing.EventId == 0, "missing EventId empty"); Check(missing.CycleId == 0, "missing CycleId empty"); Check(missing.RedirectId == 0, "missing RedirectId empty"); Check(missing.AmmoCount == 0, "missing AmmoCount empty"); Check(missing.CurrentStageIdx == 0, "missing CurrentStageIdx empty"); Check(missing.ChainProgress == 0, "missing ChainProgress empty"); Check(missing.RewardFlag == 0, "missing RewardFlag empty"); Check(missing.TargetFlag == 0, "missing TargetFlag empty"); Check(missing.HasBeenOpened == false, "missing HasBeenOpened empty"); Check(missing.Inflation == 0, "missing Inflation empty"); foreach (var property in obj.Properties().ToArray()) { var bad = (JObject)obj.DeepClone(); bad.Remove(property.Name); raw["EventShootingRangeData"] = bad.ToString(Formatting.None); Check(PlayerKVSaveSchema.DecodeShootingRange(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "every field required"); foreach (var invalid in new JToken[] { JValue.CreateNull(), new JValue("coercion"), new JObject() }) { bad = (JObject)obj.DeepClone(); bad[property.Name] = invalid; raw["EventShootingRangeData"] = bad.ToString(Formatting.None); Check(PlayerKVSaveSchema.DecodeShootingRange(raw, PackSnapshot(), new PlayerKVSchemaDiagnostics()) == null, "no coercion or null"); } } foreach (var invalid in new[] { "null", "[]", "{}", "{", "{\\\"duplicate\\\":1,\\\"duplicate\\\":2}" }) { raw["EventShootingRangeData"] = invalid; e = new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeShootingRange(raw, PackSnapshot(), e) == null && e.Count(24, PlayerKVSchemaError.Malformed) == 1, "invalid optional is an error"); } return Task.CompletedTask; }); await Run("D3-fixed-config-copy-and-signed-width", async () => { var source = new Dictionary { [4055001] = 2, [300] = 3 }; var snapshot = PackSnapshot(source); source.Clear(); Check(snapshot.HasPack(4055001) && snapshot.HasPackType(300,3), "configuration captured copy"); foreach (var text in new[] { "9223372036854775808", "-9223372036854775809", "1.0", "\"1\"" }) await Throws(() => { PlayerKVSaveSchema.Signed64(PlayerKVSaveSchema.Json(text)); return Task.CompletedTask; }); var items = new List { 7 }; var state = new PlayerKVBargainState(true, 0,300,0,0,0,0,0,0,0,0,0,false,false,0,items); items.Clear(); Check(state.selections.Count == 1, "DTO owns collection"); Check(state.selections is not List, "collection is not mutable list"); }); } }