using System.Globalization; using game.PlayerDataStorage; using Newtonsoft.Json; static partial class Tests { static readonly string[] CompleteCore = { "3", "18446744073709551615", "4", "5", "6", "7", "101", "2026-09-23T00:00:00.0000000Z", "-1", "8", "18446744073709551614", "9", "1", "0", "10", "11", "12", "13", "14" }; static PlayerKVSchemaSnapshot SchemaSnapshot(string culture = "en-US") => new("synthetic-v1", new[] { 101 }, new[] { 1, 2 }, new Dictionary { ["None"] = 0, ["Fish"] = 1, ["Another"] = 2 }, CultureInfo.GetCultureInfo(culture), new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc), 17); static Dictionary ModernRaw(string[] slots = null) => new() { ["NewPlayerDataDic"] = JsonConvert.SerializeObject(new Dictionary { ["newData"] = string.Join(",", slots ?? CompleteCore) }) }; static PlayerKVCoreState Core(Dictionary raw, out PlayerKVSchemaDiagnostics errors, string culture = "en-US") { errors = new(); var modern = PlayerKVSaveSchema.DecodeModern(raw, errors); return PlayerKVSaveSchema.DecodeCore(raw, modern, SchemaSnapshot(culture), errors); } static PlayerKVAchievementState Achievement(Dictionary raw, Dictionary modern, out PlayerKVSchemaDiagnostics errors) { errors = new(); return PlayerKVSaveSchema.DecodeAchievements(raw, modern, SchemaSnapshot(), errors); } static async Task RunSchemaTests() { await Run("schema-primitive-json-and-finite-float", async () => { Check(PlayerKVSaveSchema.Single("1.25") == 1.25f, "invariant finite number accepted"); foreach (var value in new[] { "NaN", "Infinity", "-Infinity", "1e999", "1,000" }) await Throws(() => { PlayerKVSaveSchema.Single(value); return Task.CompletedTask; }); var token = PlayerKVSaveSchema.Json("{\"x\":[true,false,null,-0,1.25e2,\"a\\u0041\\n\"]}"); Check(token["x"].Count() == 6, "RFC literals numbers and escapes accepted by syntax layer"); Check(PlayerKVSaveSchema.StringMap("{\"x\":\"a\\u0041\"}")["x"] == "aA", "string escapes materialized"); foreach (var value in new[] { "[1,]", "01", "1.", ".1", "+1", "1e", "{\"x\":1e999}" }) await Throws(() => { PlayerKVSaveSchema.Json(value); return Task.CompletedTask; }); }); await Run("M01-M02-modern-19-slots-currency-aliases", () => { var state = Core(ModernRaw(), out var e); Check(state != null && e.TotalErrors == 0, "complete modern accepted"); Check(state.Unsigned(1) == ulong.MaxValue && state.Unsigned(10) == ulong.MaxValue - 1, "unsigned values not rounded"); Check(state.Integer(4) == 6 && state.Integer(11) == 9 && state.Integer(8) == -1, "pearl, diamond and signed bit mask distinct"); Check(state.Integer(16) == 12 && state.Integer(17) == 13 && state.Integer(18) == 14, "all small game counts captured"); Check(state.EnergyTime.Kind == DateTimeKind.Utc, "roundtrip UTC retained"); return Task.CompletedTask; }); await Run("M01-M02-legacy-complete-partial-and-conflict", () => { string[] keys = { "Lv", "Gold", "vipAllScore", "Energy", "Diamond", "Magnification", "EquipRodID", "LastEnergyRecoveryTime", "RewardNewMap", "EnergyRM", "GoldRM", "DiamondTrue", "CurrentMapId", "LastMapId", "FishingFrequency", "AllFishingFrequency", "HeistGameCount", "BombGameCount", "FishHuntGameCount" }; var raw = keys.Select((k, i) => (k, v: CompleteCore[i])).ToDictionary(x => x.k, x => x.v); Check(Core(raw, out var e) != null && e.TotalErrors == 0, "complete legacy accepted"); raw.Remove("Gold"); Check(Core(raw, out e) == null && e.Count(1, PlayerKVSchemaError.MissingCore) == 1, "cannot compose partial core"); raw["NewPlayerDataDic"] = ModernRaw()["NewPlayerDataDic"]; Check(Core(raw, out e) != null, "consistent overlapping legacy accepted"); raw["Diamond"] = "7"; Check(Core(raw, out e) == null && e.Count(1, PlayerKVSchemaError.ConflictingRepresentations) == 1, "partial legacy conflict rejected"); return Task.CompletedTask; }); await Run("M01-M02-every-slot-malformed-and-boundaries", () => { for (int i = 0; i < 19; i++) { var slots = (string[])CompleteCore.Clone(); slots[i] = "invalid"; Check(Core(ModernRaw(slots), out var e) == null && e.TotalErrors > 0, "invalid consumed slot rejected"); } foreach (int count in new[] { 0, 18, 20 }) Check(Core(ModernRaw(Enumerable.Repeat("1", count).ToArray()), out _) == null, "exact length required"); var negative = (string[])CompleteCore.Clone(); negative[3] = "-1"; Check(Core(ModernRaw(negative), out _) == null, "energy cannot be clamped"); negative[3] = "0"; negative[0] = "0"; Check(Core(ModernRaw(negative), out _) == null, "level at least one"); var unknown = (string[])CompleteCore.Clone(); unknown[6] = "999"; Check(Core(ModernRaw(unknown), out var errors) == null && errors.Count(1, PlayerKVSchemaError.UnsupportedSchema) == 1, "unknown rod no fallback"); unknown = (string[])CompleteCore.Clone(); unknown[12] = "999"; Check(Core(ModernRaw(unknown), out _) == null, "map references captured table"); return Task.CompletedTask; }); await Run("M01-modern-outer-strict-json-and-alias-conflicts", () => { foreach (var text in new[] { "null", "[]", "{\"a\":null}", "{\"a\":1}", "{\"a\":\"x\",\"a\":\"y\"}", "{}{}", "/*comment*/{}", "{\"a\":NaN}", "{'a':'b'}", "{a:\"b\"}", "{\"a\":\"b\",}", "{\"a\":\"\\x41\"}", "{\"a\":\"unterminated}" }) { Check(Core(new() { ["NewPlayerDataDic"] = text }, out var e) == null && e.TotalErrors > 0, "outer format rejected"); } var modern = new Dictionary { ["newData"] = string.Join(",", CompleteCore), ["pearl"] = "77" }; Check(Core(new() { ["NewPlayerDataDic"] = JsonConvert.SerializeObject(modern) }, out var err) == null && err.Count(1, PlayerKVSchemaError.ConflictingRepresentations) == 1, "nested alias checked"); return Task.CompletedTask; }); await Run("M01-date-culture-ambiguity-and-snapshot-copy", () => { var slots = (string[])CompleteCore.Clone(); slots[7] = "01/02/2026"; Check(Core(ModernRaw(slots), out var e, "en-GB") == null && e.Count(1, PlayerKVSchemaError.UnsupportedSchema) == 1, "ambiguous day/month rejected"); var rods = new List { 101 }; var conditions = new Dictionary { ["Fish"] = 1 }; var culture = new CultureInfo("en-US"); var snapshot = new PlayerKVSchemaSnapshot("v1", rods, new[] { 1 }, conditions, culture, DateTime.MinValue, 17); rods.Clear(); conditions.Clear(); culture.DateTimeFormat.ShortDatePattern = "dd/MM/yyyy"; Check(snapshot.HasRod(101) && snapshot.Condition("Fish") == 1 && snapshot.Culture.IsReadOnly, "snapshot owns copies"); Check(snapshot.InitWheelTicket == 17 && snapshot.Version == "v1", "M05 default comes from captured snapshot"); return Task.CompletedTask; }); await Run("M08-three-representations-and-priority", () => { var raw = new Dictionary { ["AchievementData"] = "{\"Fish\":{\"Id\":2,\"Target\":18446744073709551615,\"day\":3}}" }; var modern = new Dictionary(); var state = Achievement(raw, modern, out var e); Check(state.Values[1].Target == ulong.MaxValue && e.TotalErrors == 0, "object exact ulong"); modern["AD"] = "{\"Fish\":\"2|18446744073709551615|3\"}"; modern["ad"] = "1:2|18446744073709551615|3"; state = Achievement(raw, modern, out e); Check(state.Values.Count == 1 && e.TotalErrors == 0, "all agree"); modern["ad"] = ""; state = Achievement(raw, modern, out e); Check(state.Values.Count == 0 && e.TotalErrors == 0, "whole newer empty set replaces old, not merged"); Check(Achievement(new(), new(), out e).Values.Count == 0, "missing explicit empty state"); return Task.CompletedTask; }); await Run("M08-AD-strict-values-no-fallback", () => { foreach (var text in new[] { "null", "{\"Fish\":null}", "{\"Fish\":{\"Id\":1,\"Target\":2,\"day\":3}}", "{\"Fish\":\"1|2\"}", "{\"Fish\":\"1|2|3|4\"}", "{\"Fish\":\"1|18446744073709551616|3\"}", "{\"Fish\":\"1|-1|3\"}", "{\"Fish\":\"1|2|3\",\"1\":\"1|2|3\"}", "{\"Fish\":\"1|2|3\",\"Fish\":\"1|2|3\"}", "{\"unknown\":\"1|2|3\"}" }) { var state = Achievement(new(), new() { ["AD"] = text, ["ad"] = "1:1|2|3" }, out var e); Check(state == null && e.Count(8, PlayerKVSchemaError.Malformed) > 0, "bad AD cannot be hidden by new representation"); } Check(Achievement(new(), new() { ["AD"] = "{}" }, out var empty).Values.Count == 0 && empty.TotalErrors == 0, "empty object valid"); return Task.CompletedTask; }); await Run("M08-object-token-types-compact-bad-records-and-conflict", () => { foreach (var text in new[] { "{\"1\":{\"Id\":\"1\",\"Target\":2,\"day\":3}}", "{\"1\":{\"Id\":1.0,\"Target\":2,\"day\":3}}", "{\"1\":{\"Id\":1,\"Target\":2}}" }) Check(Achievement(new() { ["AchievementData"] = text }, new(), out _) == null, "no loose coercion/default"); foreach (var text in new[] { "1:1|2|3;", ";1:1|2|3", "1:1|2|3;Fish:1|2|3", "1:1|2", "unknown:1|2|3" }) Check(Achievement(new(), new() { ["ad"] = text }, out _) == null, "malformed compact rejected"); Check(Achievement(new(), new() { ["AD"] = "{\"1\":\"1|2|3\"}", ["ad"] = "1:1|3|3" }, out var e) == null && e.Count(8, PlayerKVSchemaError.ConflictingRepresentations) == 1, "conflicting targets rejected"); return Task.CompletedTask; }); await Run("M23-full-unsigned-domain-missing-and-invalid", () => { foreach (ulong value in new[] { 0UL, 255UL, 1UL << 31, 1UL << 32, ulong.MaxValue }) { var e = new PlayerKVSchemaDiagnostics(); var state = PlayerKVSaveSchema.DecodeFootPrint(new Dictionary { ["FootPrint"] = value.ToString(CultureInfo.InvariantCulture) }, e); Check(state.Bits == value && e.TotalErrors == 0, "full bits retained in decoded value"); } Check(PlayerKVSaveSchema.DecodeFootPrint(new Dictionary(), new()).Bits == 0, "missing empty state"); foreach (var text in new[] { null, "", "-1", "18446744073709551616", "1.0", "1e3" }) { var e = new PlayerKVSchemaDiagnostics(); Check(PlayerKVSaveSchema.DecodeFootPrint(new Dictionary { ["FootPrint"] = text }, e) == null && e.TotalErrors == 1, "bad bits rejected"); } return Task.CompletedTask; }); await Run("schema-diagnostics-32-stable-aggregate-all", () => { var e = new PlayerKVSchemaDiagnostics(); for (int i = 50; i >= 0; i--) e.Add(i % 2 == 0 ? 8 : 1, i, PlayerKVSchemaError.Malformed); Check(e.TotalErrors == 51 && e.Details.Count == 32 && e.OmittedDetails == 19, "all errors counted with bounded storage"); Check(e.Count(8, PlayerKVSchemaError.Malformed) == 26 && e.Count(1, PlayerKVSchemaError.Malformed) == 25, "module code aggregate complete"); Check(e.Details.First().Module == 1 && e.Details.First().Field == 1 && e.Details.Last().Module == 8 && e.Details.Last().Field == 12, "stable module/field ordering"); return Task.CompletedTask; }); } }