Add canonical System.Text.Json converters for every CultMath value type
CultMathJson is the JSON twin of CultMathResolver: vectors, quaternion and
Color32 are objects keyed by field name, matrices are arrays of row objects,
rect is {min,max}. Consumers add them with AddCultMathConverters(); CultLib owns
no JSON options. CultCircle and CultSphere serialize and deserialize through
them, with [JsonConstructor] so the struct constructor is used. The Caching
README documents each MessagePack and JSON shape in one table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
77602d5c0e
commit
20c30224fd
6 changed files with 290 additions and 0 deletions
143
src/GameCult.Caching.MessagePack/CultMathJson.cs
Normal file
143
src/GameCult.Caching.MessagePack/CultMathJson.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The canonical System.Text.Json shape of every public CultMath value type, the JSON twin of
|
||||
/// <see cref="CultMathResolver"/>. Vectors, quaternions and colors are objects keyed by their
|
||||
/// field names (<c>{"x":1,"y":2}</c>), matrices are arrays of row objects, <c>rect</c> is
|
||||
/// <c>{"min":..,"max":..}</c>, and <c>CultCellular</c> is <c>{"nearest":..,"edge":..,"id":..}</c>.
|
||||
/// Reading skips unknown properties and leaves missing ones at zero (a missing <c>Color32.a</c>
|
||||
/// 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 <see cref="AddCultMathConverters"/>; JSON options are the consumer's own.
|
||||
/// </summary>
|
||||
public static class CultMathJson
|
||||
{
|
||||
/// <summary>The CultMath types that have a converter, for coverage checks.</summary>
|
||||
public static IReadOnlyCollection<Type> ConvertedTypes => Converters.Keys;
|
||||
|
||||
/// <summary>Adds the CultMath converters to <paramref name="options"/> and returns it.</summary>
|
||||
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<T> : JsonConverter<T>
|
||||
{
|
||||
private readonly Action<Utf8JsonWriter, T> write;
|
||||
private readonly Func<JsonElement, T> read;
|
||||
|
||||
public ObjectShape(Action<Utf8JsonWriter, T> write, Func<JsonElement, T> 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<Type, JsonConverter> Shape<T>(Action<Utf8JsonWriter, T> write, Func<JsonElement, T> read) =>
|
||||
new(typeof(T), new ObjectShape<T>(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<Type, JsonConverter> Converters = Build();
|
||||
|
||||
private static Dictionary<Type, JsonConverter> Build()
|
||||
{
|
||||
var shapes = new[]
|
||||
{
|
||||
Shape<float2>(PutFloat2, GetFloat2),
|
||||
Shape<float3>(PutFloat3, GetFloat3),
|
||||
Shape<float4>(PutFloat4, GetFloat4),
|
||||
Shape<double2>((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<double3>((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<int2>((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<int3>((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<int4>((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<bool2>((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<bool3>((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<bool4>((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<quaternion>((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<Color32>((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<Random>((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<float2x2>((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<float3x3>((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<rect>((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<CultCellular>((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<Type, JsonConverter>();
|
||||
foreach (var shape in shapes) map.Add(shape.Key, shape.Value);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="..\..\packages\cultmath\src\CultMath\CultMath.csproj" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.7" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ namespace GameCult.Geometry
|
|||
public readonly struct CultCircle : IEquatable<CultCircle>
|
||||
{
|
||||
/// <summary>Creates a circle.</summary>
|
||||
[JsonConstructor]
|
||||
public CultCircle(float2 center, float radius)
|
||||
{
|
||||
if (radius < 0f)
|
||||
|
|
@ -75,6 +76,7 @@ namespace GameCult.Geometry
|
|||
public readonly struct CultSphere : IEquatable<CultSphere>
|
||||
{
|
||||
/// <summary>Creates a sphere.</summary>
|
||||
[JsonConstructor]
|
||||
public CultSphere(float3 center, float radius)
|
||||
{
|
||||
if (radius < 0f)
|
||||
|
|
|
|||
104
tests/GameCult.Caching.Tests/CultMathJsonTests.cs
Normal file
104
tests/GameCult.Caching.Tests/CultMathJsonTests.cs
Normal file
|
|
@ -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>(T value) => JsonSerializer.Serialize(value, Options);
|
||||
|
||||
private static T Parse<T>(string json) => JsonSerializer.Deserialize<T>(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<float3>("""{"z":3,"extra":[1,2],"x":1}""").Should().Be(new float3(1f, 0f, 3f));
|
||||
Parse<Color32>("""{"r":1,"g":2,"b":3}""").Should().Be(new Color32(1, 2, 3, 255));
|
||||
Parse<float2?>("null").Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Reading_RefusesShapesThatAreNotTheType()
|
||||
{
|
||||
Action array = () => Parse<float2>("[1,2]");
|
||||
array.Should().Throw<JsonException>();
|
||||
Action shortMatrix = () => Parse<float2x2>("""[{"x":1,"y":2}]""");
|
||||
shortMatrix.Should().Throw<JsonException>();
|
||||
Action nil = () => Parse<rect>("null");
|
||||
nil.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[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>(T value) => Parse<T>(Json(value)).Should().Be(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CultSphere>(sphereJson, options).Should().Be(sphere);
|
||||
JsonSerializer.Deserialize<CultCircle>(circleJson, options).Should().Be(circle);
|
||||
}
|
||||
|
||||
// The bytes CultCircle and CultSphere produced when they held CultVec2/CultVec3 (probed at 65311c9).
|
||||
[Test]
|
||||
public void GeometryPrimitives_KeepTheirPreCollapseBytes()
|
||||
|
|
|
|||
Loading…
Reference in a new issue