diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs new file mode 100644 index 00000000..7070d389 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using CultMath; +using Random = CultMath.Random; + +namespace GameCult.Caching.MessagePack; + +/// +/// The canonical System.Text.Json shape of every public CultMath value type, the JSON twin of +/// . Vectors, quaternions and colors are objects keyed by their +/// field names ({"x":1,"y":2}), matrices are arrays of row objects, rect is +/// {"min":..,"max":..}, and CultCellular is {"nearest":..,"edge":..,"id":..}. +/// Reading skips unknown properties and leaves missing ones at zero (a missing Color32.a +/// is 255); a value that is not an object, or a matrix that is not an array of exactly its row count, is refused. +/// Register with ; JSON options are the consumer's own. +/// +public static class CultMathJson +{ + /// The CultMath types that have a converter, for coverage checks. + public static IReadOnlyCollection ConvertedTypes => Converters.Keys; + + /// Adds the CultMath converters to and returns it. + public static JsonSerializerOptions AddCultMathConverters(this JsonSerializerOptions options) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + foreach (var converter in Converters.Values) options.Converters.Add(converter); + return options; + } + + private sealed class ObjectShape : JsonConverter + { + private readonly Action write; + private readonly Func read; + + public ObjectShape(Action write, Func read) + { + this.write = write; + this.read = read; + } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + return read(document.RootElement); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => write(writer, value); + } + + private static KeyValuePair Shape(Action write, Func read) => + new(typeof(T), new ObjectShape(write, read)); + + private static JsonElement Obj(JsonElement e) + { + if (e.ValueKind != JsonValueKind.Object) throw new JsonException("Expected a JSON object."); + return e; + } + + private static JsonElement Rows(JsonElement e, int count) + { + if (e.ValueKind != JsonValueKind.Array || e.GetArrayLength() != count) + throw new JsonException($"Expected a JSON array of {count} rows."); + return e; + } + + private static bool Has(JsonElement e, string name, out JsonElement value) => Obj(e).TryGetProperty(name, out value); + private static float F(JsonElement e, string name) => Has(e, name, out var v) ? v.GetSingle() : 0f; + private static double D(JsonElement e, string name) => Has(e, name, out var v) ? v.GetDouble() : 0d; + private static int I(JsonElement e, string name) => Has(e, name, out var v) ? v.GetInt32() : 0; + private static bool B(JsonElement e, string name) => Has(e, name, out var v) && v.GetBoolean(); + private static JsonElement Member(JsonElement e, string name) => + Has(e, name, out var v) ? v : Empty; + private static readonly JsonElement Empty = JsonDocument.Parse("{}").RootElement; + + private static void PutFloat2(Utf8JsonWriter w, float2 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); } + private static void PutFloat3(Utf8JsonWriter w, float3 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); } + private static void PutFloat4(Utf8JsonWriter w, float4 v) { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); } + private static float2 GetFloat2(JsonElement e) => new(F(e, "x"), F(e, "y")); + private static float3 GetFloat3(JsonElement e) => new(F(e, "x"), F(e, "y"), F(e, "z")); + private static float4 GetFloat4(JsonElement e) => new(F(e, "x"), F(e, "y"), F(e, "z"), F(e, "w")); + + private static readonly Dictionary Converters = Build(); + + private static Dictionary Build() + { + var shapes = new[] + { + Shape(PutFloat2, GetFloat2), + Shape(PutFloat3, GetFloat3), + Shape(PutFloat4, GetFloat4), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); }, + e => new double2(D(e, "x"), D(e, "y"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); }, + e => new double3(D(e, "x"), D(e, "y"), D(e, "z"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteEndObject(); }, + e => new int2(I(e, "x"), I(e, "y"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteEndObject(); }, + e => new int3(I(e, "x"), I(e, "y"), I(e, "z"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); }, + e => new int4(I(e, "x"), I(e, "y"), I(e, "z"), I(e, "w"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteEndObject(); }, + e => new bool2(B(e, "x"), B(e, "y"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteBoolean("z", v.z); w.WriteEndObject(); }, + e => new bool3(B(e, "x"), B(e, "y"), B(e, "z"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteBoolean("x", v.x); w.WriteBoolean("y", v.y); w.WriteBoolean("z", v.z); w.WriteBoolean("w", v.w); w.WriteEndObject(); }, + e => new bool4(B(e, "x"), B(e, "y"), B(e, "z"), B(e, "w"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("x", v.x); w.WriteNumber("y", v.y); w.WriteNumber("z", v.z); w.WriteNumber("w", v.w); w.WriteEndObject(); }, + e => new quaternion(F(e, "x"), F(e, "y"), F(e, "z"), F(e, "w"))), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("r", v.r); w.WriteNumber("g", v.g); w.WriteNumber("b", v.b); w.WriteNumber("a", v.a); w.WriteEndObject(); }, + e => new Color32( + Has(e, "r", out var r) ? r.GetByte() : (byte)0, + Has(e, "g", out var g) ? g.GetByte() : (byte)0, + Has(e, "b", out var b) ? b.GetByte() : (byte)0, + Has(e, "a", out var a) ? a.GetByte() : (byte)255)), + Shape((w, v) => { w.WriteStartObject(); w.WriteNumber("state", v.state); w.WriteEndObject(); }, + e => new Random { state = Has(e, "state", out var s) ? s.GetUInt32() : 0u }), + + // Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}. + Shape((w, v) => { w.WriteStartArray(); PutFloat2(w, v[0]); PutFloat2(w, v[1]); w.WriteEndArray(); }, + e => + { + var rows = Rows(e, 2); + return new float2x2(GetFloat2(rows[0]), GetFloat2(rows[1])); + }), + Shape((w, v) => { w.WriteStartArray(); PutFloat3(w, v[0]); PutFloat3(w, v[1]); PutFloat3(w, v[2]); w.WriteEndArray(); }, + e => + { + var rows = Rows(e, 3); + return new float3x3(GetFloat3(rows[0]), GetFloat3(rows[1]), GetFloat3(rows[2])); + }), + Shape((w, v) => { w.WriteStartObject(); w.WritePropertyName("min"); PutFloat2(w, v.min); w.WritePropertyName("max"); PutFloat2(w, v.max); w.WriteEndObject(); }, + e => new rect(GetFloat2(Member(e, "min")), GetFloat2(Member(e, "max")))), + Shape((w, v) => { w.WriteStartObject(); w.WritePropertyName("nearest"); PutFloat4(w, v.nearest); w.WritePropertyName("edge"); PutFloat4(w, v.edge); w.WriteNumber("id", v.id); w.WriteEndObject(); }, + e => new CultCellular(GetFloat4(Member(e, "nearest")), GetFloat4(Member(e, "edge")), F(e, "id"))), + }; + + var map = new Dictionary(); + foreach (var shape in shapes) map.Add(shape.Key, shape.Value); + return map; + } +} diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index a37232a9..c2588e24 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -8,6 +8,7 @@ + diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index 277d0cb2..ae4d08a4 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -57,3 +57,27 @@ await cache.PullAllBackingStoresAsync(); persisted through hand-written MessagePack array layouts in this package. - The raw CultNet document lane should treat these payload bytes as already blessed, not decode and re-encode them for sport. + +## CultMath Value Shapes + +Every public CultMath value type has one MessagePack shape (`CultMathResolver`, +in the default chain) and one JSON shape (`CultMathJson`, added to a consumer's +options with `new JsonSerializerOptions().AddCultMathConverters()`; CultLib owns +no JSON options of its own). MessagePack decoding skips extra elements, leaves +missing ones at zero (`Color32` alpha at 255) and refuses nil. JSON reading +skips unknown properties and leaves missing ones at zero. A consumer resolver or +converter registered ahead of these wins. + +| Type | MessagePack | JSON | +| --- | --- | --- | +| `float2`, `double2`, `int2`, `bool2` | `[x, y]` | `{"x":..,"y":..}` | +| `float3`, `double3`, `int3`, `bool3` | `[x, y, z]` | `{"x":..,"y":..,"z":..}` | +| `float4`, `int4`, `bool4`, `quaternion` | `[x, y, z, w]` | `{"x":..,"y":..,"z":..,"w":..}` | +| `Color32` | `[r, g, b, a]` as uint8 | `{"r":..,"g":..,"b":..,"a":..}` | +| `Random` | `[state]` | `{"state":..}` | +| `float2x2`, `float3x3` | array of row vectors (row-major) | array of row objects | +| `rect` | `[min, max]` of `float2` | `{"min":{..},"max":{..}}` | +| `CultCellular` | `[nearest, edge, id]` | `{"nearest":{..},"edge":{..},"id":..}` | + +Floats are MessagePack float32 (`0xCA`), doubles float64 (`0xCB`), integers the +shortest MessagePack integer. diff --git a/src/GameCult.Geometry/CultGeometryPrimitives.cs b/src/GameCult.Geometry/CultGeometryPrimitives.cs index dafcca68..580f2b71 100644 --- a/src/GameCult.Geometry/CultGeometryPrimitives.cs +++ b/src/GameCult.Geometry/CultGeometryPrimitives.cs @@ -12,6 +12,7 @@ namespace GameCult.Geometry public readonly struct CultCircle : IEquatable { /// Creates a circle. + [JsonConstructor] public CultCircle(float2 center, float radius) { if (radius < 0f) @@ -75,6 +76,7 @@ namespace GameCult.Geometry public readonly struct CultSphere : IEquatable { /// Creates a sphere. + [JsonConstructor] public CultSphere(float3 center, float radius) { if (radius < 0f) diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs new file mode 100644 index 00000000..64702b74 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Text.Json; +using CultMath; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public sealed class CultMathJsonTests + { + private static readonly JsonSerializerOptions Options = new JsonSerializerOptions().AddCultMathConverters(); + + private static string Json(T value) => JsonSerializer.Serialize(value, Options); + + private static T Parse(string json) => JsonSerializer.Deserialize(json, Options)!; + + [Test] + public void EveryShape_IsPinned() + { + Json(new float2(1f, 2f)).Should().Be("""{"x":1,"y":2}"""); + Json(new float3(1f, 2f, 3f)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new float4(1f, 2f, 3f, 4f)).Should().Be("""{"x":1,"y":2,"z":3,"w":4}"""); + Json(new double2(1d, 2.5d)).Should().Be("""{"x":1,"y":2.5}"""); + Json(new double3(1d, 2d, 3d)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new int2(3, -4)).Should().Be("""{"x":3,"y":-4}"""); + Json(new int3(1, 2, 3)).Should().Be("""{"x":1,"y":2,"z":3}"""); + Json(new int4(1, 2, 3, 4)).Should().Be("""{"x":1,"y":2,"z":3,"w":4}"""); + Json(new bool2(true, false)).Should().Be("""{"x":true,"y":false}"""); + Json(new bool3(true, false, true)).Should().Be("""{"x":true,"y":false,"z":true}"""); + Json(new bool4(true, false, false, true)).Should().Be("""{"x":true,"y":false,"z":false,"w":true}"""); + Json(new quaternion(0f, 0f, 0f, 1f)).Should().Be("""{"x":0,"y":0,"z":0,"w":1}"""); + Json(new Color32(255, 128, 0, 7)).Should().Be("""{"r":255,"g":128,"b":0,"a":7}"""); + Json(new CultMath.Random(12345u)).Should().Be("""{"state":12345}"""); + Json(new float2x2(1f, 2f, 3f, 4f)).Should().Be("""[{"x":1,"y":2},{"x":3,"y":4}]"""); + Json(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)).Should().Be( + """[{"x":1,"y":2,"z":3},{"x":4,"y":5,"z":6},{"x":7,"y":8,"z":9}]"""); + Json(new rect(5f, 7f, -1f, -2f)).Should().Be("""{"min":{"x":-1,"y":-2},"max":{"x":5,"y":7}}"""); + Json(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be( + """{"nearest":{"x":1,"y":2,"z":3,"w":4},"edge":{"x":5,"y":6,"z":7,"w":8},"id":0.25}"""); + } + + [Test] + public void EveryShape_RoundTrips() + { + RoundTrip(new float2(1.5f, -2f)); + RoundTrip(new float3(1f, 2f, 3f)); + RoundTrip(new float4(1f, 2f, 3f, 4f)); + RoundTrip(new double2(1e300, -2.5)); + RoundTrip(new double3(1, 2, 3)); + RoundTrip(new int2(-7, 900000)); + RoundTrip(new int3(1, -2, 3)); + RoundTrip(new int4(1, 2, 3, int.MinValue)); + RoundTrip(new bool2(true, false)); + RoundTrip(new bool3(false, true, true)); + RoundTrip(new bool4(true, false, false, true)); + RoundTrip(new quaternion(0.1f, 0.2f, 0.3f, 0.9f)); + RoundTrip(new Color32(255, 128, 0, 7)); + RoundTrip(new CultMath.Random(12345u)); + RoundTrip(new float2x2(1f, 2f, 3f, 4f)); + RoundTrip(new float3x3(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)); + RoundTrip(new rect(-1f, -2f, 5f, 7f)); + RoundTrip(new CultCellular(new float4(1, 2, 3, 4), new float4(5, 6, 7, 8), 0.25f)); + } + + [Test] + public void Reading_SkipsUnknownPropertiesAndZeroesMissingOnes() + { + Parse("""{"z":3,"extra":[1,2],"x":1}""").Should().Be(new float3(1f, 0f, 3f)); + Parse("""{"r":1,"g":2,"b":3}""").Should().Be(new Color32(1, 2, 3, 255)); + Parse("null").Should().BeNull(); + } + + [Test] + public void Reading_RefusesShapesThatAreNotTheType() + { + Action array = () => Parse("[1,2]"); + array.Should().Throw(); + Action shortMatrix = () => Parse("""[{"x":1,"y":2}]"""); + shortMatrix.Should().Throw(); + Action nil = () => Parse("null"); + nil.Should().Throw(); + } + + [Test] + public void EveryPublicCultMathValueType_HasAConverter() + { + var valueTypes = typeof(float2).Assembly.GetExportedTypes() + .Where(t => t.IsValueType && !t.IsEnum) + .ToHashSet(); + + valueTypes.Except(CultMathJson.ConvertedTypes).Should().BeEmpty(); + CultMathJson.ConvertedTypes.Except(valueTypes).Should().BeEmpty(); + foreach (var type in valueTypes) + { + var json = JsonSerializer.Serialize(Activator.CreateInstance(type), type, Options); + JsonSerializer.Deserialize(json, type, Options).Should().Be(Activator.CreateInstance(type)); + } + } + + private static void RoundTrip(T value) => Parse(Json(value)).Should().Be(value); + } +} diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index daae992a..8741f891 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -47,6 +47,22 @@ namespace GameCult.Geometry.Tests sphere.XyCircle.Bounds.Should().Be(new rect(-4f, -3f, 6f, 7f)); } + [Test] + public void GeometryPrimitives_JsonOmitsComputedConvenienceProperties() + { + var options = new JsonSerializerOptions().AddCultMathConverters(); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); + var circle = new CultCircle(new float2(1f, 2f), 3f); + + var sphereJson = JsonSerializer.Serialize(sphere, options); + var circleJson = JsonSerializer.Serialize(circle, options); + + sphereJson.Should().Be("""{"Center":{"x":1,"y":2,"z":3},"Radius":5}"""); + circleJson.Should().Be("""{"Center":{"x":1,"y":2},"Radius":3}"""); + JsonSerializer.Deserialize(sphereJson, options).Should().Be(sphere); + JsonSerializer.Deserialize(circleJson, options).Should().Be(circle); + } + // The bytes CultCircle and CultSphere produced when they held CultVec2/CultVec3 (probed at 65311c9). [Test] public void GeometryPrimitives_KeepTheirPreCollapseBytes()