From 38b79a8d897ac5e2f3b3b24313a051b8b9a47a30 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 8 May 2026 01:49:44 +0100 Subject: [PATCH 001/498] Seed HLSL-shaped math library --- .gitignore | 7 ++ CultMath.slnx | 8 ++ README.md | 43 ++++++++ docs/design.md | 21 ++++ global.json | 6 ++ src/CultMath/CultMath.csproj | 21 ++++ src/CultMath/float2.cs | 26 +++++ src/CultMath/float3.cs | 31 ++++++ src/CultMath/float4.cs | 31 ++++++ src/CultMath/math.cs | 114 +++++++++++++++++++++ tests/CultMath.Tests/CultMath.Tests.csproj | 20 ++++ tests/CultMath.Tests/MathTests.cs | 38 +++++++ 12 files changed, 366 insertions(+) create mode 100644 .gitignore create mode 100644 CultMath.slnx create mode 100644 README.md create mode 100644 docs/design.md create mode 100644 global.json create mode 100644 src/CultMath/CultMath.csproj create mode 100644 src/CultMath/float2.cs create mode 100644 src/CultMath/float3.cs create mode 100644 src/CultMath/float4.cs create mode 100644 src/CultMath/math.cs create mode 100644 tests/CultMath.Tests/CultMath.Tests.csproj create mode 100644 tests/CultMath.Tests/MathTests.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..59e67361 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/bin/ +**/obj/ +artifacts/ +*.user +*.suo +.vs/ +.idea/ diff --git a/CultMath.slnx b/CultMath.slnx new file mode 100644 index 00000000..95834573 --- /dev/null +++ b/CultMath.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..c3b1809d --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# CultMath + +Clean-room C# math shaped for people who think in HLSL. + +CultMath provides lowercase vector types and shader-style intrinsics so +math-heavy renderer code can move between C# and HLSL without turning every +line into a translation exercise. It is not a Unity.Mathematics clone. It is a +small, Aquarium-born library built from public shader semantics and plain .NET. + +## Current Surface + +- `float2`, `float3`, `float4` +- component-wise arithmetic +- scalar-to-vector splats +- swizzles for common lanes +- conversions to and from `System.Numerics` +- `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, + `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, + `cross`, `length`, `distance`, `normalize`, and `reflect` + +## Example + +```csharp +using CultMath; + +float3 normal = math.normalize(new float3(0.25f, 1.0f, -0.1f)); +float rim = math.saturate(1.0f - math.dot(normal, new float3(0.0f, 0.0f, -1.0f))); +float glow = math.smoothstep(0.2f, 1.0f, rim); +``` + +## Build + +```powershell +dotnet build CultMath.slnx +dotnet test CultMath.slnx +``` + +## Doctrine + +The goal is mechanical sympathy between CPU-side authoring and GPU-side shader +math. If an intrinsic exists here, it should behave like the shader concept +unless .NET numeric rules make that impossible. Weird deviations get tests and +documentation, not folklore. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 00000000..62102739 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,21 @@ +# Design + +CultMath exists to make renderer math readable in both places it needs to live: +C# host code and HLSL shader code. + +## Rules + +- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, and `math`. +- Implement public shader semantics directly; do not copy Unity.Mathematics. +- Prefer small immutable value types. +- Prefer component-wise overloads over clever generic machinery until the API + earns that complexity. +- Keep the first surface boring and exact. Noise, matrices, quaternions, and + packing helpers come later. + +## Non-Goals + +- Compiling C# to shaders. +- Replacing `System.Numerics` for general .NET work. +- Recreating every Unity.Mathematics type because a spreadsheet somewhere says + “coverage.” That way lies decorative obesity. diff --git a/global.json b/global.json new file mode 100644 index 00000000..99b1511d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.202", + "rollForward": "latestFeature" + } +} diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj new file mode 100644 index 00000000..f06b8bed --- /dev/null +++ b/src/CultMath/CultMath.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + CultMath + CultMath + CultMath + Clean-room C# math shaped for HLSL-style renderer code. + GameCult + https://github.com/GameCult/CultMath + README.md + $(NoWarn);CS8981 + + + + + + + diff --git a/src/CultMath/float2.cs b/src/CultMath/float2.cs new file mode 100644 index 00000000..1f1d66b1 --- /dev/null +++ b/src/CultMath/float2.cs @@ -0,0 +1,26 @@ +namespace CultMath; + +public readonly record struct float2(float x, float y) +{ + public static readonly float2 zero = new(0.0f, 0.0f); + public static readonly float2 one = new(1.0f, 1.0f); + + public float this[int index] => index switch + { + 0 => x, + 1 => y, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator float2(float value) => new(value, value); + public static explicit operator System.Numerics.Vector2(float2 value) => new(value.x, value.y); + public static explicit operator float2(System.Numerics.Vector2 value) => new(value.X, value.Y); + + public static float2 operator +(float2 value) => value; + public static float2 operator -(float2 value) => new(-value.x, -value.y); + public static float2 operator +(float2 left, float2 right) => new(left.x + right.x, left.y + right.y); + public static float2 operator -(float2 left, float2 right) => new(left.x - right.x, left.y - right.y); + public static float2 operator *(float2 left, float2 right) => new(left.x * right.x, left.y * right.y); + public static float2 operator /(float2 left, float2 right) => new(left.x / right.x, left.y / right.y); + public static float2 operator %(float2 left, float2 right) => new(left.x % right.x, left.y % right.y); +} diff --git a/src/CultMath/float3.cs b/src/CultMath/float3.cs new file mode 100644 index 00000000..037e8838 --- /dev/null +++ b/src/CultMath/float3.cs @@ -0,0 +1,31 @@ +namespace CultMath; + +public readonly record struct float3(float x, float y, float z) +{ + public static readonly float3 zero = new(0.0f, 0.0f, 0.0f); + public static readonly float3 one = new(1.0f, 1.0f, 1.0f); + + public float2 xy => new(x, y); + public float2 xz => new(x, z); + public float2 yz => new(y, z); + + public float this[int index] => index switch + { + 0 => x, + 1 => y, + 2 => z, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator float3(float value) => new(value, value, value); + public static explicit operator System.Numerics.Vector3(float3 value) => new(value.x, value.y, value.z); + public static explicit operator float3(System.Numerics.Vector3 value) => new(value.X, value.Y, value.Z); + + public static float3 operator +(float3 value) => value; + public static float3 operator -(float3 value) => new(-value.x, -value.y, -value.z); + public static float3 operator +(float3 left, float3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); + public static float3 operator -(float3 left, float3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); + public static float3 operator *(float3 left, float3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); + public static float3 operator /(float3 left, float3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); + public static float3 operator %(float3 left, float3 right) => new(left.x % right.x, left.y % right.y, left.z % right.z); +} diff --git a/src/CultMath/float4.cs b/src/CultMath/float4.cs new file mode 100644 index 00000000..d9ec0982 --- /dev/null +++ b/src/CultMath/float4.cs @@ -0,0 +1,31 @@ +namespace CultMath; + +public readonly record struct float4(float x, float y, float z, float w) +{ + public static readonly float4 zero = new(0.0f, 0.0f, 0.0f, 0.0f); + public static readonly float4 one = new(1.0f, 1.0f, 1.0f, 1.0f); + + public float2 xy => new(x, y); + public float3 xyz => new(x, y, z); + + public float this[int index] => index switch + { + 0 => x, + 1 => y, + 2 => z, + 3 => w, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public static implicit operator float4(float value) => new(value, value, value, value); + public static explicit operator System.Numerics.Vector4(float4 value) => new(value.x, value.y, value.z, value.w); + public static explicit operator float4(System.Numerics.Vector4 value) => new(value.X, value.Y, value.Z, value.W); + + public static float4 operator +(float4 value) => value; + public static float4 operator -(float4 value) => new(-value.x, -value.y, -value.z, -value.w); + public static float4 operator +(float4 left, float4 right) => new(left.x + right.x, left.y + right.y, left.z + right.z, left.w + right.w); + public static float4 operator -(float4 left, float4 right) => new(left.x - right.x, left.y - right.y, left.z - right.z, left.w - right.w); + public static float4 operator *(float4 left, float4 right) => new(left.x * right.x, left.y * right.y, left.z * right.z, left.w * right.w); + public static float4 operator /(float4 left, float4 right) => new(left.x / right.x, left.y / right.y, left.z / right.z, left.w / right.w); + public static float4 operator %(float4 left, float4 right) => new(left.x % right.x, left.y % right.y, left.z % right.z, left.w % right.w); +} diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs new file mode 100644 index 00000000..6b0cf455 --- /dev/null +++ b/src/CultMath/math.cs @@ -0,0 +1,114 @@ +namespace CultMath; + +public static class math +{ + public const float PI = MathF.PI; + public const float TAU = MathF.PI * 2.0f; + public const float HALF_PI = MathF.PI * 0.5f; + + public static float radians(float degrees) => degrees * (PI / 180.0f); + public static float degrees(float radians) => radians * (180.0f / PI); + + public static float abs(float value) => MathF.Abs(value); + public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); + public static float3 abs(float3 value) => new(abs(value.x), abs(value.y), abs(value.z)); + public static float4 abs(float4 value) => new(abs(value.x), abs(value.y), abs(value.z), abs(value.w)); + + public static float floor(float value) => MathF.Floor(value); + public static float2 floor(float2 value) => new(floor(value.x), floor(value.y)); + public static float3 floor(float3 value) => new(floor(value.x), floor(value.y), floor(value.z)); + public static float4 floor(float4 value) => new(floor(value.x), floor(value.y), floor(value.z), floor(value.w)); + + public static float ceil(float value) => MathF.Ceiling(value); + public static float2 ceil(float2 value) => new(ceil(value.x), ceil(value.y)); + public static float3 ceil(float3 value) => new(ceil(value.x), ceil(value.y), ceil(value.z)); + public static float4 ceil(float4 value) => new(ceil(value.x), ceil(value.y), ceil(value.z), ceil(value.w)); + + public static float frac(float value) => value - floor(value); + public static float2 frac(float2 value) => value - floor(value); + public static float3 frac(float3 value) => value - floor(value); + public static float4 frac(float4 value) => value - floor(value); + + public static float min(float left, float right) => MathF.Min(left, right); + public static float2 min(float2 left, float2 right) => new(min(left.x, right.x), min(left.y, right.y)); + public static float3 min(float3 left, float3 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z)); + public static float4 min(float4 left, float4 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z), min(left.w, right.w)); + + public static float max(float left, float right) => MathF.Max(left, right); + public static float2 max(float2 left, float2 right) => new(max(left.x, right.x), max(left.y, right.y)); + public static float3 max(float3 left, float3 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z)); + public static float4 max(float4 left, float4 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z), max(left.w, right.w)); + + public static float clamp(float value, float minimum, float maximum) => min(max(value, minimum), maximum); + public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum); + public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum); + public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum); + + public static float saturate(float value) => clamp(value, 0.0f, 1.0f); + public static float2 saturate(float2 value) => clamp(value, 0.0f, 1.0f); + public static float3 saturate(float3 value) => clamp(value, 0.0f, 1.0f); + public static float4 saturate(float4 value) => clamp(value, 0.0f, 1.0f); + + public static float lerp(float start, float end, float amount) => start + (end - start) * amount; + public static float2 lerp(float2 start, float2 end, float2 amount) => start + (end - start) * amount; + public static float3 lerp(float3 start, float3 end, float3 amount) => start + (end - start) * amount; + public static float4 lerp(float4 start, float4 end, float4 amount) => start + (end - start) * amount; + + public static float step(float edge, float value) => value < edge ? 0.0f : 1.0f; + public static float2 step(float2 edge, float2 value) => new(step(edge.x, value.x), step(edge.y, value.y)); + public static float3 step(float3 edge, float3 value) => new(step(edge.x, value.x), step(edge.y, value.y), step(edge.z, value.z)); + public static float4 step(float4 edge, float4 value) => new(step(edge.x, value.x), step(edge.y, value.y), step(edge.z, value.z), step(edge.w, value.w)); + + public static float smoothstep(float minimum, float maximum, float value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float2 smoothstep(float2 minimum, float2 maximum, float2 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float3 smoothstep(float3 minimum, float3 maximum, float3 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float4 smoothstep(float4 minimum, float4 maximum, float4 value) + { + var t = saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0f - 2.0f * t); + } + + public static float dot(float2 left, float2 right) => left.x * right.x + left.y * right.y; + public static float dot(float3 left, float3 right) => left.x * right.x + left.y * right.y + left.z * right.z; + public static float dot(float4 left, float4 right) => left.x * right.x + left.y * right.y + left.z * right.z + left.w * right.w; + + public static float3 cross(float3 left, float3 right) => + new( + left.y * right.z - left.z * right.y, + left.z * right.x - left.x * right.z, + left.x * right.y - left.y * right.x); + + public static float lengthsq(float2 value) => dot(value, value); + public static float lengthsq(float3 value) => dot(value, value); + public static float lengthsq(float4 value) => dot(value, value); + + public static float length(float2 value) => MathF.Sqrt(lengthsq(value)); + public static float length(float3 value) => MathF.Sqrt(lengthsq(value)); + public static float length(float4 value) => MathF.Sqrt(lengthsq(value)); + + public static float distance(float2 left, float2 right) => length(left - right); + public static float distance(float3 left, float3 right) => length(left - right); + public static float distance(float4 left, float4 right) => length(left - right); + + public static float2 normalize(float2 value) => value / MathF.Max(length(value), 1.0e-20f); + public static float3 normalize(float3 value) => value / MathF.Max(length(value), 1.0e-20f); + public static float4 normalize(float4 value) => value / MathF.Max(length(value), 1.0e-20f); + + public static float2 reflect(float2 incident, float2 normal) => incident - 2.0f * dot(normal, incident) * normal; + public static float3 reflect(float3 incident, float3 normal) => incident - 2.0f * dot(normal, incident) * normal; +} diff --git a/tests/CultMath.Tests/CultMath.Tests.csproj b/tests/CultMath.Tests/CultMath.Tests.csproj new file mode 100644 index 00000000..1e77eba1 --- /dev/null +++ b/tests/CultMath.Tests/CultMath.Tests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs new file mode 100644 index 00000000..80b0ed95 --- /dev/null +++ b/tests/CultMath.Tests/MathTests.cs @@ -0,0 +1,38 @@ +using CultMath; +using Xunit; + +namespace CultMath.Tests; + +public sealed class MathTests +{ + [Fact] + public void FracMatchesShaderStylePositiveAndNegativeValues() + { + Assert.Equal(0.25f, math.frac(1.25f)); + Assert.Equal(0.75f, math.frac(-1.25f)); + } + + [Fact] + public void VectorOperationsAreComponentWise() + { + var value = new float3(1.0f, 2.0f, 3.0f) * new float3(4.0f, 5.0f, 6.0f); + + Assert.Equal(new float3(4.0f, 10.0f, 18.0f), value); + } + + [Fact] + public void NormalizeProducesUnitLength() + { + var value = math.normalize(new float3(3.0f, 4.0f, 0.0f)); + + Assert.Equal(1.0f, math.length(value), precision: 5); + } + + [Fact] + public void SmoothstepUsesHermiteRamp() + { + Assert.Equal(0.0f, math.smoothstep(0.0f, 1.0f, -1.0f)); + Assert.Equal(0.5f, math.smoothstep(0.0f, 1.0f, 0.5f), precision: 5); + Assert.Equal(1.0f, math.smoothstep(0.0f, 1.0f, 2.0f)); + } +} From 5cef6d951e3bf8bf49c89d81675a88605b5aa599 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 25 May 2026 20:33:04 +0100 Subject: [PATCH 002/498] Add shader-shaped vector constructors and trig intrinsics --- src/CultMath/float3.cs | 5 +++++ src/CultMath/float4.cs | 10 ++++++++++ src/CultMath/math.cs | 8 ++++++++ tests/CultMath.Tests/MathTests.cs | 17 +++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/src/CultMath/float3.cs b/src/CultMath/float3.cs index 037e8838..70cfa02b 100644 --- a/src/CultMath/float3.cs +++ b/src/CultMath/float3.cs @@ -5,6 +5,11 @@ public readonly record struct float3(float x, float y, float z) public static readonly float3 zero = new(0.0f, 0.0f, 0.0f); public static readonly float3 one = new(1.0f, 1.0f, 1.0f); + public float3(float2 xy, float z) + : this(xy.x, xy.y, z) + { + } + public float2 xy => new(x, y); public float2 xz => new(x, z); public float2 yz => new(y, z); diff --git a/src/CultMath/float4.cs b/src/CultMath/float4.cs index d9ec0982..c7bfb404 100644 --- a/src/CultMath/float4.cs +++ b/src/CultMath/float4.cs @@ -5,6 +5,16 @@ public readonly record struct float4(float x, float y, float z, float w) public static readonly float4 zero = new(0.0f, 0.0f, 0.0f, 0.0f); public static readonly float4 one = new(1.0f, 1.0f, 1.0f, 1.0f); + public float4(float2 xy, float z, float w) + : this(xy.x, xy.y, z, w) + { + } + + public float4(float3 xyz, float w) + : this(xyz.x, xyz.y, xyz.z, w) + { + } + public float2 xy => new(x, y); public float3 xyz => new(x, y, z); diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs index 6b0cf455..6d44f900 100644 --- a/src/CultMath/math.cs +++ b/src/CultMath/math.cs @@ -8,6 +8,14 @@ public static class math public static float radians(float degrees) => degrees * (PI / 180.0f); public static float degrees(float radians) => radians * (180.0f / PI); + public static float sin(float value) => MathF.Sin(value); + public static float cos(float value) => MathF.Cos(value); + public static float tan(float value) => MathF.Tan(value); + public static float asin(float value) => MathF.Asin(value); + public static float acos(float value) => MathF.Acos(value); + public static float atan(float value) => MathF.Atan(value); + public static float atan2(float y, float x) => MathF.Atan2(y, x); + public static float sqrt(float value) => MathF.Sqrt(value); public static float abs(float value) => MathF.Abs(value); public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 80b0ed95..8576a770 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -20,6 +20,14 @@ public sealed class MathTests Assert.Equal(new float3(4.0f, 10.0f, 18.0f), value); } + [Fact] + public void VectorConstructorsAcceptLowerDimensionVectors() + { + Assert.Equal(new float3(1.0f, 2.0f, 3.0f), new float3(new float2(1.0f, 2.0f), 3.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float2(1.0f, 2.0f), 3.0f, 4.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); + } + [Fact] public void NormalizeProducesUnitLength() { @@ -35,4 +43,13 @@ public sealed class MathTests Assert.Equal(0.5f, math.smoothstep(0.0f, 1.0f, 0.5f), precision: 5); Assert.Equal(1.0f, math.smoothstep(0.0f, 1.0f, 2.0f)); } + + [Fact] + public void TrigIntrinsicsUseRadians() + { + Assert.Equal(1.0f, math.sin(math.HALF_PI), precision: 5); + Assert.Equal(-1.0f, math.cos(math.PI), precision: 5); + Assert.Equal(1.0f, math.tan(math.PI * 0.25f), precision: 5); + Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); + } } From bfe356fe92e26a641d4db0ba592f23d7ee00396b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 3 Jun 2026 02:58:56 +0100 Subject: [PATCH 003/498] Add Rust-backed Voronoi batch kernel --- .gitignore | 9 +- README.md | 32 +++-- docs/design.md | 7 +- native/cultmath-core/Cargo.lock | 7 ++ native/cultmath-core/Cargo.toml | 13 ++ native/cultmath-core/src/lib.rs | 188 +++++++++++++++++++++++++++++ src/CultMath/Color32.cs | 4 + src/CultMath/CultMath.csproj | 1 + src/CultMath/CultMathTone.cs | 10 ++ src/CultMath/Voronoi.cs | 190 ++++++++++++++++++++++++++++++ tests/CultMath.Tests/MathTests.cs | 16 +++ 11 files changed, 461 insertions(+), 16 deletions(-) create mode 100644 native/cultmath-core/Cargo.lock create mode 100644 native/cultmath-core/Cargo.toml create mode 100644 native/cultmath-core/src/lib.rs create mode 100644 src/CultMath/Color32.cs create mode 100644 src/CultMath/CultMathTone.cs create mode 100644 src/CultMath/Voronoi.cs diff --git a/.gitignore b/.gitignore index 59e67361..6eae4393 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ +bin/ +obj/ **/bin/ **/obj/ -artifacts/ -*.user -*.suo -.vs/ -.idea/ +target/ +**/target/ diff --git a/README.md b/README.md index c3b1809d..b693d258 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # CultMath -Clean-room C# math shaped for people who think in HLSL. +Clean-room math shaped for people who think in HLSL. -CultMath provides lowercase vector types and shader-style intrinsics so -math-heavy renderer code can move between C# and HLSL without turning every -line into a translation exercise. It is not a Unity.Mathematics clone. It is a -small, Aquarium-born library built from public shader semantics and plain .NET. +CultMath provides lowercase vector types, shader-style intrinsics, and native +hot-path kernels so math-heavy renderer code can move between C#, Rust, and HLSL +without turning every line into a translation exercise. It is not a +Unity.Mathematics clone. It is a small, Aquarium-born library built from public +shader semantics. ## Current Surface @@ -17,6 +18,17 @@ small, Aquarium-born library built from public shader semantics and plain .NET. - `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, `cross`, `length`, `distance`, `normalize`, and `reflect` +- `Voronoi.SampleTones`, a C# batch surface that calls the Rust + `cultmath-core` native kernel when `cultmath_core` is available and falls back + to the managed parity path otherwise. + +## Native Core + +The canonical hot-path body lives in `native/cultmath-core`. It exports a C ABI +for batch kernels such as `cultmath_apollonian_voronoi_tones`. + +Runtime wrappers own syntax and integration. Rust owns optimized primitive +kernel behavior and parity fixtures. ## Example @@ -33,11 +45,13 @@ float glow = math.smoothstep(0.2f, 1.0f, rim); ```powershell dotnet build CultMath.slnx dotnet test CultMath.slnx +cargo test --manifest-path native/cultmath-core/Cargo.toml +cargo build --release --manifest-path native/cultmath-core/Cargo.toml ``` ## Doctrine -The goal is mechanical sympathy between CPU-side authoring and GPU-side shader -math. If an intrinsic exists here, it should behave like the shader concept -unless .NET numeric rules make that impossible. Weird deviations get tests and -documentation, not folklore. +The goal is mechanical sympathy between CPU-side authoring, native kernels, and +GPU-side shader math. If an intrinsic exists here, it should behave like the +shader concept unless a runtime's numeric rules make that impossible. Weird +deviations get tests and documentation, not folklore. diff --git a/docs/design.md b/docs/design.md index 62102739..3c87fb51 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,11 +1,14 @@ # Design -CultMath exists to make renderer math readable in both places it needs to live: -C# host code and HLSL shader code. +CultMath exists to make renderer math readable in every place it needs to live: +Rust hot kernels, C# host code, and HLSL shader code. ## Rules - Keep type names HLSL-shaped: `float2`, `float3`, `float4`, and `math`. +- Keep Rust as the owner of native hot-path kernels and parity fixtures. +- Keep C#, shader, and scripting surfaces as runtime wrappers, not competing + math authorities. - Implement public shader semantics directly; do not copy Unity.Mathematics. - Prefer small immutable value types. - Prefer component-wise overloads over clever generic machinery until the API diff --git a/native/cultmath-core/Cargo.lock b/native/cultmath-core/Cargo.lock new file mode 100644 index 00000000..09598b97 --- /dev/null +++ b/native/cultmath-core/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cultmath-core" +version = "0.1.0" diff --git a/native/cultmath-core/Cargo.toml b/native/cultmath-core/Cargo.toml new file mode 100644 index 00000000..cf7819a0 --- /dev/null +++ b/native/cultmath-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cultmath-core" +version = "0.1.0" +edition = "2021" + +[lib] +name = "cultmath_core" +crate-type = ["cdylib", "staticlib"] + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "abort" diff --git a/native/cultmath-core/src/lib.rs b/native/cultmath-core/src/lib.rs new file mode 100644 index 00000000..5a484608 --- /dev/null +++ b/native/cultmath-core/src/lib.rs @@ -0,0 +1,188 @@ +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CultMathColor32 { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +#[no_mangle] +pub unsafe extern "C" fn cultmath_apollonian_voronoi_tones( + xs: *const f32, + ys: *const f32, + tones: *const u8, + spans: *const f32, + count: usize, + resolution_y: f32, + frame_index: i32, + out_colors: *mut CultMathColor32, +) -> i32 { + if xs.is_null() || ys.is_null() || tones.is_null() || spans.is_null() || out_colors.is_null() { + return -1; + } + + let xs = unsafe { std::slice::from_raw_parts(xs, count) }; + let ys = unsafe { std::slice::from_raw_parts(ys, count) }; + let tones = unsafe { std::slice::from_raw_parts(tones, count) }; + let spans = unsafe { std::slice::from_raw_parts(spans, count) }; + let out_colors = unsafe { std::slice::from_raw_parts_mut(out_colors, count) }; + for index in 0..count { + out_colors[index] = tone_color( + xs[index], + ys[index], + resolution_y, + frame_index, + tones[index], + spans[index], + ); + } + + 0 +} + +fn tone_color(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> CultMathColor32 { + let sample = stable_sample(px, py, resolution_y, frame_index, tone, span); + let luminance = saturate(sample.0 * 0.299 + sample.1 * 0.587 + sample.2 * 0.114); + match tone { + 3 => { + let glow = 0.62 + luminance * 0.38; + color(255.0 * glow, 96.0 * glow, 14.0 * glow) + } + 4 => { + let glow = 0.68 + luminance * 0.32; + color(245.0 * glow, 248.0 * glow, 252.0 * glow) + } + _ => { + let (gain, lift) = match tone { + 1 => (0.25, 6.0), + 2 => (0.95, 20.0), + _ => (0.16, 2.0), + }; + color( + lift + sample.0 * 255.0 * gain, + lift + sample.1 * 255.0 * gain, + lift + sample.2 * 255.0 * gain, + ) + } + } +} + +fn stable_sample(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> (f32, f32, f32) { + let seed = px * 12.9898 + py * 78.233 + hash1((tone as f32 + 1.0) * 19.19); + let jitter_x = (hash1(seed) - 0.5) * span; + let jitter_y = (hash1(seed + 37.17) - 0.5) * span; + voronoi(px + jitter_x, py + jitter_y, resolution_y, frame_index) +} + +fn voronoi(px: f32, py: f32, resolution_y: f32, frame_index: i32) -> (f32, f32, f32) { + let time = frame_index as f32 / 120.0; + let scale = 6.0; + let x = scale * px / resolution_y.max(1.0); + let y = scale * py / resolution_y.max(1.0); + let nx = x.floor(); + let ny = y.floor(); + let fx = frac(x); + let fy = frac(y); + let mut distance = 8.0; + let mut red = 0.0; + let mut green = 0.0; + let mut blue = 0.0; + let smoothness = 0.005; + + for j in -2..=2 { + for i in -2..=2 { + let gx = i as f32; + let gy = j as f32; + let (mut ox, mut oy) = hash2(nx + gx, ny + gy); + let weight = ox * 0.5 + 0.5; + ox = 0.5 + 0.5 * (time + 6.2831 * ox).sin(); + oy = 0.5 + 0.5 * (time + 6.2831 * oy).sin(); + let dx = (gx - fx + ox).abs(); + let dy = (gy - fy + oy).abs(); + let d = dx.max(dy) * weight; + let seed = hash1((nx + gx) * 7.0 + (ny + gy) * 113.0); + let candidate_red = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 2.0).sin(); + let candidate_green = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 3.0).sin(); + let candidate_blue = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 2.0).sin(); + let h = smoothstep(0.0, 1.0, 0.5 + 0.5 * (distance - d) / smoothness); + let correction = h * (1.0 - h) * smoothness / (1.0 + 3.0 * smoothness); + distance = lerp(distance, d, h) - correction; + red = lerp(red, candidate_red, h) - correction; + green = lerp(green, candidate_green, h) - correction; + blue = lerp(blue, candidate_blue, h) - correction; + } + } + + let edge_dimming = 1.0 - 0.1 * smoothstep(0.04, 0.05, distance); + ( + (red * edge_dimming).max(0.0), + (green * edge_dimming).max(0.0), + (blue * edge_dimming).max(0.0), + ) +} + +fn hash1(value: f32) -> f32 { + frac(value.sin() * 43758.5453) +} + +fn hash2(x: f32, y: f32) -> (f32, f32) { + ( + frac((x * 127.1 + y * 311.7).sin() * 43758.5453), + frac((x * 269.5 + y * 183.3).sin() * 43758.5453), + ) +} + +fn frac(value: f32) -> f32 { + value - value.floor() +} + +fn saturate(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} + +fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { + let t = saturate((value - edge0) / (edge1 - edge0)); + t * t * (3.0 - 2.0 * t) +} + +fn lerp(start: f32, end: f32, amount: f32) -> f32 { + start + (end - start) * amount +} + +fn color(r: f32, g: f32, b: f32) -> CultMathColor32 { + CultMathColor32 { + r: r.clamp(0.0, 255.0) as u8, + g: g.clamp(0.0, 255.0) as u8, + b: b.clamp(0.0, 255.0) as u8, + a: 255, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_sampler_writes_colors() { + let xs = [0.0, 20.0, 100.0]; + let ys = [0.0, 30.0, 140.0]; + let tones = [0, 3, 4]; + let spans = [1920.0, 8.0, 8.0]; + let mut out = [CultMathColor32::default(); 3]; + let rc = unsafe { + cultmath_apollonian_voronoi_tones( + xs.as_ptr(), + ys.as_ptr(), + tones.as_ptr(), + spans.as_ptr(), + xs.len(), + 1080.0, + 12, + out.as_mut_ptr(), + ) + }; + assert_eq!(rc, 0); + assert!(out.iter().any(|color| color.r != 0 || color.g != 0 || color.b != 0)); + } +} diff --git a/src/CultMath/Color32.cs b/src/CultMath/Color32.cs new file mode 100644 index 00000000..e9a2b481 --- /dev/null +++ b/src/CultMath/Color32.cs @@ -0,0 +1,4 @@ +namespace CultMath; + +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public readonly record struct Color32(byte r, byte g, byte b, byte a = 255); diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index f06b8bed..83f6fb9d 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -12,6 +12,7 @@ https://github.com/GameCult/CultMath README.md $(NoWarn);CS8981 + true diff --git a/src/CultMath/CultMathTone.cs b/src/CultMath/CultMathTone.cs new file mode 100644 index 00000000..25dfffeb --- /dev/null +++ b/src/CultMath/CultMathTone.cs @@ -0,0 +1,10 @@ +namespace CultMath; + +public enum CultMathTone : byte +{ + Background = 0, + Panel = 1, + Edge = 2, + Header = 3, + Body = 4, +} diff --git a/src/CultMath/Voronoi.cs b/src/CultMath/Voronoi.cs new file mode 100644 index 00000000..52e6acb6 --- /dev/null +++ b/src/CultMath/Voronoi.cs @@ -0,0 +1,190 @@ +using System.Runtime.InteropServices; + +namespace CultMath; + +public static class Voronoi +{ + public static bool NativeAvailable { get; private set; } = true; + + public static Color32 SampleTone(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + Span xs = stackalloc[] { x }; + Span ys = stackalloc[] { y }; + Span tones = stackalloc[] { tone }; + Span spans = stackalloc[] { span }; + Span colors = stackalloc Color32[1]; + SampleTones(xs, ys, tones, spans, resolutionY, frameIndex, colors); + return colors[0]; + } + + public static void SampleTones( + ReadOnlySpan xs, + ReadOnlySpan ys, + ReadOnlySpan tones, + ReadOnlySpan spans, + float resolutionY, + int frameIndex, + Span colors) + { + if (xs.Length != ys.Length || xs.Length != tones.Length || xs.Length != spans.Length || xs.Length != colors.Length) + { + throw new ArgumentException("CultMath Voronoi batch spans must have equal length."); + } + + if (xs.IsEmpty) + { + return; + } + + if (NativeAvailable && TrySampleNative(xs, ys, tones, spans, resolutionY, frameIndex, colors)) + { + return; + } + + for (var index = 0; index < xs.Length; index++) + { + colors[index] = SampleManaged(xs[index], ys[index], resolutionY, frameIndex, tones[index], spans[index]); + } + } + + private static unsafe bool TrySampleNative( + ReadOnlySpan xs, + ReadOnlySpan ys, + ReadOnlySpan tones, + ReadOnlySpan spans, + float resolutionY, + int frameIndex, + Span colors) + { + try + { + fixed (float* xPtr = xs) + fixed (float* yPtr = ys) + fixed (CultMathTone* tonePtr = tones) + fixed (float* spanPtr = spans) + fixed (Color32* colorPtr = colors) + { + var result = Native.cultmath_apollonian_voronoi_tones( + xPtr, + yPtr, + (byte*)tonePtr, + spanPtr, + (nuint)xs.Length, + resolutionY, + frameIndex, + colorPtr); + if (result == 0) + { + return true; + } + } + } + catch (DllNotFoundException) + { + NativeAvailable = false; + } + catch (EntryPointNotFoundException) + { + NativeAvailable = false; + } + + return false; + } + + private static Color32 SampleManaged(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + var sample = StableSample(x, y, resolutionY, frameIndex, tone, span); + var luminance = math.saturate(sample.x * 0.299f + sample.y * 0.587f + sample.z * 0.114f); + if (tone == CultMathTone.Header) + { + var glow = 0.62f + luminance * 0.38f; + return new Color32((byte)(255 * glow), (byte)(96 * glow), (byte)(14 * glow)); + } + + if (tone == CultMathTone.Body) + { + var glow = 0.68f + luminance * 0.32f; + return new Color32((byte)(245 * glow), (byte)(248 * glow), (byte)(252 * glow)); + } + + var gain = tone switch + { + CultMathTone.Panel => 0.25f, + CultMathTone.Edge => 0.95f, + _ => 0.16f, + }; + var lift = tone switch + { + CultMathTone.Panel => 6.0f, + CultMathTone.Edge => 20.0f, + _ => 2.0f, + }; + return new Color32( + (byte)Math.Clamp(lift + sample.x * 255.0f * gain, 0, 255), + (byte)Math.Clamp(lift + sample.y * 255.0f * gain, 0, 255), + (byte)Math.Clamp(lift + sample.z * 255.0f * gain, 0, 255)); + } + + private static float3 StableSample(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) + { + var seed = x * 12.9898f + y * 78.233f + Hash1(((byte)tone + 1.0f) * 19.19f); + var jitter = new float2((Hash1(seed) - 0.5f) * span, (Hash1(seed + 37.17f) - 0.5f) * span); + return SampleField(x + jitter.x, y + jitter.y, resolutionY, frameIndex); + } + + private static float3 SampleField(float px, float py, float resolutionY, int frameIndex) + { + var time = frameIndex / 120.0f; + var p = new float2(6.0f * px / Math.Max(1.0f, resolutionY), 6.0f * py / Math.Max(1.0f, resolutionY)); + var n = math.floor(p); + var f = math.frac(p); + var distance = 8.0f; + var color = float3.zero; + const float smoothness = 0.005f; + for (var j = -2; j <= 2; j++) + { + for (var i = -2; i <= 2; i++) + { + var g = new float2(i, j); + var o = Hash2(n + g); + var weight = o.x * 0.5f + 0.5f; + o = 0.5f + 0.5f * new float2(math.sin(time + 6.2831f * o.x), math.sin(time + 6.2831f * o.y)); + var d2 = math.abs(g - f + o); + var d = Math.Max(d2.x, d2.y) * weight; + var seed = Hash1(math.dot(n + g, new float2(7.0f, 113.0f))); + var candidate = 0.5f + 0.5f * new float3( + math.sin(seed * 2.5f + 3.5f + 2.0f), + math.sin(seed * 2.5f + 3.5f + 3.0f), + math.sin(seed * 2.5f + 3.5f + 2.0f)); + var h = math.smoothstep(0.0f, 1.0f, 0.5f + 0.5f * (distance - d) / smoothness); + var correction = h * (1.0f - h) * smoothness / (1.0f + 3.0f * smoothness); + distance = math.lerp(distance, d, h) - correction; + color = math.lerp(color, candidate, h) - correction; + } + } + + return math.max(float3.zero, color * (1.0f - 0.1f * math.smoothstep(0.04f, 0.05f, distance))); + } + + private static float Hash1(float value) => math.frac(math.sin(value) * 43758.5453f); + + private static float2 Hash2(float2 value) + { + var p = new float2(math.dot(value, new float2(127.1f, 311.7f)), math.dot(value, new float2(269.5f, 183.3f))); + return math.frac(new float2(math.sin(p.x) * 43758.5453f, math.sin(p.y) * 43758.5453f)); + } + + private static unsafe partial class Native + { + [DllImport("cultmath_core", CallingConvention = CallingConvention.Cdecl)] + public static extern int cultmath_apollonian_voronoi_tones( + float* xs, + float* ys, + byte* tones, + float* spans, + nuint count, + float resolutionY, + int frameIndex, + Color32* outColors); + } +} diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 8576a770..2e0cea93 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -52,4 +52,20 @@ public sealed class MathTests Assert.Equal(1.0f, math.tan(math.PI * 0.25f), precision: 5); Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); } + + [Fact] + public void VoronoiBatchProducesToneMappedColors() + { + var xs = new[] { 0.0f, 24.0f, 96.0f }; + var ys = new[] { 0.0f, 36.0f, 144.0f }; + var tones = new[] { CultMathTone.Background, CultMathTone.Header, CultMathTone.Body }; + var spans = new[] { 1920.0f, 8.0f, 8.0f }; + var colors = new Color32[xs.Length]; + + Voronoi.SampleTones(xs, ys, tones, spans, 1080.0f, 12, colors); + + Assert.NotEqual(new Color32(), colors[0]); + Assert.True(colors[1].r > colors[1].g); + Assert.True(colors[2].r > 120 && colors[2].g > 120 && colors[2].b > 120); + } } From b79b6d047fc850fa7ed38e0f4cc673b25ab50b23 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 3 Jun 2026 04:08:48 +0100 Subject: [PATCH 004/498] Broaden Voronoi color spectrum --- native/cultmath-core/src/lib.rs | 37 +++++++++++++++++---- src/CultMath/Voronoi.cs | 58 +++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/native/cultmath-core/src/lib.rs b/native/cultmath-core/src/lib.rs index 5a484608..46e7ced6 100644 --- a/native/cultmath-core/src/lib.rs +++ b/native/cultmath-core/src/lib.rs @@ -55,9 +55,9 @@ fn tone_color(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, s } _ => { let (gain, lift) = match tone { - 1 => (0.25, 6.0), - 2 => (0.95, 20.0), - _ => (0.16, 2.0), + 1 => (0.42, 14.0), + 2 => (1.05, 24.0), + _ => (0.24, 4.0), }; color( lift + sample.0 * 255.0 * gain, @@ -102,9 +102,8 @@ fn voronoi(px: f32, py: f32, resolution_y: f32, frame_index: i32) -> (f32, f32, let dy = (gy - fy + oy).abs(); let d = dx.max(dy) * weight; let seed = hash1((nx + gx) * 7.0 + (ny + gy) * 113.0); - let candidate_red = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 2.0).sin(); - let candidate_green = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 3.0).sin(); - let candidate_blue = 0.5 + 0.5 * (seed * 2.5 + 3.5 + 2.0).sin(); + let hue = frac((nx + gx) * 0.173 + (ny + gy) * 0.379 + seed * 0.431); + let (candidate_red, candidate_green, candidate_blue) = pastel_spectrum(hue); let h = smoothstep(0.0, 1.0, 0.5 + 0.5 * (distance - d) / smoothness); let correction = h * (1.0 - h) * smoothness / (1.0 + 3.0 * smoothness); distance = lerp(distance, d, h) - correction; @@ -150,6 +149,32 @@ fn lerp(start: f32, end: f32, amount: f32) -> f32 { start + (end - start) * amount } +fn pastel_spectrum(hue: f32) -> (f32, f32, f32) { + hsv_to_rgb(hue, 0.52, 0.98) +} + +fn hsv_to_rgb(hue: f32, saturation: f32, value: f32) -> (f32, f32, f32) { + let h = frac(hue) * 6.0; + let c = value * saturation; + let x = c * (1.0 - ((h % 2.0) - 1.0).abs()); + let m = value - c; + let (r, g, b) = if h < 1.0 { + (c, x, 0.0) + } else if h < 2.0 { + (x, c, 0.0) + } else if h < 3.0 { + (0.0, c, x) + } else if h < 4.0 { + (0.0, x, c) + } else if h < 5.0 { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + + (r + m, g + m, b + m) +} + fn color(r: f32, g: f32, b: f32) -> CultMathColor32 { CultMathColor32 { r: r.clamp(0.0, 255.0) as u8, diff --git a/src/CultMath/Voronoi.cs b/src/CultMath/Voronoi.cs index 52e6acb6..9791a8e0 100644 --- a/src/CultMath/Voronoi.cs +++ b/src/CultMath/Voronoi.cs @@ -109,15 +109,15 @@ public static class Voronoi var gain = tone switch { - CultMathTone.Panel => 0.25f, - CultMathTone.Edge => 0.95f, - _ => 0.16f, + CultMathTone.Panel => 0.42f, + CultMathTone.Edge => 1.05f, + _ => 0.24f, }; var lift = tone switch { - CultMathTone.Panel => 6.0f, - CultMathTone.Edge => 20.0f, - _ => 2.0f, + CultMathTone.Panel => 14.0f, + CultMathTone.Edge => 24.0f, + _ => 4.0f, }; return new Color32( (byte)Math.Clamp(lift + sample.x * 255.0f * gain, 0, 255), @@ -152,10 +152,8 @@ public static class Voronoi var d2 = math.abs(g - f + o); var d = Math.Max(d2.x, d2.y) * weight; var seed = Hash1(math.dot(n + g, new float2(7.0f, 113.0f))); - var candidate = 0.5f + 0.5f * new float3( - math.sin(seed * 2.5f + 3.5f + 2.0f), - math.sin(seed * 2.5f + 3.5f + 3.0f), - math.sin(seed * 2.5f + 3.5f + 2.0f)); + var hue = math.frac((n.x + g.x) * 0.173f + (n.y + g.y) * 0.379f + seed * 0.431f); + var candidate = PastelSpectrum(hue); var h = math.smoothstep(0.0f, 1.0f, 0.5f + 0.5f * (distance - d) / smoothness); var correction = h * (1.0f - h) * smoothness / (1.0f + 3.0f * smoothness); distance = math.lerp(distance, d, h) - correction; @@ -168,6 +166,46 @@ public static class Voronoi private static float Hash1(float value) => math.frac(math.sin(value) * 43758.5453f); + private static float3 PastelSpectrum(float hue) + { + return HsvToRgb(hue, 0.52f, 0.98f); + } + + private static float3 HsvToRgb(float hue, float saturation, float value) + { + var h = math.frac(hue) * 6.0f; + var c = value * saturation; + var x = c * (1.0f - Math.Abs(h % 2.0f - 1.0f)); + var m = value - c; + float3 rgb; + if (h < 1.0f) + { + rgb = new float3(c, x, 0.0f); + } + else if (h < 2.0f) + { + rgb = new float3(x, c, 0.0f); + } + else if (h < 3.0f) + { + rgb = new float3(0.0f, c, x); + } + else if (h < 4.0f) + { + rgb = new float3(0.0f, x, c); + } + else if (h < 5.0f) + { + rgb = new float3(x, 0.0f, c); + } + else + { + rgb = new float3(c, 0.0f, x); + } + + return rgb + m; + } + private static float2 Hash2(float2 value) { var p = new float2(math.dot(value, new float2(127.1f, 311.7f)), math.dot(value, new float2(269.5f, 183.3f))); From f535801cd0ceb273fabb96a9bd8ad508489ef28c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 10 Jun 2026 21:22:43 +0100 Subject: [PATCH 005/498] Promote shader math primitives --- README.md | 30 ++- docs/design.md | 9 +- shaders/CultMath.hlsl | 254 ++++++++++++++++++++++++ src/CultMath/CultMath.csproj | 1 + src/CultMath/math.cs | 202 +++++++++++++++++++ tests/CultMath.Tests/HlslMirrorTests.cs | 63 ++++++ tests/CultMath.Tests/MathTests.cs | 48 +++++ 7 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 shaders/CultMath.hlsl create mode 100644 tests/CultMath.Tests/HlslMirrorTests.cs diff --git a/README.md b/README.md index b693d258..ef69df80 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ shader semantics. - conversions to and from `System.Numerics` - `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, - `cross`, `length`, `distance`, `normalize`, and `reflect` + `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential + `decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and + deterministic value-noise primitives +- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side + parity. HLSL already owns `float2`, `float3`, and `float4`; the include + exposes `cultmath_*` functions for shared semantics, including CultMath's + safe `normalize` contract. - `Voronoi.SampleTones`, a C# batch surface that calls the Rust `cultmath-core` native kernel when `cultmath_core` is available and falls back to the managed parity path otherwise. @@ -38,8 +44,25 @@ using CultMath; float3 normal = math.normalize(new float3(0.25f, 1.0f, -0.1f)); float rim = math.saturate(1.0f - math.dot(normal, new float3(0.0f, 0.0f, -1.0f))); float glow = math.smoothstep(0.2f, 1.0f, rim); +float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f)); ``` +```hlsl +#include "CultMath.hlsl" + +float3 normal = cultmath_normalize(float3(0.25, 1.0, -0.1)); +float rim = cultmath_saturate(1.0 - dot(normal, float3(0.0, 0.0, -1.0))); +float glow = cultmath_smoothstep(0.2, 1.0, rim); +float grain = cultmath_value_noise_bicubic(float2(12.0, 3.5)); +``` + +## Provenance + +The small spline, damping, and pursuit helpers were promoted from Aetheria's +local `AetheriaMath` surface. The value-noise family follows public shader +literature from Inigo Quilez's noise and mini-spline articles, expressed in +CultMath names with tests instead of copied project-local helper drift. + ## Build ```powershell @@ -55,3 +78,8 @@ The goal is mechanical sympathy between CPU-side authoring, native kernels, and GPU-side shader math. If an intrinsic exists here, it should behave like the shader concept unless a runtime's numeric rules make that impossible. Weird deviations get tests and documentation, not folklore. + +CultMath is still not a shader generator. Projects should include +`shaders/CultMath.hlsl` directly or add that directory to their shader include +path. If a shared operation needs CPU/GPU parity, put the primitive here and +test it here before copying local math helpers into a renderer or daemon. diff --git a/docs/design.md b/docs/design.md index 3c87fb51..8250ba78 100644 --- a/docs/design.md +++ b/docs/design.md @@ -9,16 +9,21 @@ Rust hot kernels, C# host code, and HLSL shader code. - Keep Rust as the owner of native hot-path kernels and parity fixtures. - Keep C#, shader, and scripting surfaces as runtime wrappers, not competing math authorities. +- Keep `shaders/CultMath.hlsl` as the canonical shader mirror for shared + intrinsics. Project-local shader helpers should move here once more than one + organ needs them or once CPU/GPU parity matters. - Implement public shader semantics directly; do not copy Unity.Mathematics. - Prefer small immutable value types. - Prefer component-wise overloads over clever generic machinery until the API earns that complexity. -- Keep the first surface boring and exact. Noise, matrices, quaternions, and - packing helpers come later. +- Keep the surface boring and exact. Noise primitives are allowed when they are + source-grounded, shader-shaped, and shared by more than one project. Matrices, + quaternions, and packing helpers still need an owner before they enter. ## Non-Goals - Compiling C# to shaders. +- Generating HLSL from Rust or Rust from HLSL. - Replacing `System.Numerics` for general .NET work. - Recreating every Unity.Mathematics type because a spreadsheet somewhere says “coverage.” That way lies decorative obesity. diff --git a/shaders/CultMath.hlsl b/shaders/CultMath.hlsl new file mode 100644 index 00000000..3cbb3fdd --- /dev/null +++ b/shaders/CultMath.hlsl @@ -0,0 +1,254 @@ +#ifndef CULTMATH_HLSL +#define CULTMATH_HLSL + +// CultMath HLSL mirror. +// +// HLSL already owns the float2/float3/float4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +static const float CULTMATH_PI = 3.14159265358979323846; +static const float CULTMATH_TAU = 6.28318530717958647692; +static const float CULTMATH_HALF_PI = 1.57079632679489661923; +static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +float2 cultmath_frac(float2 value) { return value - floor(value); } +float3 cultmath_frac(float3 value) { return value - floor(value); } +float4 cultmath_frac(float4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +float2 cultmath_clamp(float2 value, float2 minimum, float2 maximum) { return min(max(value, minimum), maximum); } +float3 cultmath_clamp(float3 value, float3 minimum, float3 maximum) { return min(max(value, minimum), maximum); } +float4 cultmath_clamp(float4 value, float4 minimum, float4 maximum) { return min(max(value, minimum), maximum); } + +float cultmath_saturate(float value) { return cultmath_clamp(value, 0.0, 1.0); } +float2 cultmath_saturate(float2 value) { return cultmath_clamp(value, float2(0.0, 0.0), float2(1.0, 1.0)); } +float3 cultmath_saturate(float3 value) { return cultmath_clamp(value, float3(0.0, 0.0, 0.0), float3(1.0, 1.0, 1.0)); } +float4 cultmath_saturate(float4 value) { return cultmath_clamp(value, float4(0.0, 0.0, 0.0, 0.0), float4(1.0, 1.0, 1.0, 1.0)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +float2 cultmath_lerp(float2 start, float2 end, float2 amount) { return start + (end - start) * amount; } +float3 cultmath_lerp(float3 start, float3 end, float3 amount) { return start + (end - start) * amount; } +float4 cultmath_lerp(float4 start, float4 end, float4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +float2 cultmath_step(float2 edge, float2 value) { return float2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +float3 cultmath_step(float3 edge, float3 value) { return float3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +float4 cultmath_step(float4 edge, float4 value) { return float4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +float2 cultmath_smoothstep(float2 minimum, float2 maximum, float2 value) +{ + float2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float2(3.0, 3.0) - float2(2.0, 2.0) * t); +} + +float3 cultmath_smoothstep(float3 minimum, float3 maximum, float3 value) +{ + float3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float3(3.0, 3.0, 3.0) - float3(2.0, 2.0, 2.0) * t); +} + +float4 cultmath_smoothstep(float4 minimum, float4 maximum, float4 value) +{ + float4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (float4(3.0, 3.0, 3.0, 3.0) - float4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +float cultmath_lengthsq(float2 value) { return dot(value, value); } +float cultmath_lengthsq(float3 value) { return dot(value, value); } +float cultmath_lengthsq(float4 value) { return dot(value, value); } + +float cultmath_distance(float2 left, float2 right) { return length(left - right); } +float cultmath_distance(float3 left, float3 right) { return length(left - right); } +float cultmath_distance(float4 left, float4 right) { return length(left - right); } + +float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } + +float2 cultmath_reflect(float2 incident, float2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float3 cultmath_reflect(float3 incident, float3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +float cultmath_csum(float2 value) { return value.x + value.y; } +float cultmath_csum(float3 value) { return value.x + value.y + value.z; } +float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float2 cultmath_decay(float2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float3 cultmath_decay(float3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +float4 cultmath_decay(float4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float2 cultmath_catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float3 cultmath_catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float4 cultmath_catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float3 cultmath_quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float3 cultmath_cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); } +float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); } + +float cultmath_value_noise(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 u = local * local * (float2(3.0, 3.0) - float2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + float2(1.0, 0.0)); + float c = cultmath_hash(cell + float2(0.0, 1.0)); + float d = cultmath_hash(cell + float2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(float2 position) +{ + float2 cell = floor(position); + float2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, -1.0)), cultmath_hash(cell + float2(0.0, -1.0)), cultmath_hash(cell + float2(1.0, -1.0)), cultmath_hash(cell + float2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 0.0)), cultmath_hash(cell + float2(0.0, 0.0)), cultmath_hash(cell + float2(1.0, 0.0)), cultmath_hash(cell + float2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 1.0)), cultmath_hash(cell + float2(0.0, 1.0)), cultmath_hash(cell + float2(1.0, 1.0)), cultmath_hash(cell + float2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 2.0)), cultmath_hash(cell + float2(0.0, 2.0)), cultmath_hash(cell + float2(1.0, 2.0)), cultmath_hash(cell + float2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +float cultmath_value_noise_texture(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale) +{ + return noise_texture.SampleLevel(noise_sampler, uv * scale, 0.0).r; +} + +float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale, float2 texel_size) +{ + float2 position = uv * scale; + float2 cell = floor(position); + float2 local = cultmath_frac(position); + float2 base_uv = cell / scale; + + float row0 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, -1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, -1.0), 0.0).r, + local.x); + float row1 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 0.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 0.0), 0.0).r, + local.x); + float row2 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 1.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 1.0), 0.0).r, + local.x); + float row3 = cultmath_catmullrom( + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 2.0), 0.0).r, + noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 2.0), 0.0).r, + local.x); + + return cultmath_catmullrom(row0, row1, row2, row3, local.y); +} + +#endif diff --git a/src/CultMath/CultMath.csproj b/src/CultMath/CultMath.csproj index 83f6fb9d..f43adce3 100644 --- a/src/CultMath/CultMath.csproj +++ b/src/CultMath/CultMath.csproj @@ -17,6 +17,7 @@ + diff --git a/src/CultMath/math.cs b/src/CultMath/math.cs index 6d44f900..817aa7fd 100644 --- a/src/CultMath/math.cs +++ b/src/CultMath/math.cs @@ -6,6 +6,16 @@ public static class math public const float TAU = MathF.PI * 2.0f; public const float HALF_PI = MathF.PI * 0.5f; + public static float2 float2(float x, float y) => new(x, y); + public static float2 float2(float value) => new(value, value); + public static float3 float3(float x, float y, float z) => new(x, y, z); + public static float3 float3(float2 xy, float z) => new(xy, z); + public static float3 float3(float value) => new(value, value, value); + public static float4 float4(float x, float y, float z, float w) => new(x, y, z, w); + public static float4 float4(float2 xy, float z, float w) => new(xy, z, w); + public static float4 float4(float3 xyz, float w) => new(xyz, w); + public static float4 float4(float value) => new(value, value, value, value); + public static float radians(float degrees) => degrees * (PI / 180.0f); public static float degrees(float radians) => radians * (180.0f / PI); public static float sin(float value) => MathF.Sin(value); @@ -16,6 +26,10 @@ public static class math public static float atan(float value) => MathF.Atan(value); public static float atan2(float y, float x) => MathF.Atan2(y, x); public static float sqrt(float value) => MathF.Sqrt(value); + public static float exp(float value) => MathF.Exp(value); + public static float2 exp(float2 value) => new(exp(value.x), exp(value.y)); + public static float3 exp(float3 value) => new(exp(value.x), exp(value.y), exp(value.z)); + public static float4 exp(float4 value) => new(exp(value.x), exp(value.y), exp(value.z), exp(value.w)); public static float abs(float value) => MathF.Abs(value); public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); @@ -119,4 +133,192 @@ public static class math public static float2 reflect(float2 incident, float2 normal) => incident - 2.0f * dot(normal, incident) * normal; public static float3 reflect(float3 incident, float3 normal) => incident - 2.0f * dot(normal, incident) * normal; + + public static float csum(float2 value) => value.x + value.y; + public static float csum(float3 value) => value.x + value.y + value.z; + public static float csum(float4 value) => value.x + value.y + value.z + value.w; + + public static float decay(float source, float lambda, float dt) => source * exp(-lambda * dt); + public static float2 decay(float2 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float3 decay(float3 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float4 decay(float4 source, float lambda, float dt) => source * exp(-lambda * dt); + public static float2 decay(float2 source, float2 lambda, float dt) => source * exp(-lambda * dt); + public static float3 decay(float3 source, float3 lambda, float dt) => source * exp(-lambda * dt); + public static float4 decay(float4 source, float4 lambda, float dt) => source * exp(-lambda * dt); + + public static float damp(float start, float end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float2 damp(float2 start, float2 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float3 damp(float3 start, float3 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + public static float4 damp(float4 start, float4 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); + + public static float first_order_intercept_time(float shotSpeed, float3 targetRelativePosition, float3 targetRelativeVelocity) + { + var velocitySquared = lengthsq(targetRelativeVelocity); + if (velocitySquared < 0.001f) + { + return 0.0f; + } + + var a = velocitySquared - shotSpeed * shotSpeed; + if (abs(a) < 0.001f) + { + var time = -lengthsq(targetRelativePosition) / (2.0f * dot(targetRelativeVelocity, targetRelativePosition)); + return max(time, 0.0f); + } + + var b = 2.0f * dot(targetRelativeVelocity, targetRelativePosition); + var c = lengthsq(targetRelativePosition); + var determinant = b * b - 4.0f * a * c; + + if (determinant > 0.0f) + { + var root = sqrt(determinant); + var t1 = (-b + root) / (2.0f * a); + var t2 = (-b - root) / (2.0f * a); + if (t1 > 0.0f) + { + return t2 > 0.0f ? min(t1, t2) : t1; + } + + return max(t2, 0.0f); + } + + if (determinant < 0.0f) + { + return 0.0f; + } + + return max(-b / (2.0f * a), 0.0f); + } + + public static float3 first_order_intercept( + float3 shooterPosition, + float3 shooterVelocity, + float shotSpeed, + float3 targetPosition, + float3 targetVelocity) + { + var targetRelativePosition = targetPosition - shooterPosition; + var targetRelativeVelocity = targetVelocity - shooterVelocity; + var time = first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); + return targetPosition + time * targetRelativeVelocity; + } + + public static float distance_to_segment(float2 point, float2 start, float2 end, out float2 closest) + { + var segment = end - start; + var segmentLengthSq = lengthsq(segment); + if (segmentLengthSq <= 0.0f) + { + closest = start; + return distance(point, start); + } + + var t = saturate(dot(point - start, segment) / segmentLengthSq); + closest = start + segment * t; + return distance(point, closest); + } + + public static float catmullrom(float p0, float p1, float p2, float p3, float t) + { + var t2 = t * t; + var t3 = t2 * t; + return 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3); + } + + public static float2 catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float3 catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float4 catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) => + 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); + + public static float quadratic_bezier(float p0, float p1, float p2, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * p0 + 2.0f * inv * t * p1 + t * t * p2; + } + + public static float3 quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * p0 + 2.0f * inv * t * p1 + t * t * p2; + } + + public static float cubic_bezier(float p0, float p1, float p2, float p3, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * inv * p0 + 3.0f * inv * inv * t * p1 + 3.0f * inv * t * t * p2 + t * t * t * p3; + } + + public static float3 cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) + { + t = saturate(t); + var inv = 1.0f - t; + return inv * inv * inv * p0 + 3.0f * inv * inv * t * p1 + 3.0f * inv * t * t * p2 + t * t * t * p3; + } + + public static float smoothstep01(float value) => smoothstep(0.0f, 1.0f, value); + + public static float smootherstep(float value) + { + value = saturate(value); + return value * value * value * (value * (value * 6.0f - 15.0f) + 10.0f); + } + + public static float hash(float value) => frac(sin(value) * 43758.5453f); + public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f))); + public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f))); + + public static float value_noise(float2 position) + { + var cell = floor(position); + var local = frac(position); + var u = local * local * (3.0f - 2.0f * local); + + var a = hash(cell); + var b = hash(cell + new float2(1.0f, 0.0f)); + var c = hash(cell + new float2(0.0f, 1.0f)); + var d = hash(cell + new float2(1.0f, 1.0f)); + + return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y); + } + + public static float value_noise_bicubic(float2 position) + { + var cell = floor(position); + var local = frac(position); + + var y0 = catmullrom( + hash(cell + new float2(-1.0f, -1.0f)), + hash(cell + new float2(0.0f, -1.0f)), + hash(cell + new float2(1.0f, -1.0f)), + hash(cell + new float2(2.0f, -1.0f)), + local.x); + var y1 = catmullrom( + hash(cell + new float2(-1.0f, 0.0f)), + hash(cell + new float2(0.0f, 0.0f)), + hash(cell + new float2(1.0f, 0.0f)), + hash(cell + new float2(2.0f, 0.0f)), + local.x); + var y2 = catmullrom( + hash(cell + new float2(-1.0f, 1.0f)), + hash(cell + new float2(0.0f, 1.0f)), + hash(cell + new float2(1.0f, 1.0f)), + hash(cell + new float2(2.0f, 1.0f)), + local.x); + var y3 = catmullrom( + hash(cell + new float2(-1.0f, 2.0f)), + hash(cell + new float2(0.0f, 2.0f)), + hash(cell + new float2(1.0f, 2.0f)), + hash(cell + new float2(2.0f, 2.0f)), + local.x); + + return catmullrom(y0, y1, y2, y3, local.y); + } } diff --git a/tests/CultMath.Tests/HlslMirrorTests.cs b/tests/CultMath.Tests/HlslMirrorTests.cs new file mode 100644 index 00000000..91a95f4d --- /dev/null +++ b/tests/CultMath.Tests/HlslMirrorTests.cs @@ -0,0 +1,63 @@ +using Xunit; + +namespace CultMath.Tests; + +public sealed class HlslMirrorTests +{ + [Fact] + public void HlslMirrorPublishesCultMathPrimitives() + { + var include = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "shaders", "CultMath.hlsl")); + var requiredSymbols = new[] + { + "cultmath_radians", + "cultmath_degrees", + "cultmath_frac", + "cultmath_clamp", + "cultmath_saturate", + "cultmath_lerp", + "cultmath_step", + "cultmath_smoothstep", + "cultmath_smootherstep", + "cultmath_lengthsq", + "cultmath_distance", + "cultmath_normalize", + "cultmath_reflect", + "cultmath_csum", + "cultmath_decay", + "cultmath_damp", + "cultmath_catmullrom", + "cultmath_quadratic_bezier", + "cultmath_cubic_bezier", + "cultmath_hash", + "cultmath_value_noise", + "cultmath_value_noise_bicubic", + "cultmath_value_noise_texture", + "cultmath_value_noise_texture_bicubic", + }; + + foreach (var symbol in requiredSymbols) + { + Assert.Contains(symbol, include); + } + + Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include); + Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include); + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "CultMath.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate CultMath repository root."); + } +} diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 2e0cea93..18fa203a 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -23,6 +23,9 @@ public sealed class MathTests [Fact] public void VectorConstructorsAcceptLowerDimensionVectors() { + Assert.Equal(new float2(1.0f, 2.0f), math.float2(1.0f, 2.0f)); + Assert.Equal(new float3(1.0f, 1.0f, 1.0f), math.float3(1.0f)); + Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); Assert.Equal(new float3(1.0f, 2.0f, 3.0f), new float3(new float2(1.0f, 2.0f), 3.0f)); Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float2(1.0f, 2.0f), 3.0f, 4.0f)); Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); @@ -42,6 +45,7 @@ public sealed class MathTests Assert.Equal(0.0f, math.smoothstep(0.0f, 1.0f, -1.0f)); Assert.Equal(0.5f, math.smoothstep(0.0f, 1.0f, 0.5f), precision: 5); Assert.Equal(1.0f, math.smoothstep(0.0f, 1.0f, 2.0f)); + Assert.Equal(0.5f, math.smootherstep(0.5f), precision: 5); } [Fact] @@ -53,6 +57,50 @@ public sealed class MathTests Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); } + [Fact] + public void AetheriaPrimitivesRemainSmallAndDeterministic() + { + Assert.Equal(6.0f, math.csum(new float3(1.0f, 2.0f, 3.0f))); + Assert.Equal(MathF.Exp(-2.0f), math.decay(1.0f, 2.0f, 1.0f), precision: 5); + Assert.Equal(1.0f - MathF.Exp(-2.0f), math.damp(0.0f, 1.0f, 2.0f, 1.0f), precision: 5); + + Assert.Equal(15.0f, math.catmullrom(0.0f, 10.0f, 20.0f, 30.0f, 0.5f), precision: 5); + Assert.Equal(0.25f, math.quadratic_bezier(0.0f, 0.0f, 1.0f, 0.5f), precision: 5); + Assert.Equal(0.5f, math.cubic_bezier(0.0f, 0.0f, 1.0f, 1.0f, 0.5f), precision: 5); + } + + [Fact] + public void SegmentDistanceReportsClosestPoint() + { + var distance = math.distance_to_segment(new float2(2.0f, 3.0f), new float2(0.0f, 0.0f), new float2(4.0f, 0.0f), out var closest); + + Assert.Equal(3.0f, distance, precision: 5); + Assert.Equal(new float2(2.0f, 0.0f), closest); + } + + [Fact] + public void FirstOrderInterceptSolvesSimpleClosingTarget() + { + var time = math.first_order_intercept_time( + 2.0f, + new float3(10.0f, 0.0f, 0.0f), + new float3(-1.0f, 0.0f, 0.0f)); + + Assert.Equal(10.0f / 3.0f, time, precision: 5); + } + + [Fact] + public void ValueNoiseIsDeterministicAndSmoothAtCellCorners() + { + var position = new float2(12.25f, -4.5f); + + Assert.Equal(math.value_noise(position), math.value_noise(position)); + Assert.Equal(math.hash(new float2(3.0f, 5.0f)), math.value_noise(new float2(3.0f, 5.0f)), precision: 5); + + var bicubic = math.value_noise_bicubic(position); + Assert.True(bicubic > -0.25f && bicubic < 1.25f); + } + [Fact] public void VoronoiBatchProducesToneMappedColors() { From 2c9f81fbf1abec2228569bb5d672aad76b5f1d8e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 10 Jun 2026 21:30:49 +0100 Subject: [PATCH 006/498] Add HLSL SPIR-V tool scripts --- .gitignore | 1 + README.md | 16 ++++++++++ tools/compile-hlsl-spirv.ps1 | 58 ++++++++++++++++++++++++++++++++++++ tools/get-dxc.ps1 | 42 ++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 tools/compile-hlsl-spirv.ps1 create mode 100644 tools/get-dxc.ps1 diff --git a/.gitignore b/.gitignore index 6eae4393..5ae20c8e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ obj/ **/obj/ target/ **/target/ +.tools/ diff --git a/README.md b/README.md index ef69df80..7627b773 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,22 @@ cargo test --manifest-path native/cultmath-core/Cargo.toml cargo build --release --manifest-path native/cultmath-core/Cargo.toml ``` +## Shader Tooling + +CultMath keeps portable DXC outside git under `.tools/`: + +```powershell +.\tools\get-dxc.ps1 +.\tools\compile-hlsl-spirv.ps1 ` + -ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl ` + -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` + -IncludePath E:\Projects\CultMath\shaders +``` + +DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the +script, but the current Windows DXC release compiled this shader successfully +with DXC's default Vulkan target while rejecting the dotted target-env value. + ## Doctrine The goal is mechanical sympathy between CPU-side authoring, native kernels, and diff --git a/tools/compile-hlsl-spirv.ps1 b/tools/compile-hlsl-spirv.ps1 new file mode 100644 index 00000000..98270cb0 --- /dev/null +++ b/tools/compile-hlsl-spirv.ps1 @@ -0,0 +1,58 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ShaderPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [string]$EntryPoint = "main", + [string]$Profile = "cs_6_0", + [string[]]$IncludePath = @(), + [string]$TargetEnv = "", + [string]$DxcPath = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $DxcPath) { + $DxcPath = Join-Path $repoRoot ".tools\dxc\current\bin\x64\dxc.exe" +} + +if (-not (Test-Path $DxcPath)) { + throw "DXC not found at '$DxcPath'. Run tools\get-dxc.ps1 first or pass -DxcPath." +} + +$resolvedShader = Resolve-Path $ShaderPath +$resolvedIncludes = @((Join-Path $repoRoot "shaders")) +foreach ($path in $IncludePath) { + $resolvedIncludes += (Resolve-Path $path) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +$arguments = @( + "-T", $Profile, + "-E", $EntryPoint, + "-spirv" +) + +if ($TargetEnv) { + $arguments += "-fspv-target-env=$TargetEnv" +} + +foreach ($include in $resolvedIncludes) { + $arguments += @("-I", $include) +} + +$arguments += @($resolvedShader, "-Fo", $OutputPath) + +& $DxcPath @arguments +if ($LASTEXITCODE -ne 0) { + throw "dxc.exe failed with exit code $LASTEXITCODE." +} + +Get-Item -LiteralPath $OutputPath diff --git a/tools/get-dxc.ps1 b/tools/get-dxc.ps1 new file mode 100644 index 00000000..2f3363f3 --- /dev/null +++ b/tools/get-dxc.ps1 @@ -0,0 +1,42 @@ +param( + [string]$InstallRoot = ".tools\dxc" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$installPath = Join-Path $repoRoot $InstallRoot +New-Item -ItemType Directory -Force -Path $installPath | Out-Null + +$release = Invoke-RestMethod ` + -Uri "https://api.github.com/repos/microsoft/DirectXShaderCompiler/releases/latest" ` + -Headers @{ "User-Agent" = "CultMath-DXC-Bootstrap" } + +$asset = $release.assets | + Where-Object { $_.name -like "dxc_*.zip" } | + Select-Object -First 1 + +if (-not $asset) { + throw "No Windows dxc_*.zip asset found on the latest DirectXShaderCompiler release." +} + +$zipPath = Join-Path $installPath $asset.name +Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue + +& curl.exe -L --retry 5 --retry-delay 2 --fail -o $zipPath $asset.browser_download_url +if ($LASTEXITCODE -ne 0) { + throw "curl.exe failed with exit code $LASTEXITCODE." +} + +$currentPath = Join-Path $installPath "current" +Remove-Item -LiteralPath $currentPath -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $currentPath | Out-Null +Expand-Archive -LiteralPath $zipPath -DestinationPath $currentPath -Force + +$dxcPath = Join-Path $currentPath "bin\x64\dxc.exe" +if (-not (Test-Path $dxcPath)) { + throw "DXC x64 executable was not found after extraction." +} + +& $dxcPath --version +Write-Host "DXC installed at $dxcPath" From 788a75dba537be7b475e3b240a5189363272eb1d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 11 Jun 2026 06:57:22 +0100 Subject: [PATCH 007/498] Add SPIR-V to Metal tooling --- README.md | 9 +++++++++ tools/compile-hlsl-spirv.ps1 | 11 +++++++++++ tools/compile-spirv-metal.ps1 | 34 ++++++++++++++++++++++++++++++++++ tools/get-spirv-cross.ps1 | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 tools/compile-spirv-metal.ps1 create mode 100644 tools/get-spirv-cross.ps1 diff --git a/README.md b/README.md index 7627b773..d6623e46 100644 --- a/README.md +++ b/README.md @@ -78,15 +78,24 @@ CultMath keeps portable DXC outside git under `.tools/`: ```powershell .\tools\get-dxc.ps1 +.\tools\get-spirv-cross.ps1 .\tools\compile-hlsl-spirv.ps1 ` -ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl ` -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` -IncludePath E:\Projects\CultMath\shaders +.\tools\compile-spirv-metal.ps1 ` + -SpirvPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` + -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.metal ``` DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the script, but the current Windows DXC release compiled this shader successfully with DXC's default Vulkan target while rejecting the dotted target-env value. +The SPIR-V script applies default Vulkan register shifts for cbuffers, textures, +UAVs, and samplers so cross-compiled Metal buffers do not alias by resource +class. SPIRV-Cross emits Metal Shading Language from SPIR-V on Windows; building +an iPad-ready `.metallib` still belongs to Apple's Metal compiler on an Apple +toolchain. ## Doctrine diff --git a/tools/compile-hlsl-spirv.ps1 b/tools/compile-hlsl-spirv.ps1 index 98270cb0..280e08d5 100644 --- a/tools/compile-hlsl-spirv.ps1 +++ b/tools/compile-hlsl-spirv.ps1 @@ -9,6 +9,10 @@ param( [string]$Profile = "cs_6_0", [string[]]$IncludePath = @(), [string]$TargetEnv = "", + [int]$VulkanCBufferShift = 0, + [int]$VulkanTextureShift = 10, + [int]$VulkanUavShift = 20, + [int]$VulkanSamplerShift = 30, [string]$DxcPath = "" ) @@ -44,6 +48,13 @@ if ($TargetEnv) { $arguments += "-fspv-target-env=$TargetEnv" } +$arguments += @( + "-fvk-b-shift", $VulkanCBufferShift.ToString(), "0", + "-fvk-t-shift", $VulkanTextureShift.ToString(), "0", + "-fvk-u-shift", $VulkanUavShift.ToString(), "0", + "-fvk-s-shift", $VulkanSamplerShift.ToString(), "0" +) + foreach ($include in $resolvedIncludes) { $arguments += @("-I", $include) } diff --git a/tools/compile-spirv-metal.ps1 b/tools/compile-spirv-metal.ps1 new file mode 100644 index 00000000..490e6331 --- /dev/null +++ b/tools/compile-spirv-metal.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SpirvPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [int]$MslVersion = 23000, + [string]$SpirvCrossPath = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $SpirvCrossPath) { + $SpirvCrossPath = Join-Path $repoRoot ".tools\spirv-cross\build\spirv-cross.exe" +} + +if (-not (Test-Path $SpirvCrossPath)) { + throw "SPIRV-Cross not found at '$SpirvCrossPath'. Run tools\get-spirv-cross.ps1 first or pass -SpirvCrossPath." +} + +$resolvedSpirv = Resolve-Path $SpirvPath +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +& $SpirvCrossPath $resolvedSpirv --msl --msl-version $MslVersion --output $OutputPath +if ($LASTEXITCODE -ne 0) { + throw "spirv-cross failed with exit code $LASTEXITCODE." +} + +Get-Item -LiteralPath $OutputPath diff --git a/tools/get-spirv-cross.ps1 b/tools/get-spirv-cross.ps1 new file mode 100644 index 00000000..f5105b9a --- /dev/null +++ b/tools/get-spirv-cross.ps1 @@ -0,0 +1,34 @@ +param( + [string]$InstallRoot = ".tools\spirv-cross" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$installPath = Join-Path $repoRoot $InstallRoot +$sourcePath = Join-Path $installPath "src" +$buildPath = Join-Path $installPath "build" + +New-Item -ItemType Directory -Force -Path $installPath | Out-Null + +if (-not (Test-Path $sourcePath)) { + git clone --depth 1 https://github.com/KhronosGroup/SPIRV-Cross.git $sourcePath +} + +cmake ` + -S $sourcePath ` + -B $buildPath ` + -G Ninja ` + -DSPIRV_CROSS_CLI=ON ` + -DSPIRV_CROSS_ENABLE_TESTS=OFF ` + -DSPIRV_CROSS_ENABLE_C_API=OFF + +cmake --build $buildPath --target spirv-cross --config Release + +$spirvCrossPath = Join-Path $buildPath "spirv-cross.exe" +if (-not (Test-Path $spirvCrossPath)) { + throw "SPIRV-Cross executable was not found after build." +} + +& $spirvCrossPath --version +Write-Host "SPIRV-Cross installed at $spirvCrossPath" From 26d9996d7b41e9fae09ba47976137e58259a4be9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 17 Jun 2026 02:53:02 +0100 Subject: [PATCH 008/498] Add SIMD SoA batch math kernels --- README.md | 3 + src/CultMath/BatchMath.cs | 147 ++++++++++++++++++++++++++++++ tests/CultMath.Tests/MathTests.cs | 37 ++++++++ 3 files changed, 187 insertions(+) create mode 100644 src/CultMath/BatchMath.cs diff --git a/README.md b/README.md index d6623e46..1050e4ec 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ shader semantics. - scalar-to-vector splats - swizzles for common lanes - conversions to and from `System.Numerics` +- `BatchMath` SoA kernels over `Span` / `ReadOnlySpan` using + `System.Numerics.Vector` when available, including radial falloff + acceleration and semi-implicit Euler integration - `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential diff --git a/src/CultMath/BatchMath.cs b/src/CultMath/BatchMath.cs new file mode 100644 index 00000000..df62e07c --- /dev/null +++ b/src/CultMath/BatchMath.cs @@ -0,0 +1,147 @@ +using System.Numerics; + +namespace CultMath; + +public static class BatchMath +{ + private const float MinimumDistanceSquared = 1.0e-8f; + + public static int LaneCount => Vector.Count; + + public static bool IsHardwareAccelerated => Vector.IsHardwareAccelerated; + + public static void Clear(Span values) => values.Clear(); + + public static void AddRadialFalloffAcceleration2D( + ReadOnlySpan positionX, + ReadOnlySpan positionY, + float centerX, + float centerY, + float strength, + float radius, + Span accelerationX, + Span accelerationY) + { + ValidateEqualLengths(positionX.Length, positionY.Length, accelerationX.Length, accelerationY.Length); + + if (radius <= 0.0f || strength == 0.0f || positionX.Length == 0) + { + return; + } + + var radiusVector = new Vector(radius); + var centerXVector = new Vector(centerX); + var centerYVector = new Vector(centerY); + var strengthVector = new Vector(strength); + var one = Vector.One; + var zero = Vector.Zero; + var minimumDistanceSquared = new Vector(MinimumDistanceSquared); + + var i = 0; + var lanes = Vector.Count; + for (; i <= positionX.Length - lanes; i += lanes) + { + var px = new Vector(positionX.Slice(i, lanes)); + var py = new Vector(positionY.Slice(i, lanes)); + var dx = centerXVector - px; + var dy = centerYVector - py; + var distanceSquared = Vector.Max(dx * dx + dy * dy, minimumDistanceSquared); + var distance = Vector.SquareRoot(distanceSquared); + var inRange = Vector.LessThanOrEqual(distance, radiusVector); + var falloff = Vector.Max(zero, one - distance / radiusVector); + var magnitude = strengthVector * falloff; + var ax = Vector.ConditionalSelect(inRange, dx / distance * magnitude, zero); + var ay = Vector.ConditionalSelect(inRange, dy / distance * magnitude, zero); + + (new Vector(accelerationX.Slice(i, lanes)) + ax).CopyTo(accelerationX.Slice(i, lanes)); + (new Vector(accelerationY.Slice(i, lanes)) + ay).CopyTo(accelerationY.Slice(i, lanes)); + } + + for (; i < positionX.Length; i++) + { + var dx = centerX - positionX[i]; + var dy = centerY - positionY[i]; + var distanceSquared = MathF.Max(dx * dx + dy * dy, MinimumDistanceSquared); + var distance = MathF.Sqrt(distanceSquared); + if (distance > radius) + { + continue; + } + + var falloff = MathF.Max(0.0f, 1.0f - distance / radius); + var magnitude = strength * falloff; + accelerationX[i] += dx / distance * magnitude; + accelerationY[i] += dy / distance * magnitude; + } + } + + public static void IntegrateSemiImplicitEuler2D( + float deltaTime, + ReadOnlySpan dynamicMask, + Span positionX, + Span positionY, + Span velocityX, + Span velocityY, + ReadOnlySpan accelerationX, + ReadOnlySpan accelerationY) + { + if (deltaTime <= 0.0f) + { + throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive."); + } + + ValidateEqualLengths( + dynamicMask.Length, + positionX.Length, + positionY.Length, + velocityX.Length, + velocityY.Length, + accelerationX.Length, + accelerationY.Length); + + var delta = new Vector(deltaTime); + var i = 0; + var lanes = Vector.Count; + for (; i <= positionX.Length - lanes; i += lanes) + { + var mask = new Vector(dynamicMask.Slice(i, lanes)); + var vx = new Vector(velocityX.Slice(i, lanes)); + var vy = new Vector(velocityY.Slice(i, lanes)); + var px = new Vector(positionX.Slice(i, lanes)); + var py = new Vector(positionY.Slice(i, lanes)); + var ax = new Vector(accelerationX.Slice(i, lanes)); + var ay = new Vector(accelerationY.Slice(i, lanes)); + + vx += ax * delta * mask; + vy += ay * delta * mask; + px += vx * delta * mask; + py += vy * delta * mask; + + vx.CopyTo(velocityX.Slice(i, lanes)); + vy.CopyTo(velocityY.Slice(i, lanes)); + px.CopyTo(positionX.Slice(i, lanes)); + py.CopyTo(positionY.Slice(i, lanes)); + } + + for (; i < positionX.Length; i++) + { + var mask = dynamicMask[i]; + velocityX[i] += accelerationX[i] * deltaTime * mask; + velocityY[i] += accelerationY[i] * deltaTime * mask; + positionX[i] += velocityX[i] * deltaTime * mask; + positionY[i] += velocityY[i] * deltaTime * mask; + } + } + + private static void ValidateEqualLengths(params int[] lengths) + { + var length = lengths[0]; + for (var i = 1; i < lengths.Length; i++) + { + if (lengths[i] != length) + { + throw new ArgumentException("CultMath batch spans must have equal length."); + } + } + } +} diff --git a/tests/CultMath.Tests/MathTests.cs b/tests/CultMath.Tests/MathTests.cs index 18fa203a..44dd1290 100644 --- a/tests/CultMath.Tests/MathTests.cs +++ b/tests/CultMath.Tests/MathTests.cs @@ -116,4 +116,41 @@ public sealed class MathTests Assert.True(colors[1].r > colors[1].g); Assert.True(colors[2].r > 120 && colors[2].g > 120 && colors[2].b > 120); } + + [Fact] + public void BatchRadialFalloffAccelerationMatchesScalarContract() + { + var xs = new[] { 0.0f, 5.0f, 10.0f }; + var ys = new[] { 0.0f, 0.0f, 0.0f }; + var ax = new float[xs.Length]; + var ay = new float[xs.Length]; + + BatchMath.AddRadialFalloffAcceleration2D(xs, ys, 10.0f, 0.0f, 10.0f, 20.0f, ax, ay); + + Assert.True(BatchMath.LaneCount >= 4); + Assert.Equal(5.0f, ax[0], precision: 5); + Assert.Equal(7.5f, ax[1], precision: 5); + Assert.Equal(0.0f, ax[2], precision: 5); + Assert.Equal(0.0f, ay[0], precision: 5); + } + + [Fact] + public void BatchEulerIntegrationHonorsDynamicMask() + { + var dynamicMask = new[] { 1.0f, 0.0f }; + var px = new[] { 0.0f, 0.0f }; + var py = new[] { 0.0f, 0.0f }; + var vx = new[] { 1.0f, 1.0f }; + var vy = new[] { 0.0f, 0.0f }; + var ax = new[] { 2.0f, 2.0f }; + var ay = new[] { 0.0f, 4.0f }; + + BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, px, py, vx, vy, ax, ay); + + Assert.Equal(2.0f, vx[0], precision: 5); + Assert.Equal(1.0f, px[0], precision: 5); + Assert.Equal(1.0f, vx[1], precision: 5); + Assert.Equal(0.0f, px[1], precision: 5); + Assert.Equal(0.0f, py[1], precision: 5); + } } From c911eb561d2e199fd8babe9548aa12e849e8e221 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 14:30:59 +0100 Subject: [PATCH 009/498] Add CultMesh typed document handles --- src/GameCult.Mesh/CultMesh.cs | 791 +++++ src/GameCult.Mesh/CultMeshPrimitives.cs | 2775 +++++++++++++++++ src/GameCult.Mesh/docs/public-api.md | 267 ++ .../CultMeshStreamingTests.cs | 999 ++++++ 4 files changed, 4832 insertions(+) create mode 100644 src/GameCult.Mesh/CultMeshPrimitives.cs diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 9cc40ca2..b4e93588 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; @@ -297,6 +298,796 @@ namespace GameCult.Mesh return new CultMeshStreamCatalog(); } + /// + /// Creates a managed Verse context for generated domain sugar. + /// + public static CultMeshVerse Verse( + string verseId, + string runtimeId, + CultMeshRouteHint? routeHint = null, + IEnumerable? claims = null) + { + return new CultMeshVerse(new CultMeshVerseContext(verseId, runtimeId, routeHint, claims)); + } + + /// + /// Creates a managed Verse context for generated domain sugar. + /// + public static Task ConnectVerseAsync( + string verseId, + string runtimeId, + CultMeshRouteHint? routeHint = null, + IEnumerable? claims = null) + { + return Task.FromResult(Verse(verseId, runtimeId, routeHint, claims)); + } + + /// + /// Starts a fluent typed operation context for one runtime. + /// + public static CultMeshOperationContextBuilder OperationContextFor(string runtimeId) + { + return new CultMeshOperationContextBuilder(runtimeId); + } + + /// + /// Starts a fluent typed query context for one runtime. + /// + public static CultMeshQueryContextBuilder QueryContextFor(string runtimeId) + { + return new CultMeshQueryContextBuilder(runtimeId); + } + + /// + /// Binds a typed operation handle to a Verse context. + /// + public static CultMeshBoundOperationHandle BindOperation( + CultMeshVerseContext context, + CultMeshOperationHandle operation) + { + return new CultMeshBoundOperationHandle(context, operation); + } + + /// + /// Binds a typed operation handle to a Verse. + /// + public static CultMeshBoundOperationHandle BindOperation( + CultMeshVerse verse, + CultMeshOperationHandle operation) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return BindOperation(verse.Context, operation); + } + + /// + /// Binds a typed query surface to a Verse context. + /// + public static CultMeshBoundQuerySurface BindQuery( + CultMeshVerseContext context, + CultMeshQuerySurface query) + { + return new CultMeshBoundQuerySurface(context, query); + } + + /// + /// Binds a typed query surface to a Verse. + /// + public static CultMeshBoundQuerySurface BindQuery( + CultMeshVerse verse, + CultMeshQuerySurface query) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return BindQuery(verse.Context, query); + } + + /// + /// Binds a typed live feed to a Verse context. + /// + public static CultMeshBoundLiveFeed BindLiveFeed( + CultMeshVerseContext context, + CultMeshLiveFeed feed) + { + return new CultMeshBoundLiveFeed(context, feed); + } + + /// + /// Binds a typed live feed to a Verse. + /// + public static CultMeshBoundLiveFeed BindLiveFeed( + CultMeshVerse verse, + CultMeshLiveFeed feed) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return BindLiveFeed(verse.Context, feed); + } + + /// + /// Binds a typed document live feed to a Verse context. + /// + public static CultMeshDocumentHandle BindDocument( + CultMeshVerseContext context, + CultMeshLiveFeed feed) + where TDocument : class + { + return new CultMeshDocumentHandle(BindLiveFeed(context, feed)); + } + + /// + /// Binds a typed document live feed to a Verse. + /// + public static CultMeshDocumentHandle BindDocument( + CultMeshVerse verse, + CultMeshLiveFeed feed) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return BindDocument(verse.Context, feed); + } + + /// + /// Binds a typed state pointer to a Verse context. + /// + public static CultMeshBoundStatePointer BindStatePointer( + CultMeshVerseContext context, + CultMeshStatePointer pointer) + { + return new CultMeshBoundStatePointer(context, pointer); + } + + /// + /// Binds a typed state pointer to a Verse. + /// + public static CultMeshBoundStatePointer BindStatePointer( + CultMeshVerse verse, + CultMeshStatePointer pointer) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return BindStatePointer(verse.Context, pointer); + } + + /// + /// Binds a mutable typed state pointer to a Verse context. + /// + public static CultMeshBoundMutableStatePointer BindMutableStatePointer( + CultMeshVerseContext context, + CultMeshMutableStatePointer pointer) + { + return new CultMeshBoundMutableStatePointer(context, pointer); + } + + /// + /// Binds a mutable typed state pointer to a Verse. + /// + public static CultMeshBoundMutableStatePointer BindMutableStatePointer( + CultMeshVerse verse, + CultMeshMutableStatePointer pointer) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return BindMutableStatePointer(verse.Context, pointer); + } + + /// + /// Creates a typed state pointer to Verse state. + /// + public static CultMeshStatePointer StatePointer( + string pointerId, + Func> resolve, + Func> watch, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + return new CultMeshStatePointer( + pointerId, + resolve, + watch, + routeHint, + sources); + } + + /// + /// Creates a typed state pointer to Verse state that can resolve through a query context. + /// + public static CultMeshStatePointer StatePointer( + string pointerId, + Func> resolve, + Func> watch, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + return new CultMeshStatePointer( + pointerId, + resolve, + watch, + routeHint, + sources); + } + + /// + /// Creates a mutable typed state pointer to Verse state. + /// + public static CultMeshMutableStatePointer MutableStatePointer( + string pointerId, + Func> resolve, + Func> watch, + Func replace, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + return new CultMeshMutableStatePointer( + pointerId, + resolve, + watch, + replace, + routeHint, + sources); + } + + /// + /// Creates a mutable typed state pointer to Verse state that can operate through a query context. + /// + public static CultMeshMutableStatePointer MutableStatePointer( + string pointerId, + Func> resolve, + Func> watch, + Func replace, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + return new CultMeshMutableStatePointer( + pointerId, + resolve, + watch, + replace, + routeHint, + sources); + } + + /// + /// Creates a typed projection source descriptor. + /// + public static CultMeshProjectionSource ProjectionSource( + string sourceId, + string? schemaId = null, + string? description = null) + { + return new CultMeshProjectionSource(sourceId, schemaId, description); + } + + /// + /// Creates a named state-reference resolver for portable UI/tool surfaces. + /// + public static CultMeshStateRefResolver StateRefResolver( + string resolverId, + Func resolve, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + { + if (resolve == null) throw new ArgumentNullException(nameof(resolve)); + return new CultMeshStateRefResolver( + resolverId, + (stateRef, _context) => resolve(stateRef), + sources, + routeHint); + } + + /// + /// Creates a named state-reference resolver for portable UI/tool surfaces. + /// + public static CultMeshStateRefResolver StateRefResolver( + string resolverId, + Func resolve, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + { + return new CultMeshStateRefResolver(resolverId, resolve, sources, routeHint); + } + + /// + /// Describes a state-reference resolver for catalogs and tools. + /// + public static CultMeshStateRefResolverDiagnostic DescribeStateRefResolver( + CultMeshStateRefResolver resolver) + { + if (resolver == null) throw new ArgumentNullException(nameof(resolver)); + return new CultMeshStateRefResolverDiagnostic( + resolver.ResolverId, + resolver.RouteHint, + resolver.Sources); + } + + /// + /// Creates a UI/tool binding descriptor from a component property to a typed state pointer. + /// + public static CultMeshStateBindingDescriptor StateBinding( + string targetProp, + CultMeshStatePointer pointer) + { + return CultMeshStateBindingDescriptor.FromPointer(targetProp, pointer); + } + + /// + /// Creates a UI/tool binding descriptor from a component property to a mutable typed state pointer. + /// + public static CultMeshStateBindingDescriptor StateBinding( + string targetProp, + CultMeshMutableStatePointer pointer) + { + if (pointer == null) throw new ArgumentNullException(nameof(pointer)); + return CultMeshStateBindingDescriptor.FromPointer(targetProp, pointer.AsStatePointer()); + } + + /// + /// Creates a UI/tool binding descriptor from a component property to a state pointer id. + /// + public static CultMeshStateBindingDescriptor StateBinding( + string targetProp, + string pointerId, + string? sourceId = null, + string? schemaId = null, + CultMeshRouteHint? routeHint = null) + { + return new CultMeshStateBindingDescriptor(targetProp, pointerId, sourceId, schemaId, routeHint); + } + + /// + /// Creates transport-friendly fields from a state binding descriptor. + /// + public static CultMeshStateBindingRecord StateBindingRecord(CultMeshStateBindingDescriptor? binding) + { + return CultMeshStateBindingRecord.FromBinding(binding); + } + + /// + /// Creates transport-friendly state binding fields directly. + /// + public static CultMeshStateBindingRecord StateBindingRecord( + string? targetProp, + string? pointerId, + string? sourceId = null, + string? schemaId = null, + string? routeKind = null, + string? routeDescription = null) + { + return new CultMeshStateBindingRecord( + targetProp, + pointerId, + sourceId, + schemaId, + routeKind, + routeDescription); + } + + /// + /// Creates a UI/tool command binding descriptor from a typed operation handle. + /// + public static CultMeshOperationBindingDescriptor OperationBinding( + CultMeshOperationHandle operation, + string? label = null, + string? schemaId = null, + CultMeshRouteHint? routeHint = null) + { + return CultMeshOperationBindingDescriptor.FromOperation(operation, label, schemaId, routeHint); + } + + /// + /// Creates a UI/tool command binding descriptor from a typed operation id. + /// + public static CultMeshOperationBindingDescriptor OperationBinding( + string operationId, + string? label = null, + string? schemaId = null, + CultMeshRouteHint? routeHint = null) + { + return new CultMeshOperationBindingDescriptor(operationId, label, schemaId, routeHint); + } + + /// + /// Creates transport-friendly fields from an operation binding descriptor. + /// + public static CultMeshOperationBindingRecord OperationBindingRecord( + CultMeshOperationBindingDescriptor? binding) + { + return CultMeshOperationBindingRecord.FromBinding(binding); + } + + /// + /// Creates transport-friendly operation binding fields directly. + /// + public static CultMeshOperationBindingRecord OperationBindingRecord( + string? operationId, + string? label = null, + string? schemaId = null, + string? routeKind = null, + string? routeDescription = null) + { + return new CultMeshOperationBindingRecord( + operationId, + label, + schemaId, + routeKind, + routeDescription); + } + + /// + /// Creates a concrete invocation descriptor from an advertised operation binding. + /// + public static CultMeshOperationInvocationDescriptor OperationInvocation( + CultMeshOperationBindingDescriptor binding, + string? idempotencyKey = null) + { + return CultMeshOperationInvocationDescriptor.FromBinding(binding, idempotencyKey); + } + + /// + /// Creates a concrete invocation descriptor from a typed operation handle. + /// + public static CultMeshOperationInvocationDescriptor OperationInvocation( + CultMeshOperationHandle operation, + string? schemaId = null, + CultMeshRouteHint? routeHint = null, + string? idempotencyKey = null) + { + return CultMeshOperationInvocationDescriptor.FromOperation( + operation, + schemaId, + routeHint, + idempotencyKey); + } + + /// + /// Creates a concrete invocation descriptor from a typed operation id. + /// + public static CultMeshOperationInvocationDescriptor OperationInvocation( + string operationId, + string? schemaId = null, + CultMeshRouteHint? routeHint = null, + string? idempotencyKey = null) + { + return new CultMeshOperationInvocationDescriptor(operationId, schemaId, routeHint, idempotencyKey); + } + + /// + /// Creates transport-friendly fields from a route hint. + /// + public static CultMeshRouteRecord RouteRecord(CultMeshRouteHint? routeHint) + { + return CultMeshRouteRecord.FromRoute(routeHint); + } + + /// + /// Creates transport-friendly route fields directly. + /// + public static CultMeshRouteRecord RouteRecord(string? kind, string? description = null) + { + return new CultMeshRouteRecord(kind, description); + } + + /// + /// Creates transport-friendly fields from a typed operation invocation. + /// + public static CultMeshOperationInvocationRecord OperationInvocationRecord( + CultMeshOperationInvocationDescriptor? invocation, + string? fallbackOperationId = null, + string? fallbackSchemaId = null, + CultMeshRouteHint? fallbackRouteHint = null, + string? fallbackIdempotencyKey = null) + { + return CultMeshOperationInvocationRecord.FromInvocation( + invocation, + fallbackOperationId, + fallbackSchemaId, + fallbackRouteHint, + fallbackIdempotencyKey); + } + + /// + /// Creates transport-friendly operation invocation fields directly. + /// + public static CultMeshOperationInvocationRecord OperationInvocationRecord( + string? operationId, + string? schemaId = null, + string? routeKind = null, + string? routeDescription = null, + string? idempotencyKey = null) + { + return new CultMeshOperationInvocationRecord( + operationId, + schemaId, + routeKind, + routeDescription, + idempotencyKey); + } + + /// + /// Creates an empty shared operation payload. + /// + public static CultMeshOperationPayload OperationPayload() + { + return CultMeshOperationPayload.Empty; + } + + /// + /// Creates a shared operation payload from string-compatible fields. + /// + public static CultMeshOperationPayload OperationPayload( + IEnumerable>? fields) + { + return CultMeshOperationPayload.FromStrings(fields); + } + + /// + /// Creates a shared operation payload from key/value pairs. + /// + public static CultMeshOperationPayload OperationPayload(params (string Key, string Value)[] fields) + { + var pairs = fields?.Select(field => new KeyValuePair(field.Key, field.Value)); + return CultMeshOperationPayload.FromStrings(pairs); + } + + /// + /// Creates a reusable projection recipe over typed source state. + /// + public static CultMeshProjectionRecipe ProjectionRecipe( + string projectionId, + IEnumerable sources, + Func> project, + CultMeshRouteHint? routeHint = null, + Func>? watch = null) + { + return new CultMeshProjectionRecipe( + projectionId, + sources, + project, + routeHint, + watch); + } + + /// + /// Creates a typed live feed surface for coherent client snapshots. + /// + public static CultMeshLiveFeed LiveFeed( + string feedId, + Func> snapshot, + Func>? watch = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + { + return new CultMeshLiveFeed( + feedId, + snapshot, + watch, + sources, + routeHint); + } + + /// + /// Creates a typed document handle from snapshot/watch delegates and binds it to a Verse context. + /// + public static CultMeshDocumentHandle Document( + string documentId, + CultMeshVerseContext context, + Func> latest, + Func> watch, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (latest == null) throw new ArgumentNullException(nameof(latest)); + if (watch == null) throw new ArgumentNullException(nameof(watch)); + + var feed = LiveFeed( + documentId, + (_parameters, queryContext) => latest(queryContext), + (_parameters, queryContext) => watch(queryContext), + sources, + routeHint); + return BindDocument(context, feed); + } + + /// + /// Creates a typed document handle from snapshot/watch delegates and binds it to a Verse. + /// + public static CultMeshDocumentHandle Document( + string documentId, + CultMeshVerse verse, + Func> latest, + Func> watch, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Document(documentId, verse.Context, latest, watch, sources, routeHint); + } + + /// + /// Describes a typed live feed surface for tooling, UI, and diagnostics. + /// + public static CultMeshLiveFeedDiagnostic DescribeLiveFeed( + CultMeshLiveFeed feed) + { + if (feed == null) throw new ArgumentNullException(nameof(feed)); + return new CultMeshLiveFeedDiagnostic(feed.FeedId, feed.RouteHint, feed.Sources); + } + + /// + /// Describes a typed query surface for tooling, UI, and diagnostics. + /// + public static CultMeshQuerySurfaceDiagnostic DescribeQuerySurface( + CultMeshQuerySurface query) + { + if (query == null) throw new ArgumentNullException(nameof(query)); + return new CultMeshQuerySurfaceDiagnostic(query.QueryId, query.RouteHint, query.Sources); + } + + /// + /// Describes a typed operation handle for tooling, UI, and diagnostics. + /// + public static CultMeshOperationHandleDiagnostic DescribeOperationHandle( + CultMeshOperationHandle operation) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + return new CultMeshOperationHandleDiagnostic(operation.OperationId); + } + + /// + /// Describes a typed state pointer for tooling, UI, and diagnostics. + /// + public static CultMeshStatePointerDiagnostic DescribeStatePointer( + CultMeshStatePointer pointer) + { + if (pointer == null) throw new ArgumentNullException(nameof(pointer)); + return new CultMeshStatePointerDiagnostic(pointer.PointerId, pointer.RouteHint, pointer.Sources); + } + + /// + /// Describes a mutable typed state pointer for tooling, UI, and diagnostics. + /// + public static CultMeshStatePointerDiagnostic DescribeStatePointer( + CultMeshMutableStatePointer pointer) + { + if (pointer == null) throw new ArgumentNullException(nameof(pointer)); + return new CultMeshStatePointerDiagnostic(pointer.PointerId, pointer.RouteHint, pointer.Sources); + } + + /// + /// Describes a native slice view for tooling, UI, and diagnostics. + /// + public static CultMeshNativeSliceViewDiagnostic DescribeNativeSliceView( + CultMeshNativeSliceViewDescriptor view) + { + return new CultMeshNativeSliceViewDiagnostic(view); + } + + /// + /// Describes a typed projection recipe for tooling, UI, and diagnostics. + /// + public static CultMeshProjectionRecipeDiagnostic DescribeProjectionRecipe( + CultMeshProjectionRecipe recipe) + { + if (recipe == null) throw new ArgumentNullException(nameof(recipe)); + return new CultMeshProjectionRecipeDiagnostic(recipe.ProjectionId, recipe.RouteHint, recipe.Sources); + } + + /// + /// Describes a typed query surface as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + CultMeshQuerySurface query) + { + if (query == null) throw new ArgumentNullException(nameof(query)); + return new CultMeshSurfaceDiagnostic(CultMeshSurfaceKind.Query, query.QueryId, query.RouteHint, query.Sources); + } + + /// + /// Describes a typed operation handle as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + CultMeshOperationHandle operation) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + return new CultMeshSurfaceDiagnostic(CultMeshSurfaceKind.Operation, operation.OperationId); + } + + /// + /// Describes a typed projection recipe as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + CultMeshProjectionRecipe recipe) + { + if (recipe == null) throw new ArgumentNullException(nameof(recipe)); + return new CultMeshSurfaceDiagnostic(CultMeshSurfaceKind.ProjectionRecipe, recipe.ProjectionId, recipe.RouteHint, recipe.Sources); + } + + /// + /// Describes a typed live feed as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + CultMeshLiveFeed feed) + { + if (feed == null) throw new ArgumentNullException(nameof(feed)); + return new CultMeshSurfaceDiagnostic(CultMeshSurfaceKind.LiveFeed, feed.FeedId, feed.RouteHint, feed.Sources); + } + + /// + /// Describes a typed state pointer as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + CultMeshStatePointer pointer) + { + if (pointer == null) throw new ArgumentNullException(nameof(pointer)); + return new CultMeshSurfaceDiagnostic( + CultMeshSurfaceKind.StatePointer, + pointer.PointerId, + pointer.RouteHint, + pointer.Sources); + } + + /// + /// Describes a mutable typed state pointer as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + CultMeshMutableStatePointer pointer) + { + if (pointer == null) throw new ArgumentNullException(nameof(pointer)); + return new CultMeshSurfaceDiagnostic( + CultMeshSurfaceKind.StatePointer, + pointer.PointerId, + pointer.RouteHint, + pointer.Sources); + } + + /// + /// Describes a native slice view as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + CultMeshNativeSliceViewDescriptor view) + { + if (view == null) throw new ArgumentNullException(nameof(view)); + return new CultMeshSurfaceDiagnostic(CultMeshSurfaceKind.NativeSliceView, view.ViewId, view.Route); + } + + /// + /// Creates a typed surface catalog diagnostic for tooling, UI, and generated bindings. + /// + public static CultMeshSurfaceCatalogDiagnostic DescribeSurfaceCatalog( + string catalogId, + IEnumerable surfaces) + { + return new CultMeshSurfaceCatalogDiagnostic(catalogId, surfaces); + } + + /// + /// Creates a kind-indexed surface catalog diagnostic for generated bindings, UI, and tools. + /// + public static CultMeshSurfaceCatalogIndexDiagnostic DescribeSurfaceCatalogIndex( + CultMeshSurfaceCatalogDiagnostic catalog) + { + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + return catalog.IndexByKind(); + } + + /// + /// Creates a timer-backed query watcher for runtimes that do not yet have native reactive transport. + /// + public static Func> PollingQueryWatcher( + Func> sample, + CultMeshPollingWatchOptions? options = null) + { + if (sample == null) throw new ArgumentNullException(nameof(sample)); + var resolvedOptions = options ?? new CultMeshPollingWatchOptions(); + return (parameters, context) => R3.Observable.Create(observer => + { + var watcher = new CultMeshPollingWatcher( + sample, + parameters, + context, + resolvedOptions); + var subscription = watcher.Observable.Subscribe(observer); + return new CultMeshCompositeDisposable(subscription, watcher); + }); + } + /// /// Parses a rudp://host:port endpoint into its host/port parts. /// diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs new file mode 100644 index 00000000..49906d1c --- /dev/null +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -0,0 +1,2775 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using R3; + +namespace GameCult.Mesh +{ + /// + /// Describes the route CultMesh used or should prefer for a typed operation, query, pointer, or native view. + /// + public enum CultMeshLocalityKind + { + /// Let CultMesh choose the fastest valid route. + Automatic, + /// The target is in the same process. + InProcess, + /// The target is available through a shared CultCache slab or native shared memory view. + SharedMemory, + /// The target is reachable through local inter-process transport. + Ipc, + /// The target is reachable through a network Verse route. + Network, + /// The target is hosted in or exposed to a WebAssembly runtime. + Wasm + } + + /// + /// Names a CultMesh locality decision without exposing transport plumbing to application code. + /// + public sealed class CultMeshRouteHint + { + /// Creates a route hint. + public CultMeshRouteHint(CultMeshLocalityKind kind = CultMeshLocalityKind.Automatic, string? description = null) + { + Kind = kind; + Description = description; + } + + /// Gets the requested or observed locality kind. + public CultMeshLocalityKind Kind { get; } + + /// Gets optional human-facing route diagnostics. + public string? Description { get; } + + /// Gets the default automatic route hint. + public static CultMeshRouteHint Automatic { get; } = new(); + } + + /// + /// Flat, transport-friendly fields for a CultMesh route hint. + /// + public sealed class CultMeshRouteRecord + { + /// Creates flat route fields. + public CultMeshRouteRecord(string? kind = null, string? description = null) + { + Kind = kind ?? ""; + Description = description ?? ""; + } + + /// Gets the serialized route locality kind. + public string Kind { get; } + + /// Gets the serialized route description. + public string Description { get; } + + /// Creates flat route fields from a route hint. + public static CultMeshRouteRecord FromRoute(CultMeshRouteHint? routeHint) + { + var route = routeHint ?? CultMeshRouteHint.Automatic; + return new CultMeshRouteRecord(route.Kind.ToString(), route.Description); + } + + /// Rehydrates flat route fields into a route hint. + public CultMeshRouteHint ToRoute(CultMeshRouteHint? fallback = null) + { + var fallbackRoute = fallback ?? CultMeshRouteHint.Automatic; + return new CultMeshRouteHint( + ParseKind(Kind, fallbackRoute.Kind), + string.IsNullOrWhiteSpace(Description) ? fallbackRoute.Description : Description); + } + + private static CultMeshLocalityKind ParseKind(string? value, CultMeshLocalityKind fallback) + { + if (string.IsNullOrWhiteSpace(value)) + return fallback; + + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) + return parsed; + + var normalized = value.Replace("-", "", StringComparison.Ordinal); + foreach (CultMeshLocalityKind candidate in Enum.GetValues(typeof(CultMeshLocalityKind))) + { + if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) + return candidate; + } + + return fallback; + } + } + + /// + /// Shared Verse invocation context used by generated domain sugar. + /// + public sealed class CultMeshVerseContext + { + /// Creates a Verse context. + public CultMeshVerseContext( + string verseId, + string runtimeId, + CultMeshRouteHint? routeHint = null, + IEnumerable? claims = null) + { + VerseId = RequireNonEmpty(verseId, nameof(verseId)); + RuntimeId = RequireNonEmpty(runtimeId, nameof(runtimeId)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Claims = claims?.ToArray() ?? Array.Empty(); + } + + /// Gets the semantic Verse id. + public string VerseId { get; } + + /// Gets the runtime using the Verse. + public string RuntimeId { get; } + + /// Gets the preferred route for generated operations and queries. + public CultMeshRouteHint RouteHint { get; } + + /// Gets authority claims carried by generated operations. + public IReadOnlyList Claims { get; } + + /// Returns a copy with one additional authority claim. + public CultMeshVerseContext WithClaim(CultMeshAuthorityClaim claim) + { + if (claim == null) throw new ArgumentNullException(nameof(claim)); + return new CultMeshVerseContext(VerseId, RuntimeId, RouteHint, Claims.Concat(new[] { claim })); + } + + /// Returns a copy with the supplied route hint. + public CultMeshVerseContext WithRoute(CultMeshRouteHint routeHint) + { + if (routeHint == null) throw new ArgumentNullException(nameof(routeHint)); + return new CultMeshVerseContext(VerseId, RuntimeId, routeHint, Claims); + } + + /// Creates a typed operation context from the Verse context. + public CultMeshOperationContext OperationContext(string? idempotencyKey = null) + { + return new CultMeshOperationContext(RuntimeId, Claims, RouteHint, idempotencyKey); + } + + /// Creates a typed query context from the Verse context. + public CultMeshQueryContext QueryContext() + { + return new CultMeshQueryContext(RuntimeId, RouteHint); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Managed Verse handle that generated domain packages can bind to. + /// + public sealed class CultMeshVerse + { + /// Creates a managed Verse handle. + public CultMeshVerse(CultMeshVerseContext context) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// Gets the Verse context shared by generated domain sugar. + public CultMeshVerseContext Context { get; } + + /// Gets the semantic Verse id. + public string VerseId => Context.VerseId; + + /// Gets the runtime using the Verse. + public string RuntimeId => Context.RuntimeId; + + /// Binds this Verse to a generated domain facade. + public TSchema Use(Func schemaFactory) + { + if (schemaFactory == null) throw new ArgumentNullException(nameof(schemaFactory)); + return schemaFactory(Context); + } + + /// Returns a Verse handle with one additional authority claim. + public CultMeshVerse WithClaim(CultMeshAuthorityClaim claim) + { + return new CultMeshVerse(Context.WithClaim(claim)); + } + + /// Returns a Verse handle with the supplied route hint. + public CultMeshVerse WithRoute(CultMeshRouteHint routeHint) + { + return new CultMeshVerse(Context.WithRoute(routeHint)); + } + + /// Creates a typed operation context from this Verse. + public CultMeshOperationContext OperationContext(string? idempotencyKey = null) + { + return Context.OperationContext(idempotencyKey); + } + + /// Creates a typed query context from this Verse. + public CultMeshQueryContext QueryContext() + { + return Context.QueryContext(); + } + + /// Binds an operation handle to this Verse. + public CultMeshBoundOperationHandle BindOperation( + CultMeshOperationHandle operation) + { + return new CultMeshBoundOperationHandle(Context, operation); + } + + /// Binds a query surface to this Verse. + public CultMeshBoundQuerySurface BindQuery( + CultMeshQuerySurface query) + { + return new CultMeshBoundQuerySurface(Context, query); + } + + /// Binds a live feed to this Verse. + public CultMeshBoundLiveFeed BindLiveFeed( + CultMeshLiveFeed feed) + { + return new CultMeshBoundLiveFeed(Context, feed); + } + + /// Binds a state pointer to this Verse. + public CultMeshBoundStatePointer BindStatePointer( + CultMeshStatePointer pointer) + { + return new CultMeshBoundStatePointer(Context, pointer); + } + + /// Binds a mutable state pointer to this Verse. + public CultMeshBoundMutableStatePointer BindMutableStatePointer( + CultMeshMutableStatePointer pointer) + { + return new CultMeshBoundMutableStatePointer(Context, pointer); + } + } + + /// + /// Describes a runtime authority claim attached to a typed operation. + /// + public sealed class CultMeshAuthorityClaim + { + /// Creates an authority claim. + public CultMeshAuthorityClaim(string role, string? shardId = null, string? leaseId = null) + { + Role = RequireNonEmpty(role, nameof(role)); + ShardId = shardId; + LeaseId = leaseId; + } + + /// Gets the role or claim kind being asserted. + public string Role { get; } + + /// Gets the optional shard covered by the claim. + public string? ShardId { get; } + + /// Gets the optional lease id backing the claim. + public string? LeaseId { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Context supplied when invoking a typed CultMesh operation. + /// + public sealed class CultMeshOperationContext + { + /// Creates an operation context. + public CultMeshOperationContext( + string runtimeId, + IEnumerable? claims = null, + CultMeshRouteHint? routeHint = null, + string? idempotencyKey = null) + { + RuntimeId = RequireNonEmpty(runtimeId, nameof(runtimeId)); + Claims = claims?.ToArray() ?? Array.Empty(); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + IdempotencyKey = idempotencyKey; + } + + /// Gets the runtime invoking the operation. + public string RuntimeId { get; } + + /// Gets authority claims attached to the operation. + public IReadOnlyList Claims { get; } + + /// Gets the preferred route for the operation. + public CultMeshRouteHint RouteHint { get; } + + /// Gets an optional operation idempotency key. + public string? IdempotencyKey { get; } + + /// Creates an operation context for a runtime. + public static CultMeshOperationContext ForRuntime(string runtimeId) + { + return new CultMeshOperationContext(runtimeId); + } + + /// Returns a copy with one additional authority claim. + public CultMeshOperationContext WithClaim(CultMeshAuthorityClaim claim) + { + if (claim == null) throw new ArgumentNullException(nameof(claim)); + return new CultMeshOperationContext(RuntimeId, Claims.Concat(new[] { claim }), RouteHint, IdempotencyKey); + } + + /// Returns a copy with the supplied route hint. + public CultMeshOperationContext WithRoute(CultMeshRouteHint routeHint) + { + if (routeHint == null) throw new ArgumentNullException(nameof(routeHint)); + return new CultMeshOperationContext(RuntimeId, Claims, routeHint, IdempotencyKey); + } + + /// Returns a copy with the supplied idempotency key. + public CultMeshOperationContext WithIdempotencyKey(string idempotencyKey) + { + return new CultMeshOperationContext( + RuntimeId, + Claims, + RouteHint, + RequireNonEmpty(idempotencyKey, nameof(idempotencyKey))); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Receipt returned by a typed CultMesh operation. + /// + public sealed class CultMeshOperationReceipt + { + /// Creates an operation receipt. + public CultMeshOperationReceipt( + string operationId, + bool accepted, + CultMeshRouteHint? route = null, + string? diagnostic = null) + { + OperationId = RequireNonEmpty(operationId, nameof(operationId)); + Accepted = accepted; + Route = route ?? CultMeshRouteHint.Automatic; + Diagnostic = diagnostic; + } + + /// Gets the semantic operation id. + public string OperationId { get; } + + /// Gets whether the operation was accepted by the target authority boundary. + public bool Accepted { get; } + + /// Gets the route used for the operation. + public CultMeshRouteHint Route { get; } + + /// Gets optional operation diagnostics. + public string? Diagnostic { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Method-shaped handle for a typed CultMesh operation. + /// + public sealed class CultMeshOperationHandle + { + private readonly Func> _invoke; + + /// Creates an operation handle. + public CultMeshOperationHandle( + string operationId, + Func> invoke) + { + OperationId = RequireNonEmpty(operationId, nameof(operationId)); + _invoke = invoke ?? throw new ArgumentNullException(nameof(invoke)); + } + + /// Gets the semantic operation id. + public string OperationId { get; } + + /// Invokes the typed operation. + public Task InvokeAsync(TRequest request, CultMeshOperationContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return _invoke(request, context); + } + + /// Invokes the typed operation for one runtime using default context. + public Task InvokeAsync(TRequest request, string runtimeId) + { + return InvokeAsync(request, CultMeshOperationContext.ForRuntime(runtimeId)); + } + + /// Binds this operation to a Verse context. + public CultMeshBoundOperationHandle Bind(CultMeshVerseContext context) + { + return new CultMeshBoundOperationHandle(context, this); + } + + /// Binds this operation to a Verse. + public CultMeshBoundOperationHandle Bind(CultMeshVerse verse) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Bind(verse.Context); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Operation handle pre-bound to a Verse so generated domain sugar can invoke without context plumbing. + /// + public sealed class CultMeshBoundOperationHandle + { + /// Creates a Verse-bound operation handle. + public CultMeshBoundOperationHandle( + CultMeshVerseContext context, + CultMeshOperationHandle operation) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Operation = operation ?? throw new ArgumentNullException(nameof(operation)); + } + + /// Gets the bound Verse context. + public CultMeshVerseContext Context { get; } + + /// Gets the underlying operation handle. + public CultMeshOperationHandle Operation { get; } + + /// Gets the semantic operation id. + public string OperationId => Operation.OperationId; + + /// Invokes the operation through the bound Verse. + public Task InvokeAsync(TRequest request, string? idempotencyKey = null) + { + return Operation.InvokeAsync(request, Context.OperationContext(idempotencyKey)); + } + } + + /// + /// Context supplied when executing a typed CultMesh query surface. + /// + public sealed class CultMeshQueryContext + { + /// Creates a query context. + public CultMeshQueryContext(string runtimeId, CultMeshRouteHint? routeHint = null) + { + RuntimeId = RequireNonEmpty(runtimeId, nameof(runtimeId)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + } + + /// Gets the runtime executing the query. + public string RuntimeId { get; } + + /// Gets the preferred route for the query. + public CultMeshRouteHint RouteHint { get; } + + /// Creates a query context for a runtime. + public static CultMeshQueryContext ForRuntime(string runtimeId) + { + return new CultMeshQueryContext(runtimeId); + } + + /// Returns a copy with the supplied route hint. + public CultMeshQueryContext WithRoute(CultMeshRouteHint routeHint) + { + if (routeHint == null) throw new ArgumentNullException(nameof(routeHint)); + return new CultMeshQueryContext(RuntimeId, routeHint); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Fluent builder for typed operation context. This is sugar over , + /// intended for call sites that should read like native Verse operations instead of transport setup. + /// + public sealed class CultMeshOperationContextBuilder + { + private readonly string _runtimeId; + private readonly List _claims = new(); + private CultMeshRouteHint _routeHint = CultMeshRouteHint.Automatic; + private string? _idempotencyKey; + + /// Creates a builder for one runtime. + public CultMeshOperationContextBuilder(string runtimeId) + { + _runtimeId = RequireNonEmpty(runtimeId, nameof(runtimeId)); + } + + /// Adds an authority claim. + public CultMeshOperationContextBuilder Claim(string role, string? shardId = null, string? leaseId = null) + { + _claims.Add(new CultMeshAuthorityClaim(role, shardId, leaseId)); + return this; + } + + /// Adds an authority claim. + public CultMeshOperationContextBuilder Claim(CultMeshAuthorityClaim claim) + { + _claims.Add(claim ?? throw new ArgumentNullException(nameof(claim))); + return this; + } + + /// Adds many authority claims. + public CultMeshOperationContextBuilder Claims(IEnumerable claims) + { + if (claims == null) throw new ArgumentNullException(nameof(claims)); + _claims.AddRange(claims); + return this; + } + + /// Sets the preferred route hint. + public CultMeshOperationContextBuilder Route(CultMeshLocalityKind kind, string? description = null) + { + _routeHint = new CultMeshRouteHint(kind, description); + return this; + } + + /// Sets the operation idempotency key. + public CultMeshOperationContextBuilder Idempotency(string idempotencyKey) + { + _idempotencyKey = RequireNonEmpty(idempotencyKey, nameof(idempotencyKey)); + return this; + } + + /// Creates the immutable operation context. + public CultMeshOperationContext Build() + { + return new CultMeshOperationContext(_runtimeId, _claims, _routeHint, _idempotencyKey); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Fluent builder for typed query context. + /// + public sealed class CultMeshQueryContextBuilder + { + private readonly string _runtimeId; + private CultMeshRouteHint _routeHint = CultMeshRouteHint.Automatic; + + /// Creates a builder for one runtime. + public CultMeshQueryContextBuilder(string runtimeId) + { + _runtimeId = RequireNonEmpty(runtimeId, nameof(runtimeId)); + } + + /// Sets the preferred route hint. + public CultMeshQueryContextBuilder Route(CultMeshLocalityKind kind, string? description = null) + { + _routeHint = new CultMeshRouteHint(kind, description); + return this; + } + + /// Creates the immutable query context. + public CultMeshQueryContext Build() + { + return new CultMeshQueryContext(_runtimeId, _routeHint); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Typed derived-state query surface. + /// + public sealed class CultMeshQuerySurface + { + private readonly Func> _execute; + private readonly Func>? _watch; + + /// Creates a query surface. + public CultMeshQuerySurface( + string queryId, + Func> execute, + Func>? watch = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + { + QueryId = RequireNonEmpty(queryId, nameof(queryId)); + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _watch = watch; + Sources = sources?.ToArray() ?? Array.Empty(); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + } + + /// Gets the semantic query id. + public string QueryId { get; } + + /// Gets the typed state sources this query depends on, when known. + public IReadOnlyList Sources { get; } + + /// Gets the preferred or observed route for the query. + public CultMeshRouteHint RouteHint { get; } + + /// Executes the query once. + public Task ExecuteAsync(TParameters parameters, CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return _execute(parameters, context); + } + + /// Executes the query once for one runtime using default context. + public Task ExecuteAsync(TParameters parameters, string runtimeId) + { + return ExecuteAsync(parameters, CultMeshQueryContext.ForRuntime(runtimeId)); + } + + /// Watches query results when the surface supports reactive execution. + public Observable Watch(TParameters parameters, CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (_watch == null) + { + throw new NotSupportedException($"Query surface '{QueryId}' does not support reactive watches."); + } + + return _watch(parameters, context); + } + + /// Binds this query surface to a Verse context. + public CultMeshBoundQuerySurface Bind(CultMeshVerseContext context) + { + return new CultMeshBoundQuerySurface(context, this); + } + + /// Binds this query surface to a Verse. + public CultMeshBoundQuerySurface Bind(CultMeshVerse verse) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Bind(verse.Context); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Query surface pre-bound to a Verse so generated domain sugar can execute without context plumbing. + /// + public sealed class CultMeshBoundQuerySurface + { + /// Creates a Verse-bound query surface. + public CultMeshBoundQuerySurface( + CultMeshVerseContext context, + CultMeshQuerySurface query) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Query = query ?? throw new ArgumentNullException(nameof(query)); + } + + /// Gets the bound Verse context. + public CultMeshVerseContext Context { get; } + + /// Gets the underlying query surface. + public CultMeshQuerySurface Query { get; } + + /// Gets the semantic query id. + public string QueryId => Query.QueryId; + + /// Gets the typed state sources this query depends on, when known. + public IReadOnlyList Sources => Query.Sources; + + /// Gets the preferred or observed route for the query. + public CultMeshRouteHint RouteHint => Query.RouteHint; + + /// Executes the query through the bound Verse. + public Task ExecuteAsync(TParameters parameters) + { + return Query.ExecuteAsync(parameters, Context.QueryContext()); + } + + /// Watches query results through the bound Verse. + public Observable Watch(TParameters parameters) + { + return Query.Watch(parameters, Context.QueryContext()); + } + } + + /// + /// Typed live view surface for composed client snapshots. + /// + public sealed class CultMeshLiveFeed + { + private readonly Func> _snapshot; + private readonly Func>? _watch; + + /// Creates a live feed surface. + public CultMeshLiveFeed( + string feedId, + Func> snapshot, + Func>? watch = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + { + FeedId = RequireNonEmpty(feedId, nameof(feedId)); + _snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + _watch = watch; + Sources = sources?.ToArray() ?? Array.Empty(); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + } + + /// Gets the semantic feed id. + public string FeedId { get; } + + /// Gets the typed state sources this live feed depends on, when known. + public IReadOnlyList Sources { get; } + + /// Gets the preferred or observed route for the feed. + public CultMeshRouteHint RouteHint { get; } + + /// Reads one coherent feed snapshot. + public Task SnapshotAsync(TParameters parameters, CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return _snapshot(parameters, ResolveContext(context)); + } + + /// Reads one coherent feed snapshot for one runtime using default context. + public Task SnapshotAsync(TParameters parameters, string runtimeId) + { + return SnapshotAsync(parameters, CultMeshQueryContext.ForRuntime(runtimeId)); + } + + /// Watches coherent feed snapshots when the feed supports reactive execution. + public Observable Watch(TParameters parameters, CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (_watch == null) + { + throw new NotSupportedException($"Live feed '{FeedId}' does not support reactive watches."); + } + + return _watch(parameters, ResolveContext(context)); + } + + /// Binds this live feed to a Verse context. + public CultMeshBoundLiveFeed Bind(CultMeshVerseContext context) + { + return new CultMeshBoundLiveFeed(context, this); + } + + /// Binds this live feed to a Verse. + public CultMeshBoundLiveFeed Bind(CultMeshVerse verse) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Bind(verse.Context); + } + + private CultMeshQueryContext ResolveContext(CultMeshQueryContext context) + { + return context.RouteHint.Kind == CultMeshLocalityKind.Automatic && + RouteHint.Kind != CultMeshLocalityKind.Automatic + ? context.WithRoute(RouteHint) + : context; + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Live feed pre-bound to a Verse so generated domain sugar can snapshot or watch without context plumbing. + /// + public sealed class CultMeshBoundLiveFeed + { + /// Creates a Verse-bound live feed. + public CultMeshBoundLiveFeed( + CultMeshVerseContext context, + CultMeshLiveFeed feed) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Feed = feed ?? throw new ArgumentNullException(nameof(feed)); + } + + /// Gets the bound Verse context. + public CultMeshVerseContext Context { get; } + + /// Gets the underlying live feed. + public CultMeshLiveFeed Feed { get; } + + /// Gets the semantic feed id. + public string FeedId => Feed.FeedId; + + /// Gets the typed state sources this live feed depends on, when known. + public IReadOnlyList Sources => Feed.Sources; + + /// Gets the preferred or observed route for the feed. + public CultMeshRouteHint RouteHint => Feed.RouteHint; + + /// Reads one coherent feed snapshot through the bound Verse. + public Task SnapshotAsync(TParameters parameters) + { + return Feed.SnapshotAsync(parameters, Context.QueryContext()); + } + + /// Watches coherent feed snapshots through the bound Verse. + public Observable Watch(TParameters parameters) + { + return Feed.Watch(parameters, Context.QueryContext()); + } + } + + /// + /// Empty parameter object for document handles that expose one typed document as a live feed. + /// + public readonly struct CultMeshDocumentQueryParameters + { + /// Gets the singleton empty document query. + public static CultMeshDocumentQueryParameters Empty { get; } = new(); + } + + /// + /// Inspectable typed document handle exposed by CultMesh. + /// + public interface ICultMeshDocumentHandle + { + /// Gets the CLR document type presented by this handle. + Type DocumentType { get; } + + /// Gets the semantic document id. + string DocumentId { get; } + + /// Gets the stable CultCache schema name. + string SchemaName { get; } + + /// Gets the stable CultCache schema version. + string SchemaVersion { get; } + + /// Gets the preferred or observed route for document access. + CultMeshRouteHint RouteHint { get; } + + /// Gets typed state sources this document handle depends on, when known. + IReadOnlyList Sources { get; } + + /// Creates a same-schema alias presentation for another CLR document type. + CultMeshDocumentHandle AsSchemaAlias() where TAlias : class; + } + + /// + /// Typed reactive document handle with schema-aware alias conversion. + /// + public sealed class CultMeshDocumentHandle : ICultMeshDocumentHandle + where TDocument : class + { + private static readonly CultDocumentDescriptor Descriptor = + CultDocumentRegistry.Shared.GetRequired(); + + private readonly CultMeshBoundLiveFeed _feed; + + /// Creates a document handle from a Verse-bound live feed. + public CultMeshDocumentHandle(CultMeshBoundLiveFeed feed) + { + _feed = feed ?? throw new ArgumentNullException(nameof(feed)); + } + + /// Gets the semantic document id. + public string DocumentId => _feed.FeedId; + + /// Gets the CLR document type presented by this handle. + public Type DocumentType => typeof(TDocument); + + /// Gets the stable CultCache schema name. + public string SchemaName => Descriptor.SchemaName; + + /// Gets the stable CultCache schema version. + public string SchemaVersion => Descriptor.SchemaVersion; + + /// Gets the content-derived schema identifier. + public string SchemaId => Descriptor.SchemaId; + + /// Gets the preferred or observed route for document access. + public CultMeshRouteHint RouteHint => _feed.RouteHint; + + /// Gets typed state sources this document handle depends on, when known. + public IReadOnlyList Sources => _feed.Sources; + + /// Gets the bound Verse context. + public CultMeshVerseContext Context => _feed.Context; + + /// Gets the underlying live feed used by this document handle. + public CultMeshLiveFeed Feed => _feed.Feed; + + /// Reads one coherent document snapshot. + public Task LatestAsync() + { + return _feed.SnapshotAsync(CultMeshDocumentQueryParameters.Empty); + } + + /// Watches coherent document snapshots. + public Observable Watch() + { + return _feed.Watch(CultMeshDocumentQueryParameters.Empty); + } + + /// Subscribes to coherent document snapshots. + public IDisposable Watch(Action onNext) + { + if (onNext == null) throw new ArgumentNullException(nameof(onNext)); + return Watch().Subscribe(onNext); + } + + /// Creates a same-schema alias presentation for another CLR document type. + public CultMeshDocumentHandle AsSchemaAlias() where TAlias : class + { + var aliasDescriptor = CultDocumentRegistry.Shared.GetRequired(); + if (!string.Equals(Descriptor.SchemaName, aliasDescriptor.SchemaName, StringComparison.Ordinal) || + !string.Equals(Descriptor.SchemaVersion, aliasDescriptor.SchemaVersion, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Document type {typeof(TAlias).FullName} uses schema '{aliasDescriptor.SchemaName}' " + + $"version '{aliasDescriptor.SchemaVersion}', but handle '{DocumentId}' exposes " + + $"schema '{SchemaName}' version '{SchemaVersion}'."); + } + + var aliasFeed = new CultMeshLiveFeed( + DocumentId, + async (parameters, context) => ConvertDocument( + await Feed.SnapshotAsync(parameters, context).ConfigureAwait(false)), + (parameters, context) => Feed + .Watch(parameters, context) + .Select(ConvertDocument), + Sources, + RouteHint); + + return new CultMeshDocumentHandle(aliasFeed.Bind(Context)); + } + + private static TAlias ConvertDocument(TDocument document) where TAlias : class + { + if (document == null) throw new ArgumentNullException(nameof(document)); + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)); + return (TAlias)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TAlias), payload); + } + } + + /// + /// Inspectable metadata for a typed live feed surface. + /// + public sealed class CultMeshLiveFeedDiagnostic + { + /// Creates live feed diagnostics. + public CultMeshLiveFeedDiagnostic( + string feedId, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + FeedId = RequireNonEmpty(feedId, nameof(feedId)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Sources = sources?.ToArray() ?? Array.Empty(); + } + + /// Gets the semantic feed id. + public string FeedId { get; } + + /// Gets the preferred or observed route for the feed. + public CultMeshRouteHint RouteHint { get; } + + /// Gets the typed state sources this live feed depends on, when known. + public IReadOnlyList Sources { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Inspectable metadata for a typed query surface. + /// + public sealed class CultMeshQuerySurfaceDiagnostic + { + /// Creates query surface diagnostics. + public CultMeshQuerySurfaceDiagnostic( + string queryId, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + QueryId = RequireNonEmpty(queryId, nameof(queryId)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Sources = sources?.ToArray() ?? Array.Empty(); + } + + /// Gets the semantic query id. + public string QueryId { get; } + + /// Gets the preferred or observed route for the query. + public CultMeshRouteHint RouteHint { get; } + + /// Gets the typed state sources this query depends on, when known. + public IReadOnlyList Sources { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Inspectable metadata for a typed operation handle. + /// + public sealed class CultMeshOperationHandleDiagnostic + { + /// Creates operation handle diagnostics. + public CultMeshOperationHandleDiagnostic(string operationId) + { + OperationId = RequireNonEmpty(operationId, nameof(operationId)); + } + + /// Gets the semantic operation id. + public string OperationId { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Inspectable metadata for a typed state pointer. + /// + public sealed class CultMeshStatePointerDiagnostic + { + /// Creates state pointer diagnostics. + public CultMeshStatePointerDiagnostic( + string pointerId, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + PointerId = RequireNonEmpty(pointerId, nameof(pointerId)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Sources = sources?.ToArray() ?? Array.Empty(); + } + + /// Gets the semantic pointer id. + public string PointerId { get; } + + /// Gets the preferred or observed route for pointer resolution. + public CultMeshRouteHint RouteHint { get; } + + /// Gets typed state sources this pointer depends on, when known. + public IReadOnlyList Sources { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Inspectable metadata for a typed projection recipe. + /// + public sealed class CultMeshProjectionRecipeDiagnostic + { + /// Creates projection recipe diagnostics. + public CultMeshProjectionRecipeDiagnostic( + string projectionId, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + ProjectionId = RequireNonEmpty(projectionId, nameof(projectionId)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Sources = sources?.ToArray() ?? Array.Empty(); + } + + /// Gets the semantic projection id. + public string ProjectionId { get; } + + /// Gets the preferred or observed route for the projection. + public CultMeshRouteHint RouteHint { get; } + + /// Gets the typed state sources this projection depends on, when known. + public IReadOnlyList Sources { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Names the kind of typed surface described in a CultMesh surface catalog. + /// + public enum CultMeshSurfaceKind + { + /// A typed derived-state query surface. + Query, + + /// A reusable typed projection recipe. + ProjectionRecipe, + + /// A coherent live view feed. + LiveFeed, + + /// A typed operation handle. + Operation, + + /// A typed pointer to Verse state. + StatePointer, + + /// A native slice or slab view. + NativeSliceView + } + + /// + /// One inspectable typed surface advertised by a runtime. + /// + public sealed class CultMeshSurfaceDiagnostic + { + /// Creates a surface diagnostic. + public CultMeshSurfaceDiagnostic( + CultMeshSurfaceKind kind, + string surfaceId, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + Kind = kind; + SurfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Sources = sources?.ToArray() ?? Array.Empty(); + } + + /// Gets the kind of surface. + public CultMeshSurfaceKind Kind { get; } + + /// Gets the semantic surface id. + public string SurfaceId { get; } + + /// Gets the preferred or observed route for the surface. + public CultMeshRouteHint RouteHint { get; } + + /// Gets typed state sources this surface depends on, when known. + public IReadOnlyList Sources { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Inspectable catalog of typed surfaces exposed by a runtime or generated binding. + /// + public sealed class CultMeshSurfaceCatalogDiagnostic + { + /// Creates a surface catalog diagnostic. + public CultMeshSurfaceCatalogDiagnostic( + string catalogId, + IEnumerable surfaces) + { + CatalogId = RequireNonEmpty(catalogId, nameof(catalogId)); + Surfaces = (surfaces ?? throw new ArgumentNullException(nameof(surfaces))).ToArray(); + } + + /// Gets the semantic catalog id. + public string CatalogId { get; } + + /// Gets the advertised typed surfaces. + public IReadOnlyList Surfaces { get; } + + /// Finds a surface by semantic id. + public CultMeshSurfaceDiagnostic? Find(string surfaceId) + { + if (string.IsNullOrWhiteSpace(surfaceId)) return null; + return Surfaces.FirstOrDefault(surface => string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)); + } + + /// Finds surfaces by kind in catalog order. + public IReadOnlyList FindByKind(CultMeshSurfaceKind kind) + { + return Surfaces.Where(surface => surface.Kind == kind).ToArray(); + } + + /// Groups surfaces by kind for generated bindings and tooling. + public CultMeshSurfaceCatalogIndexDiagnostic IndexByKind() + { + return new CultMeshSurfaceCatalogIndexDiagnostic(this); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Kind-indexed view of a typed surface catalog for generated bindings, UI runtimes, and tools. + /// + public sealed class CultMeshSurfaceCatalogIndexDiagnostic + { + /// Creates a kind-indexed catalog diagnostic. + public CultMeshSurfaceCatalogIndexDiagnostic(CultMeshSurfaceCatalogDiagnostic catalog) + { + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + CatalogId = catalog.CatalogId; + Queries = catalog.FindByKind(CultMeshSurfaceKind.Query); + ProjectionRecipes = catalog.FindByKind(CultMeshSurfaceKind.ProjectionRecipe); + LiveFeeds = catalog.FindByKind(CultMeshSurfaceKind.LiveFeed); + Operations = catalog.FindByKind(CultMeshSurfaceKind.Operation); + StatePointers = catalog.FindByKind(CultMeshSurfaceKind.StatePointer); + NativeSliceViews = catalog.FindByKind(CultMeshSurfaceKind.NativeSliceView); + } + + /// Gets the semantic catalog id. + public string CatalogId { get; } + + /// Gets typed derived-state query surfaces. + public IReadOnlyList Queries { get; } + + /// Gets reusable typed projection recipes. + public IReadOnlyList ProjectionRecipes { get; } + + /// Gets coherent live view feeds. + public IReadOnlyList LiveFeeds { get; } + + /// Gets typed operation handles. + public IReadOnlyList Operations { get; } + + /// Gets typed pointers to Verse state. + public IReadOnlyList StatePointers { get; } + + /// Gets native slice or slab views. + public IReadOnlyList NativeSliceViews { get; } + } + + /// + /// Options for a polling watch fallback over a typed query or live feed snapshot. + /// + public sealed class CultMeshPollingWatchOptions + { + /// Creates polling watch options. + public CultMeshPollingWatchOptions( + TimeSpan? interval = null, + bool emitInitial = true, + IEqualityComparer? comparer = null) + { + Interval = interval ?? TimeSpan.FromMilliseconds(50); + if (Interval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(interval), "Polling interval must be positive."); + } + + EmitInitial = emitInitial; + Comparer = comparer ?? EqualityComparer.Default; + } + + /// Gets the polling interval. + public TimeSpan Interval { get; } + + /// Gets whether the first sampled value should be emitted. + public bool EmitInitial { get; } + + /// Gets the equality comparer used to suppress unchanged samples. + public IEqualityComparer Comparer { get; } + } + + /// + /// Timer-backed fallback watcher for runtimes that do not yet have native reactive transport. + /// + internal sealed class CultMeshPollingWatcher : IDisposable + { + private readonly Func> _sample; + private readonly CultMeshPollingWatchOptions _options; + private readonly Subject _subject = new(); + private readonly Timer _timer; + private readonly object _gate = new(); + private readonly TParameters _parameters; + private readonly CultMeshQueryContext _context; + private TResult? _last; + private bool _hasLast; + private bool _running; + private bool _disposed; + + public CultMeshPollingWatcher( + Func> sample, + TParameters parameters, + CultMeshQueryContext context, + CultMeshPollingWatchOptions options) + { + _sample = sample ?? throw new ArgumentNullException(nameof(sample)); + _parameters = parameters; + _context = context ?? throw new ArgumentNullException(nameof(context)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _timer = new Timer(OnTimer, null, TimeSpan.Zero, _options.Interval); + } + + public Observable Observable => _subject; + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + _timer.Dispose(); + _subject.Dispose(); + } + + private void OnTimer(object? _) + { + lock (_gate) + { + if (_disposed || _running) + { + return; + } + + _running = true; + } + + _ = SampleAsync(); + } + + private async Task SampleAsync() + { + try + { + var next = await _sample(_parameters, _context).ConfigureAwait(false); + var shouldEmit = false; + lock (_gate) + { + if (_disposed) + { + return; + } + + shouldEmit = _hasLast + ? !_options.Comparer.Equals(_last!, next) + : _options.EmitInitial; + _last = next; + _hasLast = true; + } + + if (shouldEmit) + { + _subject.OnNext(next); + } + } + finally + { + lock (_gate) + { + _running = false; + } + } + } + } + + internal sealed class CultMeshCompositeDisposable : IDisposable + { + private readonly IDisposable[] _disposables; + private int _disposed; + + public CultMeshCompositeDisposable(params IDisposable[] disposables) + { + _disposables = disposables ?? throw new ArgumentNullException(nameof(disposables)); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + foreach (var disposable in _disposables) + { + disposable.Dispose(); + } + } + } + + /// + /// Typed pointer to Verse state that UI surfaces and tools can resolve and watch. + /// + public sealed class CultMeshStatePointer + { + private readonly Func> _resolve; + private readonly Func> _watch; + + /// Creates a state pointer. + public CultMeshStatePointer( + string pointerId, + Func> resolve, + Func> watch, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + : this( + pointerId, + _ => (resolve ?? throw new ArgumentNullException(nameof(resolve)))(), + _ => (watch ?? throw new ArgumentNullException(nameof(watch)))(), + routeHint, + sources) + { + } + + /// Creates a state pointer that can resolve through a Verse query context. + public CultMeshStatePointer( + string pointerId, + Func> resolve, + Func> watch, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + PointerId = RequireNonEmpty(pointerId, nameof(pointerId)); + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _watch = watch ?? throw new ArgumentNullException(nameof(watch)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Sources = sources?.ToArray() ?? Array.Empty(); + } + + /// Gets the semantic pointer id. + public string PointerId { get; } + + /// Gets the preferred or observed route for pointer resolution. + public CultMeshRouteHint RouteHint { get; } + + /// Gets typed state sources this pointer depends on, when known. + public IReadOnlyList Sources { get; } + + /// Resolves the pointer once. + public Task ResolveAsync() + { + return ResolveAsync(CultMeshQueryContext.ForRuntime("local")); + } + + /// Resolves the pointer once through a query context. + public Task ResolveAsync(CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return _resolve(ResolveContext(context)); + } + + /// Resolves the pointer once for one runtime using default context. + public Task ResolveAsync(string runtimeId) + { + return ResolveAsync(CultMeshQueryContext.ForRuntime(runtimeId)); + } + + /// Watches resolved values for this pointer. + public Observable Watch() + { + return Watch(CultMeshQueryContext.ForRuntime("local")); + } + + /// Watches resolved values for this pointer through a query context. + public Observable Watch(CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return _watch(ResolveContext(context)); + } + + /// Binds this pointer to a Verse context. + public CultMeshBoundStatePointer Bind(CultMeshVerseContext context) + { + return new CultMeshBoundStatePointer(context, this); + } + + /// Binds this pointer to a Verse. + public CultMeshBoundStatePointer Bind(CultMeshVerse verse) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Bind(verse.Context); + } + + private CultMeshQueryContext ResolveContext(CultMeshQueryContext context) + { + return context.RouteHint.Kind == CultMeshLocalityKind.Automatic && + RouteHint.Kind != CultMeshLocalityKind.Automatic + ? context.WithRoute(RouteHint) + : context; + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// State pointer pre-bound to a Verse so UI/tool runtimes can resolve state without context plumbing. + /// + public sealed class CultMeshBoundStatePointer + { + /// Creates a Verse-bound state pointer. + public CultMeshBoundStatePointer( + CultMeshVerseContext context, + CultMeshStatePointer pointer) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Pointer = pointer ?? throw new ArgumentNullException(nameof(pointer)); + } + + /// Gets the bound Verse context. + public CultMeshVerseContext Context { get; } + + /// Gets the underlying state pointer. + public CultMeshStatePointer Pointer { get; } + + /// Gets the semantic pointer id. + public string PointerId => Pointer.PointerId; + + /// Gets typed state sources this pointer depends on, when known. + public IReadOnlyList Sources => Pointer.Sources; + + /// Gets the preferred or observed route for pointer resolution. + public CultMeshRouteHint RouteHint => Pointer.RouteHint; + + /// Resolves the pointer through the bound Verse. + public Task ResolveAsync() + { + return Pointer.ResolveAsync(Context.QueryContext()); + } + + /// Watches the pointer through the bound Verse. + public Observable Watch() + { + return Pointer.Watch(Context.QueryContext()); + } + } + + /// + /// Typed pointer to Verse state that UI surfaces and tools can resolve, watch, and replace. + /// + public sealed class CultMeshMutableStatePointer + { + private readonly Func> _resolve; + private readonly Func> _watch; + private readonly Func _replace; + + /// Creates a mutable state pointer. + public CultMeshMutableStatePointer( + string pointerId, + Func> resolve, + Func> watch, + Func replace, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + : this( + pointerId, + _ => (resolve ?? throw new ArgumentNullException(nameof(resolve)))(), + _ => (watch ?? throw new ArgumentNullException(nameof(watch)))(), + (_, value) => (replace ?? throw new ArgumentNullException(nameof(replace)))(value), + routeHint, + sources) + { + } + + /// Creates a mutable state pointer that can operate through a Verse query context. + public CultMeshMutableStatePointer( + string pointerId, + Func> resolve, + Func> watch, + Func replace, + CultMeshRouteHint? routeHint = null, + IEnumerable? sources = null) + { + PointerId = RequireNonEmpty(pointerId, nameof(pointerId)); + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _watch = watch ?? throw new ArgumentNullException(nameof(watch)); + _replace = replace ?? throw new ArgumentNullException(nameof(replace)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Sources = sources?.ToArray() ?? Array.Empty(); + } + + /// Gets the semantic pointer id. + public string PointerId { get; } + + /// Gets the preferred or observed route for pointer access. + public CultMeshRouteHint RouteHint { get; } + + /// Gets typed state sources this pointer depends on, when known. + public IReadOnlyList Sources { get; } + + /// Resolves the pointer once. + public Task ResolveAsync() + { + return ResolveAsync(CultMeshQueryContext.ForRuntime("local")); + } + + /// Resolves the pointer once. + public Task ReadAsync() + { + return ResolveAsync(); + } + + /// Resolves the pointer once through a query context. + public Task ResolveAsync(CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return _resolve(ResolveContext(context)); + } + + /// Resolves the pointer once through a query context. + public Task ReadAsync(CultMeshQueryContext context) + { + return ResolveAsync(context); + } + + /// Resolves the pointer once for one runtime using default context. + public Task ResolveAsync(string runtimeId) + { + return ResolveAsync(CultMeshQueryContext.ForRuntime(runtimeId)); + } + + /// Resolves the pointer once for one runtime using default context. + public Task ReadAsync(string runtimeId) + { + return ResolveAsync(runtimeId); + } + + /// Replaces the pointed state value. + public Task ReplaceAsync(T value) + { + return ReplaceAsync(value, CultMeshQueryContext.ForRuntime("local")); + } + + /// Replaces the pointed state value through a query context. + public Task ReplaceAsync(T value, CultMeshQueryContext context) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (context == null) throw new ArgumentNullException(nameof(context)); + return _replace(ResolveContext(context), value); + } + + /// Replaces the pointed state value for one runtime using default context. + public Task ReplaceAsync(T value, string runtimeId) + { + return ReplaceAsync(value, CultMeshQueryContext.ForRuntime(runtimeId)); + } + + /// Watches resolved values for this pointer. + public Observable Watch() + { + return Watch(CultMeshQueryContext.ForRuntime("local")); + } + + /// Watches resolved values for this pointer through a query context. + public Observable Watch(CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return _watch(ResolveContext(context)); + } + + /// Views this mutable pointer as a read/watch state pointer. + public CultMeshStatePointer AsStatePointer() + { + return new CultMeshStatePointer( + PointerId, + (Func>)ResolveAsync, + (Func>)Watch, + RouteHint, + Sources); + } + + /// Binds this pointer to a Verse context. + public CultMeshBoundMutableStatePointer Bind(CultMeshVerseContext context) + { + return new CultMeshBoundMutableStatePointer(context, this); + } + + /// Binds this pointer to a Verse. + public CultMeshBoundMutableStatePointer Bind(CultMeshVerse verse) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Bind(verse.Context); + } + + private CultMeshQueryContext ResolveContext(CultMeshQueryContext context) + { + return context.RouteHint.Kind == CultMeshLocalityKind.Automatic && + RouteHint.Kind != CultMeshLocalityKind.Automatic + ? context.WithRoute(RouteHint) + : context; + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Mutable state pointer pre-bound to a Verse so UI/tool runtimes can read, watch, and replace state without context plumbing. + /// + public sealed class CultMeshBoundMutableStatePointer + { + /// Creates a Verse-bound mutable state pointer. + public CultMeshBoundMutableStatePointer( + CultMeshVerseContext context, + CultMeshMutableStatePointer pointer) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Pointer = pointer ?? throw new ArgumentNullException(nameof(pointer)); + } + + /// Gets the bound Verse context. + public CultMeshVerseContext Context { get; } + + /// Gets the underlying mutable state pointer. + public CultMeshMutableStatePointer Pointer { get; } + + /// Gets the semantic pointer id. + public string PointerId => Pointer.PointerId; + + /// Gets typed state sources this pointer depends on, when known. + public IReadOnlyList Sources => Pointer.Sources; + + /// Gets the preferred or observed route for pointer access. + public CultMeshRouteHint RouteHint => Pointer.RouteHint; + + /// Resolves the pointer through the bound Verse. + public Task ResolveAsync() + { + return Pointer.ResolveAsync(Context.QueryContext()); + } + + /// Resolves the pointer through the bound Verse. + public Task ReadAsync() + { + return ResolveAsync(); + } + + /// Replaces the pointer value through the bound Verse. + public Task ReplaceAsync(T value) + { + return Pointer.ReplaceAsync(value, Context.QueryContext()); + } + + /// Watches the pointer through the bound Verse. + public Observable Watch() + { + return Pointer.Watch(Context.QueryContext()); + } + + /// Views this mutable pointer as a read/watch state pointer bound to the same Verse. + public CultMeshBoundStatePointer AsStatePointer() + { + return new CultMeshBoundStatePointer(Context, Pointer.AsStatePointer()); + } + } + + /// + /// Describes one typed source consumed by a projection recipe. + /// + public sealed class CultMeshProjectionSource + { + /// Creates a projection source descriptor. + public CultMeshProjectionSource(string sourceId, string? schemaId = null, string? description = null) + { + SourceId = RequireNonEmpty(sourceId, nameof(sourceId)); + SchemaId = schemaId; + Description = description; + } + + /// Gets the semantic source id, such as a state pointer, document handle, or native view id. + public string SourceId { get; } + + /// Gets the optional schema id for the source. + public string? SchemaId { get; } + + /// Gets optional human-facing source diagnostics. + public string? Description { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Resolves string state references embedded in portable UI/tool surfaces through a named CultMesh resolver. + /// + public sealed class CultMeshStateRefResolver + { + private readonly Func _resolve; + + /// Creates a state-reference resolver. + public CultMeshStateRefResolver( + string resolverId, + Func resolve, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + { + ResolverId = RequireNonEmpty(resolverId, nameof(resolverId)); + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + Sources = sources?.ToArray() ?? Array.Empty(); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + } + + /// Gets the semantic resolver id. + public string ResolverId { get; } + + /// Gets the source state read by the resolver. + public IReadOnlyList Sources { get; } + + /// Gets the preferred route for resolving state refs. + public CultMeshRouteHint RouteHint { get; } + + /// Gets an empty resolver that always returns an empty string. + public static CultMeshStateRefResolver Empty { get; } = + new("cultmesh.state_refs.empty", (_stateRef, _context) => ""); + + /// Resolves a state ref using a local query context. + public string Resolve(string stateRef) + { + return Resolve(stateRef, CultMeshQueryContext.ForRuntime("local")); + } + + /// Resolves a state ref using a runtime id. + public string Resolve(string stateRef, string runtimeId) + { + return Resolve(stateRef, CultMeshQueryContext.ForRuntime(runtimeId)); + } + + /// Resolves a state ref using an explicit query context. + public string Resolve(string stateRef, CultMeshQueryContext context) + { + if (string.IsNullOrWhiteSpace(stateRef)) + return ""; + if (context == null) throw new ArgumentNullException(nameof(context)); + return _resolve(stateRef, ResolveContext(context)) ?? ""; + } + + /// Attempts to resolve a non-empty value. + public bool TryResolve(string stateRef, out string value) + { + value = Resolve(stateRef); + return !string.IsNullOrEmpty(value); + } + + /// Attempts to resolve a non-empty value. + public bool TryResolve(string stateRef, CultMeshQueryContext context, out string value) + { + value = Resolve(stateRef, context); + return !string.IsNullOrEmpty(value); + } + + /// Creates a resolver that falls back to another resolver when this resolver returns empty. + public CultMeshStateRefResolver Or(CultMeshStateRefResolver fallback) + { + if (fallback == null) throw new ArgumentNullException(nameof(fallback)); + return new CultMeshStateRefResolver( + ResolverId + "|" + fallback.ResolverId, + (stateRef, context) => + { + var value = Resolve(stateRef, context); + return string.IsNullOrEmpty(value) + ? fallback.Resolve(stateRef, context) + : value; + }, + Sources.Concat(fallback.Sources), + RouteHint.Kind == CultMeshLocalityKind.Automatic ? fallback.RouteHint : RouteHint); + } + + /// Returns the legacy function shape for existing surface renderers. + public Func AsFunc() + { + return Resolve; + } + + private CultMeshQueryContext ResolveContext(CultMeshQueryContext context) + { + if (context.RouteHint.Kind != CultMeshLocalityKind.Automatic || RouteHint.Kind == CultMeshLocalityKind.Automatic) + return context; + + return context.WithRoute(RouteHint); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Diagnostics for a named CultMesh state-reference resolver. + /// + public sealed class CultMeshStateRefResolverDiagnostic + { + /// Creates a state-reference resolver diagnostic. + public CultMeshStateRefResolverDiagnostic( + string resolverId, + CultMeshRouteHint routeHint, + IEnumerable? sources = null) + { + ResolverId = RequireNonEmpty(resolverId, nameof(resolverId)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + Sources = sources?.ToArray() ?? Array.Empty(); + } + + /// Gets the semantic resolver id. + public string ResolverId { get; } + + /// Gets the preferred route for resolving refs. + public CultMeshRouteHint RouteHint { get; } + + /// Gets source state used by the resolver. + public IReadOnlyList Sources { get; } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Describes one UI/tool surface property bound to a typed CultMesh state pointer. + /// + public sealed class CultMeshStateBindingDescriptor + { + /// Creates a state binding descriptor. + public CultMeshStateBindingDescriptor( + string targetProp, + string pointerId, + string? sourceId = null, + string? schemaId = null, + CultMeshRouteHint? routeHint = null) + { + TargetProp = RequireNonEmpty(targetProp, nameof(targetProp)); + PointerId = RequireNonEmpty(pointerId, nameof(pointerId)); + SourceId = sourceId; + SchemaId = schemaId; + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + } + + /// Gets the component property receiving the resolved state value. + public string TargetProp { get; } + + /// Gets the semantic CultMesh state pointer id. + public string PointerId { get; } + + /// Gets the optional source document, cache, native view, or daemon source id. + public string? SourceId { get; } + + /// Gets the optional schema id for the source state. + public string? SchemaId { get; } + + /// Gets the preferred route for resolving and watching the binding. + public CultMeshRouteHint RouteHint { get; } + + /// Creates a binding from an existing state pointer. + public static CultMeshStateBindingDescriptor FromPointer( + string targetProp, + CultMeshStatePointer pointer) + { + if (pointer == null) throw new ArgumentNullException(nameof(pointer)); + var source = pointer.Sources.FirstOrDefault(); + return new CultMeshStateBindingDescriptor( + targetProp, + pointer.PointerId, + source?.SourceId, + source?.SchemaId, + pointer.RouteHint); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Transport-friendly fields for a typed state binding descriptor. + /// + public sealed class CultMeshStateBindingRecord + { + /// Creates a state binding record. + public CultMeshStateBindingRecord( + string? targetProp = null, + string? pointerId = null, + string? sourceId = null, + string? schemaId = null, + string? routeKind = null, + string? routeDescription = null) + { + TargetProp = string.IsNullOrWhiteSpace(targetProp) ? "value" : targetProp!; + PointerId = pointerId ?? ""; + SourceId = sourceId ?? ""; + SchemaId = schemaId ?? ""; + RouteKind = routeKind ?? ""; + RouteDescription = routeDescription ?? ""; + } + + /// Gets the component property receiving the resolved state value. + public string TargetProp { get; } + + /// Gets the semantic CultMesh state pointer id. + public string PointerId { get; } + + /// Gets the source document, cache, native view, or daemon source id. + public string SourceId { get; } + + /// Gets the source schema id. + public string SchemaId { get; } + + /// Gets the flattened route kind. + public string RouteKind { get; } + + /// Gets the flattened route description. + public string RouteDescription { get; } + + /// Creates transport-friendly fields from a state binding descriptor. + public static CultMeshStateBindingRecord FromBinding(CultMeshStateBindingDescriptor? binding) + { + var route = CultMeshRouteRecord.FromRoute(binding?.RouteHint); + return new CultMeshStateBindingRecord( + binding?.TargetProp, + binding?.PointerId, + binding?.SourceId, + binding?.SchemaId, + route.Kind, + route.Description); + } + + /// Rehydrates this record as a state binding descriptor. + public CultMeshStateBindingDescriptor ToBinding( + CultMeshRouteHint? fallbackRouteHint = null, + string? fallbackTargetProp = null) + { + var targetProp = string.IsNullOrWhiteSpace(TargetProp) + ? (string.IsNullOrWhiteSpace(fallbackTargetProp) ? "value" : fallbackTargetProp!) + : TargetProp; + var pointerId = string.IsNullOrWhiteSpace(PointerId) + ? $"{targetProp}.unknown" + : PointerId; + var route = new CultMeshRouteRecord(RouteKind, RouteDescription) + .ToRoute(fallbackRouteHint ?? CultMeshRouteHint.Automatic); + + return new CultMeshStateBindingDescriptor( + targetProp, + pointerId, + SourceId, + SchemaId, + route); + } + } + + /// + /// Describes one UI/tool command bound to a typed CultMesh operation. + /// + public sealed class CultMeshOperationBindingDescriptor + { + /// Creates an operation binding descriptor. + public CultMeshOperationBindingDescriptor( + string operationId, + string? label = null, + string? schemaId = null, + CultMeshRouteHint? routeHint = null) + { + OperationId = RequireNonEmpty(operationId, nameof(operationId)); + Label = label ?? ""; + SchemaId = schemaId ?? ""; + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + } + + /// Gets the semantic CultMesh operation id. + public string OperationId { get; } + + /// Gets the human-facing command label. + public string Label { get; } + + /// Gets the optional request schema id for the operation. + public string SchemaId { get; } + + /// Gets the preferred route for invoking the operation. + public CultMeshRouteHint RouteHint { get; } + + /// Creates a binding from an existing typed operation handle. + public static CultMeshOperationBindingDescriptor FromOperation( + CultMeshOperationHandle operation, + string? label = null, + string? schemaId = null, + CultMeshRouteHint? routeHint = null) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + return new CultMeshOperationBindingDescriptor( + operation.OperationId, + label, + schemaId, + routeHint); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Transport-friendly fields for a typed operation binding descriptor. + /// + public sealed class CultMeshOperationBindingRecord + { + /// Creates an operation binding record. + public CultMeshOperationBindingRecord( + string? operationId = null, + string? label = null, + string? schemaId = null, + string? routeKind = null, + string? routeDescription = null) + { + OperationId = operationId ?? ""; + Label = label ?? ""; + SchemaId = schemaId ?? ""; + RouteKind = routeKind ?? ""; + RouteDescription = routeDescription ?? ""; + } + + /// Gets the semantic CultMesh operation id. + public string OperationId { get; } + + /// Gets the human-facing command label. + public string Label { get; } + + /// Gets the optional request schema id for the operation. + public string SchemaId { get; } + + /// Gets the flattened route kind. + public string RouteKind { get; } + + /// Gets the flattened route description. + public string RouteDescription { get; } + + /// Creates transport-friendly fields from an operation binding descriptor. + public static CultMeshOperationBindingRecord FromBinding(CultMeshOperationBindingDescriptor? binding) + { + var route = CultMeshRouteRecord.FromRoute(binding?.RouteHint); + return new CultMeshOperationBindingRecord( + binding?.OperationId, + binding?.Label, + binding?.SchemaId, + route.Kind, + route.Description); + } + + /// Rehydrates this record as an operation binding descriptor. + public CultMeshOperationBindingDescriptor ToBinding( + CultMeshRouteHint? fallbackRouteHint = null, + string? fallbackOperationId = null) + { + var operationId = string.IsNullOrWhiteSpace(OperationId) + ? (fallbackOperationId ?? "") + : OperationId; + var route = new CultMeshRouteRecord(RouteKind, RouteDescription) + .ToRoute(fallbackRouteHint ?? CultMeshRouteHint.Automatic); + + return new CultMeshOperationBindingDescriptor( + operationId, + Label, + SchemaId, + route); + } + } + + /// + /// Describes one concrete invocation of a typed CultMesh operation. + /// + public sealed class CultMeshOperationInvocationDescriptor + { + /// Creates an operation invocation descriptor. + public CultMeshOperationInvocationDescriptor( + string operationId, + string? schemaId = null, + CultMeshRouteHint? routeHint = null, + string? idempotencyKey = null) + { + OperationId = RequireNonEmpty(operationId, nameof(operationId)); + SchemaId = schemaId ?? ""; + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + IdempotencyKey = idempotencyKey; + } + + /// Gets the semantic CultMesh operation id being invoked. + public string OperationId { get; } + + /// Gets the optional request schema id for the operation. + public string SchemaId { get; } + + /// Gets the preferred route for invoking the operation. + public CultMeshRouteHint RouteHint { get; } + + /// Gets an optional caller-provided idempotency key. + public string? IdempotencyKey { get; } + + /// Creates an invocation from an advertised operation binding. + public static CultMeshOperationInvocationDescriptor FromBinding( + CultMeshOperationBindingDescriptor binding, + string? idempotencyKey = null) + { + if (binding == null) throw new ArgumentNullException(nameof(binding)); + return new CultMeshOperationInvocationDescriptor( + binding.OperationId, + binding.SchemaId, + binding.RouteHint, + idempotencyKey); + } + + /// Creates an invocation from an existing typed operation handle. + public static CultMeshOperationInvocationDescriptor FromOperation( + CultMeshOperationHandle operation, + string? schemaId = null, + CultMeshRouteHint? routeHint = null, + string? idempotencyKey = null) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + return new CultMeshOperationInvocationDescriptor( + operation.OperationId, + schemaId, + routeHint, + idempotencyKey); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Flat, transport-friendly fields for a concrete CultMesh operation invocation. + /// + [MessagePackObject] + public sealed class CultMeshOperationInvocationRecord + { + /// Creates flat operation invocation fields. + [SerializationConstructor] + public CultMeshOperationInvocationRecord( + string? operationId = null, + string? schemaId = null, + string? routeKind = null, + string? routeDescription = null, + string? idempotencyKey = null) + { + OperationId = operationId ?? ""; + SchemaId = schemaId ?? ""; + RouteKind = routeKind ?? ""; + RouteDescription = routeDescription ?? ""; + IdempotencyKey = idempotencyKey ?? ""; + } + + /// Gets the semantic CultMesh operation id. + [Key(0)] + public string OperationId { get; } + + /// Gets the optional request schema id. + [Key(1)] + public string SchemaId { get; } + + /// Gets the serialized route locality kind. + [Key(2)] + public string RouteKind { get; } + + /// Gets the serialized route description. + [Key(3)] + public string RouteDescription { get; } + + /// Gets the serialized idempotency key. + [Key(4)] + public string IdempotencyKey { get; } + + /// Creates flat fields from a typed invocation descriptor. + public static CultMeshOperationInvocationRecord FromInvocation( + CultMeshOperationInvocationDescriptor? invocation, + string? fallbackOperationId = null, + string? fallbackSchemaId = null, + CultMeshRouteHint? fallbackRouteHint = null, + string? fallbackIdempotencyKey = null) + { + var route = CultMeshRouteRecord.FromRoute(invocation?.RouteHint ?? fallbackRouteHint); + return new CultMeshOperationInvocationRecord( + string.IsNullOrWhiteSpace(invocation?.OperationId) ? fallbackOperationId : invocation!.OperationId, + string.IsNullOrWhiteSpace(invocation?.SchemaId) ? fallbackSchemaId : invocation!.SchemaId, + route.Kind, + route.Description, + string.IsNullOrWhiteSpace(invocation?.IdempotencyKey) ? fallbackIdempotencyKey : invocation!.IdempotencyKey); + } + + /// Rehydrates the flat fields into a typed invocation descriptor. + public CultMeshOperationInvocationDescriptor ToInvocation( + string? fallbackOperationId = null, + string? fallbackSchemaId = null, + CultMeshRouteHint? fallbackRouteHint = null, + string? fallbackIdempotencyKey = null) + { + var operationId = string.IsNullOrWhiteSpace(OperationId) ? fallbackOperationId : OperationId; + if (string.IsNullOrWhiteSpace(operationId)) + throw new InvalidOperationException("CultMesh operation invocation fields do not contain an operation id."); + + var route = new CultMeshRouteRecord(RouteKind, RouteDescription) + .ToRoute(fallbackRouteHint); + + return new CultMeshOperationInvocationDescriptor( + operationId!, + string.IsNullOrWhiteSpace(SchemaId) ? fallbackSchemaId : SchemaId, + route, + string.IsNullOrWhiteSpace(IdempotencyKey) ? fallbackIdempotencyKey : IdempotencyKey); + } + } + + /// + /// Shared payload value carried by one concrete CultMesh operation invocation. + /// + public sealed class CultMeshOperationPayload : IReadOnlyDictionary + { + private static readonly IReadOnlyDictionary EmptyFields = + new Dictionary(0, StringComparer.Ordinal); + + private readonly IReadOnlyDictionary _fields; + + /// Creates an empty operation payload. + public CultMeshOperationPayload() + : this(EmptyFields) + { + } + + /// Creates an operation payload from string-compatible fields. + public CultMeshOperationPayload(IEnumerable>? fields) + { + if (fields == null) + { + _fields = EmptyFields; + return; + } + + var copy = new Dictionary(StringComparer.Ordinal); + foreach (var field in fields) + { + if (string.IsNullOrWhiteSpace(field.Key)) + continue; + copy[field.Key] = field.Value ?? ""; + } + + _fields = copy; + } + + /// Gets the empty operation payload. + public static CultMeshOperationPayload Empty { get; } = new(); + + /// Gets the payload field names. + public IEnumerable Keys => _fields.Keys; + + /// Gets the payload field values. + public IEnumerable Values => _fields.Values; + + /// Gets the number of payload fields. + public int Count => _fields.Count; + + /// Gets a payload field by name. + public string this[string key] => _fields[key]; + + /// Gets whether the payload contains a field. + public bool ContainsKey(string key) + { + return _fields.ContainsKey(key); + } + + /// Tries to read a raw string field. + public bool TryGetValue(string key, out string value) + { + return _fields.TryGetValue(key, out value); + } + + /// Reads a raw string field with a default value. + public string GetString(string key, string defaultValue = "") + { + return _fields.TryGetValue(key, out var value) ? value ?? defaultValue : defaultValue; + } + + /// Reads an integer field using invariant parsing. + public int GetInt32(string key, int defaultValue = 0) + { + return int.TryParse(GetString(key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : defaultValue; + } + + /// Reads a floating point field using invariant parsing. + public double GetDouble(string key, double defaultValue = 0) + { + return double.TryParse(GetString(key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? value + : defaultValue; + } + + /// Reads a boolean field using common CultMesh surface spellings. + public bool GetBoolean(string key, bool defaultValue = false) + { + var value = GetString(key); + if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "1", StringComparison.Ordinal) || + string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)) + return true; + if (string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "0", StringComparison.Ordinal) || + string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) + return false; + return defaultValue; + } + + /// Returns a copy with one field overwritten. + public CultMeshOperationPayload With(string key, string value) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Value must be non-empty.", nameof(key)); + var copy = new Dictionary(_fields, StringComparer.Ordinal) + { + [key] = value ?? "" + }; + return new CultMeshOperationPayload(copy); + } + + /// Copies payload fields into a mutable dictionary. + public Dictionary ToDictionary(IEqualityComparer? comparer = null) + { + var copy = new Dictionary(comparer ?? StringComparer.Ordinal); + foreach (var field in _fields) + copy[field.Key] = field.Value ?? ""; + return copy; + } + + /// Creates a payload from string-compatible fields. + public static CultMeshOperationPayload FromStrings(IEnumerable>? fields) + { + return new CultMeshOperationPayload(fields); + } + + /// + public IEnumerator> GetEnumerator() + { + return _fields.GetEnumerator(); + } + + /// + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } + + /// + /// Names a reusable projection from typed source state into derived state. + /// + public sealed class CultMeshProjectionRecipe + { + private readonly Func> _project; + private readonly Func>? _watch; + + /// Creates a projection recipe. + public CultMeshProjectionRecipe( + string projectionId, + IEnumerable sources, + Func> project, + CultMeshRouteHint? routeHint = null, + Func>? watch = null) + { + ProjectionId = RequireNonEmpty(projectionId, nameof(projectionId)); + Sources = sources?.ToArray() ?? throw new ArgumentNullException(nameof(sources)); + _project = project ?? throw new ArgumentNullException(nameof(project)); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + _watch = watch; + } + + /// Gets the semantic projection id. + public string ProjectionId { get; } + + /// Gets the typed state sources this projection depends on. + public IReadOnlyList Sources { get; } + + /// Gets the preferred or observed route for the projection. + public CultMeshRouteHint RouteHint { get; } + + /// Projects derived state once. + public Task ProjectAsync(TParameters parameters, CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + return _project(parameters, ResolveContext(context)); + } + + /// Projects derived state once for one runtime using default context. + public Task ProjectAsync(TParameters parameters, string runtimeId) + { + return ProjectAsync(parameters, CultMeshQueryContext.ForRuntime(runtimeId)); + } + + /// Watches projection results when the recipe supports reactive execution. + public Observable Watch(TParameters parameters, CultMeshQueryContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (_watch == null) + { + throw new NotSupportedException($"Projection recipe '{ProjectionId}' does not support reactive watches."); + } + + return _watch(parameters, ResolveContext(context)); + } + + /// Views this projection recipe as a typed query surface. + public CultMeshQuerySurface AsQuerySurface() + { + return new CultMeshQuerySurface( + ProjectionId, + ProjectAsync, + _watch == null ? null : Watch, + Sources, + RouteHint); + } + + private CultMeshQueryContext ResolveContext(CultMeshQueryContext context) + { + return context.RouteHint.Kind == CultMeshLocalityKind.Automatic && + RouteHint.Kind != CultMeshLocalityKind.Automatic + ? context.WithRoute(RouteHint) + : context; + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Describes one column in a native CultMesh slice view. + /// + public sealed class CultMeshNativeSliceColumn + { + /// Creates a native slice column descriptor. + public CultMeshNativeSliceColumn(string name, string valueType, int elementSizeBytes) + { + Name = RequireNonEmpty(name, nameof(name)); + ValueType = RequireNonEmpty(valueType, nameof(valueType)); + if (elementSizeBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(elementSizeBytes), "Element size must be positive."); + } + + ElementSizeBytes = elementSizeBytes; + } + + /// Gets the column name. + public string Name { get; } + + /// Gets the schema value type for each element. + public string ValueType { get; } + + /// Gets the size of one element in bytes. + public int ElementSizeBytes { get; } + + /// Creates a native slice column descriptor for an unmanaged CLR value type. + public static CultMeshNativeSliceColumn For(string name) where TValue : unmanaged + { + return new CultMeshNativeSliceColumn(name, typeof(TValue).FullName ?? typeof(TValue).Name, SizeOf()); + } + + private static int SizeOf() where TValue : unmanaged + { + return System.Runtime.InteropServices.Marshal.SizeOf(); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Describes a typed native slice view exposed by CultMesh. + /// + public sealed class CultMeshNativeSliceViewDescriptor + { + /// Creates a native slice view descriptor. + public CultMeshNativeSliceViewDescriptor( + string viewId, + string schemaId, + int rowCount, + IEnumerable columns, + CultMeshRouteHint? route = null, + string? nativeHandle = null) + { + ViewId = RequireNonEmpty(viewId, nameof(viewId)); + SchemaId = RequireNonEmpty(schemaId, nameof(schemaId)); + if (rowCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(rowCount), "Row count cannot be negative."); + } + + RowCount = rowCount; + Columns = columns?.ToArray() ?? throw new ArgumentNullException(nameof(columns)); + Route = route ?? CultMeshRouteHint.Automatic; + NativeHandle = nativeHandle; + } + + /// Gets the semantic view id. + public string ViewId { get; } + + /// Gets the schema id for the rows in this view. + public string SchemaId { get; } + + /// Gets the number of rows in this view. + public int RowCount { get; } + + /// Gets column descriptors in view order. + public IReadOnlyList Columns { get; } + + /// Gets the route that exposes this view. + public CultMeshRouteHint Route { get; } + + /// Gets an optional runtime-specific native handle. + public string? NativeHandle { get; } + + /// Gets the estimated byte stride for one row when all columns are densely packed. + public int DenseRowStrideBytes => Columns.Sum(column => column.ElementSizeBytes); + + /// Finds a column descriptor by name. + public CultMeshNativeSliceColumn? FindColumn(string name) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name)); + return Columns.FirstOrDefault(column => string.Equals(column.Name, name, StringComparison.Ordinal)); + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + /// + /// Inspectable metadata for a native slice view. + /// + public sealed class CultMeshNativeSliceViewDiagnostic + { + /// Creates native slice view diagnostics. + public CultMeshNativeSliceViewDiagnostic(CultMeshNativeSliceViewDescriptor view) + { + if (view == null) throw new ArgumentNullException(nameof(view)); + ViewId = view.ViewId; + SchemaId = view.SchemaId; + RowCount = view.RowCount; + Columns = view.Columns.ToArray(); + Route = view.Route; + NativeHandle = view.NativeHandle; + DenseRowStrideBytes = view.DenseRowStrideBytes; + } + + /// Gets the semantic view id. + public string ViewId { get; } + + /// Gets the schema id for rows in this view. + public string SchemaId { get; } + + /// Gets the row count advertised by this view. + public int RowCount { get; } + + /// Gets column descriptors in view order. + public IReadOnlyList Columns { get; } + + /// Gets the route that exposes this view. + public CultMeshRouteHint Route { get; } + + /// Gets an optional runtime-specific native handle. + public string? NativeHandle { get; } + + /// Gets the estimated byte stride for one densely packed row. + public int DenseRowStrideBytes { get; } + } +} diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 6089d331..30044d6e 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -87,6 +87,273 @@ whether a peer card is authorized for a requested role and shard. This keeps peer exchange separate from authority instead of letting contact gossip mutate the world. +### Cross-Runtime Primitives + +These are the first shared primitives for the cozy typed Verse surface that +projects like Aetheria should build on instead of inventing daemon-specific +adapters: + +In CultMesh, a primitive can be more than a data shape. Useful state-access +patterns should graduate into shared managed affordances: typed handles, +generated binding metadata, route selection, authority context, watches, +diagnostics, and native-view descriptors that let each runtime use the same +semantic API. The domain package names the thing; CultMesh supplies the fast, +portable machinery that makes it feel native. + +- `CultMeshStatePointer` is a typed pointer to Verse state that UI surfaces, + tools, and clients can resolve or watch without string state-ref plumbing. + `CultMesh.StatePointer(...)` creates simple or context-aware pointers. + Pointers carry source descriptors and route hints, and + `CultMesh.DescribeStatePointer(...)` exposes that metadata for catalogs and + inspectors. Context-aware pointers can bind with `Bind(...)`, + `CultMesh.BindStatePointer(...)`, or `CultMeshVerse.BindStatePointer(...)`, + producing a Verse-bound handle that resolves and watches through the shared + Verse query context instead of forcing UI/tool code to rebuild route plumbing. +- `CultMeshMutableStatePointer` extends the same state pointer shape with a + typed `ReplaceAsync(...)` operation for authoritative document handles, + editor tools, and UI surfaces that intentionally write state. It keeps the + read/watch/replace trio in CultMesh instead of letting each domain package + invent private document wrappers. `CultMesh.MutableStatePointer(...)`, + `CultMesh.BindMutableStatePointer(...)`, and + `CultMeshVerse.BindMutableStatePointer(...)` preserve the same Verse context, + route hints, source diagnostics, and state-binding metadata as read-only + pointers. +- `CultMeshStateRefResolver` is the shared bridge for UI/tool surfaces that + still receive state references from authored Eve/CultUI documents. Resolvers + are named, composable, carry source descriptors and route hints, and expose + diagnostics through `CultMesh.DescribeStateRefResolver(...)`. Legacy + `Func` lowerers can use `AsFunc()` while generated surfaces + migrate to typed state pointers and binding descriptors. +- `CultMeshStateBindingDescriptor` and `CultMeshStateBindingRecord` are the + UI/tool state edge. Live surfaces bind a component prop to a typed state + pointer; persistence and transport boundaries use + `CultMesh.StateBindingRecord(...)` instead of local pointer-field copies or + route parsers. +- `CultMeshOperationHandle` is a method-shaped operation + handle. `CultMeshOperationContext`, `CultMeshAuthorityClaim`, and + `CultMeshOperationReceipt` carry runtime identity, authority metadata, + route hints, idempotency, acceptance, and diagnostics. + `CultMesh.DescribeOperationHandle(...)` exposes the operation id for surface + catalogs and generated tooling. `Bind(...)`, `CultMesh.BindOperation(...)`, + and `CultMeshVerse.BindOperation(...)` produce Verse-bound handles so + generated domain facades can invoke without re-threading context plumbing at + every call site. +- `CultMeshOperationBindingDescriptor`, `CultMeshOperationBindingRecord`, + `CultMeshOperationInvocationDescriptor`, `CultMeshOperationInvocationRecord`, + and `CultMeshOperationPayload` are the UI/tool operation edge. Live surfaces + carry typed operation identity and scalar payload reads; persistence and + transport boundaries use `CultMesh.OperationBindingRecord(...)`, + `CultMesh.OperationInvocationRecord(...)`, and + `CultMeshOperationPayload.ToDictionary()` instead of local route parsers or + ad-hoc payload maps. +- `CultMeshQuerySurface` is a typed derived-state query + surface. Query execution can be local, remote, shared-memory, IPC, or + WASM-routed while preserving the same semantic API. Query surfaces can also + carry source descriptors, route hints, and reactive watches so tools and UI + runtimes can inspect what state a generated query depends on, which route it + prefers, and subscribe without inventing polling glue. Query surfaces can be + bound with `Bind(...)`, `CultMesh.BindQuery(...)`, or + `CultMeshVerse.BindQuery(...)`. +- `CultMeshLiveFeed` is a typed live view surface for + composed client snapshots such as an RTS viewport plus selection, health, and + authority panels. It gives clients one `snapshot(...)` / `watch(...)` handle + while preserving source descriptors and route defaults. Live feeds can be + bound with `Bind(...)`, `CultMesh.BindLiveFeed(...)`, or + `CultMeshVerse.BindLiveFeed(...)`. +- `CultMeshDocumentHandle` is the typed reactive document edge for + CultCache/CultNet state. It exposes document metadata, one coherent + `LatestAsync()` read, and a `Watch()` stream without forcing callers through + transport or projection layers. Same-schema CLR aliases can be requested with + `AsSchemaAlias()`; CultMesh verifies the shared + `[CultDocument(schemaName, schemaVersion)]` identity and uses the shared + CultCache MessagePack codec for conversion. +- `CultMeshProjectionRecipe` names a reusable projection + from typed source state into derived state. It records source handles, + route hints, and projection execution, and can be exposed as a typed query + surface. Reactive projection recipes preserve their watcher when exposed as + query surfaces. When a caller supplies an automatic query context, the recipe + route becomes the default; explicit caller routes still win. +- `CultMeshNativeSliceViewDescriptor` and `CultMeshNativeSliceColumn` describe + typed native views for co-located runtimes such as Unity Burst jobs, Rust + slices, or browser typed arrays. `CultMesh.DescribeNativeSliceView(...)` + exposes the full view id, schema id, row count, columns, route, native handle, + and dense stride for tools and adapters. +- `CultMeshRouteHint`, `CultMeshRouteRecord`, and `CultMeshLocalityKind` name + locality choices without forcing application code to choose transport-specific + APIs. `CultMesh.RouteRecord(...)` is the shared way to flatten and rehydrate + route kind/description fields at persisted document, UI, tool, and transport + boundaries. + +These types are intentionally small handles and descriptors. Generated bindings +and runtime adapters should compose them into domain sugar such as +`verse.aetheria().entity(id).pilot().move(...)`, +`zone.objects().visibleTo(units).within(rect)`, and +`renderView.AsNativeArrays()`. + +The C# helpers already support the first layer of that shape: + +```csharp +var context = CultMeshOperationContext + .ForRuntime("unity-raven") + .WithClaim(new CultMeshAuthorityClaim("pilot-control", shardId: "zone:local-rts")) + .WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.InProcess)); + +await moveOperation.InvokeAsync(moveRequest, context); +``` + +Generated facades should bind surfaces once to the correct Verse view: + +```csharp +var queryVerse = verse.WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory)); +var commandVerse = verse.WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.Network)); + +var visibleObjects = queryVerse.BindQuery(objectsViewport); +var movePilot = commandVerse.BindOperation(setMoveVector); + +var objects = await visibleObjects.ExecuteAsync(viewportRequest); +await movePilot.InvokeAsync(moveRequest, idempotencyKey); +``` + +A single typed document can be surfaced the same way: + +```csharp +var cockpit = CultMesh.Document( + "aetheria.ship.cockpit.current", + verse, + context => cockpitStore.LatestAsync(context), + context => cockpitStore.Watch(context), + sources: new[] + { + CultMesh.ProjectionSource( + "pilot:cockpit", + schemaId: "gamecult.aetheria.cockpit_state.v1") + }); + +var current = await cockpit.LatestAsync(); +using var subscription = cockpit.Watch(next => RenderCockpit(next)); + +var daemonAlias = cockpit.AsSchemaAlias(); +``` + +```ts +const queryVerse = verse.withRoute("shared-memory", statePath); +const commandVerse = verse + .withRoute("network", endpoint) + .withClaim("commander-control", { shardId: "aetheria.local" }); + +const visibleObjects = CultMesh.bindQuery(queryVerse, queries.objectsViewport); +const movePilot = CultMesh.bindOperation(commandVerse, operations.setMoveVector); + +const objects = await visibleObjects.execute(viewportRequest); +await movePilot.invoke({ actorEntityKey, directionX: 1, directionY: 0 }); +``` + +Native view descriptors can name unmanaged columns without hand-entered byte +sizes: + +```csharp +var view = new CultMeshNativeSliceViewDescriptor( + "aetheria.zone.render", + "gamecult.aetheria.render_body.v1", + rowCount, + new[] + { + CultMeshNativeSliceColumn.For("position"), + CultMeshNativeSliceColumn.For("velocity") + }, + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory)); +``` + +Projection recipes give local projection code a shared shape before it becomes +runtime-specific glue: + +```csharp +var objectsViewport = CultMesh.ProjectionRecipe( + "aetheria.zone.objects.visible", + new[] + { + CultMesh.ProjectionSource("daemon:aetheria.frame.latest.v1", "gamecult.aetheria.daemon_frame.v1"), + CultMesh.ProjectionSource("daemon:aetheria.authority.policy.v1") + }, + (request, context) => ProjectObjectsAsync(request, context), + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory)); + +var query = objectsViewport.AsQuerySurface(); +``` + +Calling `query.ExecuteAsync(request, "unity-raven")` inherits the recipe's +shared-memory route. Calling it with a `CultMeshQueryContext` that already names +`Network`, `Ipc`, `Wasm`, or another route uses the caller's route instead. +`query.Sources` and `query.RouteHint` preserve the recipe metadata for +diagnostics, tooling, and UI/MCP surfaces. If the projection supports reactive +execution, `query.Watch(...)` / `query.watch(...)` keeps the same projection +route resolution, so a browser map tab, Unity renderer, Eve surface, or MCP +tool can subscribe through the same typed query affordance it uses for one-shot +reads. + +`CultMesh.DescribeQuerySurface(...)` and +`CultMesh.DescribeProjectionRecipe(...)` expose the same metadata in a stable +diagnostic shape for generated clients, Eve/CultUI inspectors, MCP tools, and +surface catalogs. Tooling should ask CultMesh for these descriptors instead of +copying source lists and route hints by convention. +`CultMesh.DescribeSurface(...)` and `CultMesh.DescribeSurfaceCatalog(...)` +collect query surfaces, projection recipes, live feeds, operations, state +pointers, and native views into one inspectable catalog for a runtime or +generated binding package. State pointers are advertised by pointer id; native +slice views are advertised by view id and route while the full column layout +stays in `CultMeshNativeSliceViewDescriptor`. +Catalog diagnostics support exact id lookup and kind filtering so tools can +discover, for example, every operation or native view without local scan +conventions. `CultMesh.DescribeSurfaceCatalogIndex(...)` / TS +`CultMesh.surfaceCatalogIndex(...)` promotes that grouping into a shared +diagnostic shape with `queries`, `projectionRecipes`, `liveFeeds`, +`operations`, `statePointers`, and `nativeSliceViews`, so generated bindings +and tools can ask for the semantic bucket they need without copying catalog +scan logic. + +Live feeds compose several query surfaces into one client-facing snapshot +without making the UI own refresh loops: + +```csharp +var viewportFeed = CultMesh.LiveFeed( + "aetheria.rts.viewport.feed", + (request, context) => SnapshotViewportAsync(request, context), + (request, context) => WatchViewport(request, context), + new[] + { + CultMesh.ProjectionSource("daemon:aetheria.frame.latest.v1"), + CultMesh.ProjectionSource("daemon:aetheria.health.latest.v1") + }, + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory)); + +var feedDiagnostics = CultMesh.DescribeLiveFeed(viewportFeed); +``` + +When a runtime has a snapshot path before it has native reactive transport, +`CultMesh.PollingQueryWatcher(...)` provides a disposable watch adapter: + +```csharp +var feed = CultMesh.LiveFeed( + "aetheria.rts.viewport.feed", + SnapshotViewportAsync, + CultMesh.PollingQueryWatcher( + SnapshotViewportAsync, + new CultMeshPollingWatchOptions( + TimeSpan.FromMilliseconds(50)))); +``` + +The polling adapter is intentionally a bridge primitive: application UI code +still receives a typed `Watch(...)` handle, and the sampling loop can later be +replaced by shared-memory, IPC, network, or WASM-native reactivity without +changing renderer call sites. + +The staged roadmap for growing these handles into the cross-runtime developer +experience lives in `docs/cross-runtime-primitives-roadmap.md`. When a project +discovers a reusable state-ref, operation, query, projection, authority, watch, +surface-binding, schema-generation, routing, diagnostic, or native-slab +abstraction, it should graduate into that roadmap and then into the shared +CultMesh/CultLib layer instead of remaining a private bridge. + ### RUDP Helpers `CultMesh.ParseRudpEndpoint(...)`, `CreateRudpServer(...)`, diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 4278d621..7985144a 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using GameCult.Caching; +using MessagePack; using NUnit.Framework; using R3; @@ -10,6 +13,833 @@ namespace GameCult.Mesh.Tests; public sealed class CultMeshStreamingTests { + [Test] + public async Task TypedOperationHandle_CarriesAuthorityAndRouteContext() + { + var handle = new CultMeshOperationHandle( + "aetheria.entity.pilot.move", + (request, context) => Task.FromResult(new CultMeshOperationReceipt( + "aetheria.entity.pilot.move", + accepted: request.EntityId == 7 && + context.Claims.Count == 1 && + context.Claims[0].Role == "pilot-control" && + context.RouteHint.Kind == CultMeshLocalityKind.InProcess, + context.RouteHint))); + + var receipt = await handle.InvokeAsync( + new MeshMoveRequest(7, 1, -1), + CultMeshOperationContext + .ForRuntime("unity-raven") + .WithClaim(new CultMeshAuthorityClaim("pilot-control", shardId: "zone:local-rts")) + .WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "co-located daemon")) + .WithIdempotencyKey("move-7-001")); + + receipt.Accepted.Should().BeTrue(); + receipt.Route.Kind.Should().Be(CultMeshLocalityKind.InProcess); + + var diagnostic = CultMesh.DescribeOperationHandle(handle); + diagnostic.OperationId.Should().Be("aetheria.entity.pilot.move"); + } + + [Test] + public void ContextBuilders_MakeTypedHandleCallsReadLikeVerseOperations() + { + var operationContext = CultMesh.OperationContextFor("unity-raven") + .Claim("pilot-control", shardId: "zone:local-rts", leaseId: "lease:raven") + .Route(CultMeshLocalityKind.SharedMemory, "co-located daemon") + .Idempotency("move-7-001") + .Build(); + + operationContext.RuntimeId.Should().Be("unity-raven"); + operationContext.Claims.Should().ContainSingle(); + operationContext.Claims[0].Role.Should().Be("pilot-control"); + operationContext.Claims[0].ShardId.Should().Be("zone:local-rts"); + operationContext.Claims[0].LeaseId.Should().Be("lease:raven"); + operationContext.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + operationContext.IdempotencyKey.Should().Be("move-7-001"); + + var queryContext = CultMesh.QueryContextFor("browser-starfire") + .Route(CultMeshLocalityKind.Wasm, "browser-local projection") + .Build(); + + queryContext.RuntimeId.Should().Be("browser-starfire"); + queryContext.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Wasm); + queryContext.RouteHint.Description.Should().Be("browser-local projection"); + } + + [Test] + public async Task VerseContext_LetsGeneratedDomainSugarUseSharedTypedContexts() + { + var verse = await CultMesh.ConnectVerseAsync( + "starbridge", + "unity-raven", + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located Verse"), + new[] { new CultMeshAuthorityClaim("pilot-control", shardId: "zone:raven", leaseId: "lease:raven") }); + var commandVerse = verse.WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote command route")); + + var aetheria = commandVerse.Use(context => new MeshAetheriaDomain(context)); + var receipt = await aetheria.Entity(7).Pilot.MoveAsync(new MeshVec2(1, 0), "move:raven:1"); + var viewport = await aetheria.Zone("zone:raven").Objects.VisibleWithinAsync(new MeshViewportRequest(-16, 16)); + + verse.VerseId.Should().Be("starbridge"); + verse.RuntimeId.Should().Be("unity-raven"); + verse.QueryContext().RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + commandVerse.OperationContext("move:raven:2").RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); + commandVerse.OperationContext("move:raven:2").IdempotencyKey.Should().Be("move:raven:2"); + receipt.OperationId.Should().Be("aetheria.entity.pilot.move"); + receipt.Accepted.Should().BeTrue(); + receipt.Route.Kind.Should().Be(CultMeshLocalityKind.Network); + viewport.Should().Equal("unity-raven:zone:raven:-16:16:Network"); + } + + [Test] + public async Task QuerySurfaceAndStatePointer_ExposeTypedReactiveState() + { + var query = new CultMeshQuerySurface( + "aetheria.zone.objects.visible", + (request, context) => Task.FromResult(new[] { $"{context.RuntimeId}:{request.MinX}:{request.MaxX}" })); + + var queryResult = await query.ExecuteAsync( + new MeshViewportRequest(-8, 8), + CultMeshQueryContext.ForRuntime("browser-starfire")); + + queryResult.Should().Equal("browser-starfire:-8:8"); + + var queryDiagnostic = CultMesh.DescribeQuerySurface(query); + queryDiagnostic.QueryId.Should().Be("aetheria.zone.objects.visible"); + queryDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic); + queryDiagnostic.Sources.Should().BeEmpty(); + + var subject = new Subject(); + var pointer = CultMesh.StatePointer( + "aetheria.selection.current", + () => Task.FromResult("entity:station:0"), + () => subject, + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located selection cache"), + new[] + { + CultMesh.ProjectionSource( + "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1") + }); + + string observed = null!; + using var subscription = pointer.Watch().Subscribe(value => observed = value); + + (await pointer.ResolveAsync()).Should().Be("entity:station:0"); + subject.OnNext("entity:pawn:7"); + observed.Should().Be("entity:pawn:7"); + + var pointerDiagnostic = CultMesh.DescribeStatePointer(pointer); + pointerDiagnostic.PointerId.Should().Be("aetheria.selection.current"); + pointerDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + pointerDiagnostic.Sources.Should().ContainSingle().Which.SchemaId.Should().Be("gamecult.aetheria.selection.v1"); + } + + [Test] + public async Task StatePointer_CanBindToVerseContextForUiAndTools() + { + var subject = new Subject(); + var pointer = CultMesh.StatePointer( + "aetheria.daemon.frame.latest", + context => Task.FromResult($"{context.RuntimeId}:{context.RouteHint.Kind}"), + context => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"), + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located daemon frame"), + new[] + { + CultMesh.ProjectionSource( + "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1") + }); + + var verse = CultMesh.Verse( + "aetheria.local", + "bifrost-tool", + new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge")); + var bound = verse.BindStatePointer(pointer); + + (await bound.ResolveAsync()).Should().Be("bifrost-tool:Ipc"); + + string observed = null!; + using var subscription = bound.Watch().Subscribe(value => observed = value); + subject.OnNext("frame:12"); + + bound.PointerId.Should().Be("aetheria.daemon.frame.latest"); + bound.Sources.Should().ContainSingle().Which.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1"); + observed.Should().Be("bifrost-tool:frame:12:Ipc"); + } + + [Test] + public async Task MutableStatePointer_HoistsReadWatchReplaceDocumentHandles() + { + var subject = new Subject(); + var stored = "frame:0"; + var observedContexts = new List(); + var pointer = CultMesh.MutableStatePointer( + "aetheria.daemon.frame.latest", + context => + { + observedContexts.Add($"read:{context.RuntimeId}:{context.RouteHint.Kind}"); + return Task.FromResult(stored); + }, + context => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"), + (context, value) => + { + observedContexts.Add($"replace:{context.RuntimeId}:{context.RouteHint.Kind}"); + stored = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located daemon frame"), + new[] + { + CultMesh.ProjectionSource( + "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1") + }); + + var verse = CultMesh.Verse( + "aetheria.local", + "unity-raven", + new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge")); + var bound = verse.BindMutableStatePointer(pointer); + + string observed = null!; + using var subscription = bound.Watch().Subscribe(value => observed = value); + + (await bound.ReadAsync()).Should().Be("frame:0"); + await bound.ReplaceAsync("frame:1"); + + stored.Should().Be("frame:1"); + observed.Should().Be("unity-raven:frame:1:Ipc"); + observedContexts.Should().Equal("read:unity-raven:Ipc", "replace:unity-raven:Ipc"); + + var diagnostic = CultMesh.DescribeStatePointer(pointer); + diagnostic.PointerId.Should().Be("aetheria.daemon.frame.latest"); + diagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + + var binding = CultMesh.StateBinding("frame", pointer); + binding.PointerId.Should().Be("aetheria.daemon.frame.latest"); + binding.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1"); + } + + [Test] + public void StateBindingDescriptor_BindsUiPropsToTypedStatePointers() + { + var pointer = CultMesh.StatePointer( + "aetheria.selection.current", + () => Task.FromResult("entity:station:0"), + () => new Subject(), + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located selection cache"), + new[] + { + CultMesh.ProjectionSource( + "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1") + }); + + var binding = CultMesh.StateBinding("value", pointer); + var explicitBinding = CultMesh.StateBinding( + "label", + "aetheria.selection.label", + sourceId: "daemon:aetheria.selection.label.v1", + schemaId: "gamecult.aetheria.selection_label.v1", + routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge")); + + binding.TargetProp.Should().Be("value"); + binding.PointerId.Should().Be("aetheria.selection.current"); + binding.SourceId.Should().Be("daemon:aetheria.selection.current.v1"); + binding.SchemaId.Should().Be("gamecult.aetheria.selection.v1"); + binding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + binding.RouteHint.Description.Should().Be("co-located selection cache"); + + explicitBinding.TargetProp.Should().Be("label"); + explicitBinding.PointerId.Should().Be("aetheria.selection.label"); + explicitBinding.SourceId.Should().Be("daemon:aetheria.selection.label.v1"); + explicitBinding.SchemaId.Should().Be("gamecult.aetheria.selection_label.v1"); + explicitBinding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc); + } + + [Test] + public void StateBindingRecord_FlattensAndRehydratesPointerBindingFields() + { + var binding = CultMesh.StateBinding( + "status", + "aetheria.current.status", + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab")); + + var record = CultMesh.StateBindingRecord(binding); + record.TargetProp.Should().Be("status"); + record.PointerId.Should().Be("aetheria.current.status"); + record.SourceId.Should().Be("daemon:aetheria.frame.latest.v1"); + record.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1"); + record.RouteKind.Should().Be(nameof(CultMeshLocalityKind.SharedMemory)); + record.RouteDescription.Should().Be("co-located frame slab"); + + var fromTypescript = CultMesh.StateBindingRecord( + "value", + "aetheria.selection.current", + "daemon:aetheria.selection.current.v1", + "gamecult.aetheria.selection.v1", + "shared-memory", + "browser-adjacent cache").ToBinding(); + + fromTypescript.TargetProp.Should().Be("value"); + fromTypescript.PointerId.Should().Be("aetheria.selection.current"); + fromTypescript.SourceId.Should().Be("daemon:aetheria.selection.current.v1"); + fromTypescript.SchemaId.Should().Be("gamecult.aetheria.selection.v1"); + fromTypescript.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + fromTypescript.RouteHint.Description.Should().Be("browser-adjacent cache"); + } + + [Test] + public void OperationBindingDescriptor_BindsUiCommandsToTypedOperations() + { + var operation = new CultMeshOperationHandle( + "aetheria.entity.pilot.move", + (_request, _context) => Task.FromResult(new CultMeshOperationReceipt("aetheria.entity.pilot.move", true))); + + var binding = CultMesh.OperationBinding( + operation, + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer")); + var explicitBinding = CultMesh.OperationBinding( + "aetheria.surface.refresh", + label: "Refresh"); + + binding.OperationId.Should().Be("aetheria.entity.pilot.move"); + binding.Label.Should().Be("Move"); + binding.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1"); + binding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); + binding.RouteHint.Description.Should().Be("remote Verse peer"); + + explicitBinding.OperationId.Should().Be("aetheria.surface.refresh"); + explicitBinding.Label.Should().Be("Refresh"); + explicitBinding.SchemaId.Should().Be(""); + explicitBinding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic); + } + + [Test] + public void OperationBindingRecord_FlattensAndRehydratesSurfaceCommandFields() + { + var binding = CultMesh.OperationBinding( + "aetheria.entity.pilot.move", + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer")); + + var record = CultMesh.OperationBindingRecord(binding); + record.OperationId.Should().Be("aetheria.entity.pilot.move"); + record.Label.Should().Be("Move"); + record.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1"); + record.RouteKind.Should().Be(nameof(CultMeshLocalityKind.Network)); + record.RouteDescription.Should().Be("remote Verse peer"); + + var fromTypescript = CultMesh.OperationBindingRecord( + "aetheria.surface.refresh", + "Refresh", + "gamecult.aetheria.refresh.v1", + "in-process", + "embedded UI runtime").ToBinding(); + + fromTypescript.OperationId.Should().Be("aetheria.surface.refresh"); + fromTypescript.Label.Should().Be("Refresh"); + fromTypescript.SchemaId.Should().Be("gamecult.aetheria.refresh.v1"); + fromTypescript.RouteHint.Kind.Should().Be(CultMeshLocalityKind.InProcess); + fromTypescript.RouteHint.Description.Should().Be("embedded UI runtime"); + } + + [Test] + public void OperationInvocationDescriptor_CarriesTypedOperationIdentityThroughUiRequests() + { + var binding = CultMesh.OperationBinding( + "aetheria.entity.pilot.move", + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "local Verse node")); + + var invocation = CultMesh.OperationInvocation(binding, idempotencyKey: "move:42"); + var explicitInvocation = CultMesh.OperationInvocation( + "aetheria.surface.refresh", + routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer")); + + invocation.OperationId.Should().Be("aetheria.entity.pilot.move"); + invocation.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1"); + invocation.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc); + invocation.RouteHint.Description.Should().Be("local Verse node"); + invocation.IdempotencyKey.Should().Be("move:42"); + + explicitInvocation.OperationId.Should().Be("aetheria.surface.refresh"); + explicitInvocation.SchemaId.Should().Be(""); + explicitInvocation.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); + explicitInvocation.IdempotencyKey.Should().BeNull(); + } + + [Test] + public void OperationPayload_ReadsCommonSurfaceScalarFields() + { + var payload = CultMesh.OperationPayload( + ("value", "42.5"), + ("tierIndex", "3"), + ("enabled", "on"), + ("name", "Starfire")); + var updated = payload.With("enabled", "false"); + + payload.GetString("name").Should().Be("Starfire"); + payload.GetString("missing", "fallback").Should().Be("fallback"); + payload.GetInt32("tierIndex", -1).Should().Be(3); + payload.GetInt32("missing", -1).Should().Be(-1); + payload.GetDouble("value", -1).Should().Be(42.5); + payload.GetBoolean("enabled").Should().BeTrue(); + updated.GetBoolean("enabled", true).Should().BeFalse(); + updated.GetString("name").Should().Be("Starfire"); + payload.GetBoolean("missing", true).Should().BeTrue(); + payload.Should().ContainKey("value"); + payload.Count.Should().Be(4); + } + + [Test] + public void OperationInvocationRecord_FlattensAndRehydratesTransportFields() + { + var invocation = CultMesh.OperationInvocation( + "aetheria.entity.pilot.move", + "gamecult.aetheria.pilot_move.v1", + new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "local Verse node"), + "move:42"); + + var record = CultMesh.OperationInvocationRecord(invocation); + var restored = record.ToInvocation( + fallbackOperationId: "fallback.operation", + fallbackSchemaId: "fallback.schema", + fallbackRouteHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route")); + var fallback = CultMesh.OperationInvocationRecord( + operationId: "", + schemaId: "", + routeKind: "not-a-route", + routeDescription: "", + idempotencyKey: "") + .ToInvocation( + fallbackOperationId: "fallback.operation", + fallbackSchemaId: "fallback.schema", + fallbackRouteHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route"), + fallbackIdempotencyKey: "fallback-key"); + var payloadFields = CultMesh.OperationPayload(("value", "Raven")).ToDictionary(); + + record.OperationId.Should().Be("aetheria.entity.pilot.move"); + record.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1"); + record.RouteKind.Should().Be("Ipc"); + record.RouteDescription.Should().Be("local Verse node"); + record.IdempotencyKey.Should().Be("move:42"); + restored.OperationId.Should().Be("aetheria.entity.pilot.move"); + restored.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1"); + restored.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc); + restored.IdempotencyKey.Should().Be("move:42"); + fallback.OperationId.Should().Be("fallback.operation"); + fallback.SchemaId.Should().Be("fallback.schema"); + fallback.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); + fallback.RouteHint.Description.Should().Be("fallback route"); + fallback.IdempotencyKey.Should().Be("fallback-key"); + payloadFields["value"].Should().Be("Raven"); + } + + [Test] + public void RouteRecord_FlattensAndParsesCrossRuntimeRouteKinds() + { + var record = CultMesh.RouteRecord(new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located slab")); + var restored = record.ToRoute(); + var fromTypescript = CultMesh.RouteRecord("in-process", "").ToRoute( + new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route")); + var fallback = CultMesh.RouteRecord("not-real", "").ToRoute( + new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "fallback route")); + + record.Kind.Should().Be("SharedMemory"); + record.Description.Should().Be("co-located slab"); + restored.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + restored.Description.Should().Be("co-located slab"); + fromTypescript.Kind.Should().Be(CultMeshLocalityKind.InProcess); + fromTypescript.Description.Should().Be("fallback route"); + fallback.Kind.Should().Be(CultMeshLocalityKind.Ipc); + fallback.Description.Should().Be("fallback route"); + } + + [Test] + public void StateRefResolver_ComposesNamedSurfaceResolution() + { + var route = new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "embedded renderer"); + var daemon = CultMesh.StateRefResolver( + "aetheria.daemon.refs", + (stateRef, context) => stateRef == "aetheria.daemon/frame/id" + ? $"{context.RuntimeId}:{context.RouteHint.Kind}:42" + : "", + new[] + { + CultMesh.ProjectionSource( + "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1") + }, + route); + var itemStats = CultMesh.StateRefResolver( + "aetheria.item_stats.refs", + stateRef => stateRef == "aetheria.item_stats/laser/damage" ? "12.5" : ""); + var resolver = daemon.Or(itemStats); + + resolver.Resolve( + "aetheria.daemon/frame/id", + CultMesh.QueryContextFor("unity-raven") + .Route(CultMeshLocalityKind.Network, "remote peer") + .Build()) + .Should() + .Be("unity-raven:Network:42"); + resolver.Resolve("aetheria.item_stats/laser/damage").Should().Be("12.5"); + resolver.TryResolve("missing", out var missing).Should().BeFalse(); + missing.Should().Be(""); + resolver.AsFunc()("aetheria.item_stats/laser/damage").Should().Be("12.5"); + + var diagnostic = CultMesh.DescribeStateRefResolver(resolver); + diagnostic.ResolverId.Should().Be("aetheria.daemon.refs|aetheria.item_stats.refs"); + diagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.InProcess); + diagnostic.Sources.Should().ContainSingle(); + diagnostic.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1"); + } + + [Test] + public async Task ProjectionRecipe_NamesSourcesAndCanBecomeQuerySurface() + { + var recipe = CultMesh.ProjectionRecipe( + "aetheria.zone.objects.visible", + new[] + { + CultMesh.ProjectionSource( + "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + description: "latest daemon frame"), + CultMesh.ProjectionSource( + "daemon:aetheria.authority.policy.v1", + schemaId: "gamecult.aetheria.authority_policy.v1") + }, + (request, context) => Task.FromResult(new[] + { + $"{context.RuntimeId}:{request.MinX}:{request.MaxX}:{context.RouteHint.Kind}" + }), + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab")); + + recipe.Sources.Should().HaveCount(2); + recipe.Sources[0].SourceId.Should().Be("daemon:aetheria.frame.latest.v1"); + recipe.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + + var recipeDiagnostic = CultMesh.DescribeProjectionRecipe(recipe); + recipeDiagnostic.ProjectionId.Should().Be("aetheria.zone.objects.visible"); + recipeDiagnostic.RouteHint.Description.Should().Be("co-located frame slab"); + recipeDiagnostic.Sources.Should().HaveCount(2); + recipeDiagnostic.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1"); + recipeDiagnostic.Sources.Should().NotBeSameAs(recipe.Sources); + + var result = await recipe.ProjectAsync( + new MeshViewportRequest(-16, 16), + CultMesh.QueryContextFor("browser-starfire") + .Route(CultMeshLocalityKind.Wasm, "browser-local projection") + .Build()); + + result.Should().Equal("browser-starfire:-16:16:Wasm"); + + var query = recipe.AsQuerySurface(); + var queryResult = await query.ExecuteAsync( + new MeshViewportRequest(-4, 4), + "unity-raven"); + + query.QueryId.Should().Be(recipe.ProjectionId); + query.Sources.Should().HaveCount(2); + query.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1"); + query.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + queryResult.Should().Equal("unity-raven:-4:4:SharedMemory"); + + var queryDiagnostic = CultMesh.DescribeQuerySurface(query); + queryDiagnostic.QueryId.Should().Be("aetheria.zone.objects.visible"); + queryDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + queryDiagnostic.Sources.Should().HaveCount(2); + queryDiagnostic.Sources.Should().NotBeSameAs(query.Sources); + } + + [Test] + public async Task LiveFeed_DescribesAndWatchesCoherentClientSnapshots() + { + var subject = new Subject(); + var feed = CultMesh.LiveFeed( + "aetheria.rts.viewport.feed", + (request, context) => Task.FromResult( + $"{context.RuntimeId}:{request.MinX}:{request.MaxX}:{context.RouteHint.Kind}"), + (_request, context) => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"), + new[] + { + CultMesh.ProjectionSource( + "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1"), + CultMesh.ProjectionSource( + "daemon:aetheria.health.latest.v1", + schemaId: "gamecult.aetheria.daemon_health.v1") + }, + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located RTS cache")); + + feed.FeedId.Should().Be("aetheria.rts.viewport.feed"); + feed.Sources.Should().HaveCount(2); + feed.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1"); + feed.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + + var diagnostic = CultMesh.DescribeLiveFeed(feed); + diagnostic.FeedId.Should().Be("aetheria.rts.viewport.feed"); + diagnostic.RouteHint.Description.Should().Be("co-located RTS cache"); + diagnostic.Sources.Should().HaveCount(2); + diagnostic.Sources[1].SourceId.Should().Be("daemon:aetheria.health.latest.v1"); + diagnostic.Sources.Should().NotBeSameAs(feed.Sources); + + var inherited = await feed.SnapshotAsync( + new MeshViewportRequest(-32, 32), + "browser-starfire"); + + inherited.Should().Be("browser-starfire:-32:32:SharedMemory"); + + var explicitRoute = await feed.SnapshotAsync( + new MeshViewportRequest(-32, 32), + CultMesh.QueryContextFor("unity-raven") + .Route(CultMeshLocalityKind.InProcess, "embedded Verse") + .Build()); + + explicitRoute.Should().Be("unity-raven:-32:32:InProcess"); + + string observed = null!; + using var subscription = feed + .Watch(new MeshViewportRequest(-1, 1), CultMeshQueryContext.ForRuntime("browser-starfire")) + .Subscribe(value => observed = value); + + subject.OnNext("frame:42"); + observed.Should().Be("browser-starfire:frame:42:SharedMemory"); + } + + [Test] + public async Task DocumentHandle_ExposesTypedSnapshotsAndSameSchemaAliases() + { + var subject = new Subject(); + var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located document slab"); + var sources = new[] + { + CultMesh.ProjectionSource( + "daemon:mesh.note.current", + schemaId: "tests.mesh_note.v1") + }; + var context = CultMesh.Verse("starbridge", "unity-pilot", route).Context; + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "primary", + Revision = 1 + }; + + var handle = CultMesh.Document( + "mesh.note.current", + context, + _ => Task.FromResult(current), + _ => subject, + sources, + route); + + handle.DocumentId.Should().Be("mesh.note.current"); + handle.DocumentType.Should().Be(typeof(MeshNoteDocument)); + handle.SchemaName.Should().Be("tests.mesh_note"); + handle.SchemaVersion.Should().Be("tests.mesh_note.v1"); + handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:mesh.note.current"); + + var snapshot = await handle.LatestAsync(); + snapshot.Text.Should().Be("primary"); + + var alias = handle.AsSchemaAlias(); + var aliasSnapshot = await alias.LatestAsync(); + alias.DocumentId.Should().Be(handle.DocumentId); + alias.SchemaName.Should().Be(handle.SchemaName); + alias.SchemaVersion.Should().Be(handle.SchemaVersion); + aliasSnapshot.Text.Should().Be("primary"); + aliasSnapshot.Revision.Should().Be(1); + + MeshNoteAliasDocument observed = null!; + using var subscription = alias.Watch(value => observed = value); + subject.OnNext(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "reactive", + Revision = 2 + }); + + observed.Text.Should().Be("reactive"); + observed.Revision.Should().Be(2); + } + + [Test] + public void DocumentHandle_RejectsAliasTypesWithDifferentSchemaIdentity() + { + var handle = CultMesh.Document( + "mesh.note.current", + CultMesh.Verse("starbridge", "unity-pilot"), + _ => Task.FromResult(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "primary", + Revision = 1 + }), + _ => new Subject()); + + Action act = () => handle.AsSchemaAlias(); + + act.Should() + .Throw() + .WithMessage("*tests.mesh_other*tests.mesh_note*"); + } + + [Test] + public void SurfaceCatalog_DescribesTypedRuntimeSurfaces() + { + var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab"); + var sources = new[] + { + CultMesh.ProjectionSource( + "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1") + }; + var query = new CultMeshQuerySurface( + "aetheria.zone.objects.visible", + (_request, _context) => Task.FromResult(Array.Empty()), + sources: sources, + routeHint: route); + var feed = CultMesh.LiveFeed( + "aetheria.rts.viewport.feed", + (_request, _context) => Task.FromResult("frame"), + sources: sources, + routeHint: route); + var operation = new CultMeshOperationHandle( + "aetheria.entity.pilot.move", + (_request, _context) => Task.FromResult(new CultMeshOperationReceipt("aetheria.entity.pilot.move", true))); + var pointer = CultMesh.StatePointer( + "aetheria.selection.current", + async () => "entity:ship:1", + () => new Subject(), + route, + sources); + var nativeView = new CultMeshNativeSliceViewDescriptor( + "aetheria.zone.render", + "gamecult.aetheria.render_body.v1", + rowCount: 128, + new[] { CultMeshNativeSliceColumn.For("position") }, + route); + + var catalog = CultMesh.DescribeSurfaceCatalog( + "gamecult.aetheria.rts.surfaces.v1", + new[] + { + CultMesh.DescribeSurface(query), + CultMesh.DescribeSurface(feed), + CultMesh.DescribeSurface(operation), + CultMesh.DescribeSurface(pointer), + CultMesh.DescribeSurface(nativeView) + }); + + catalog.CatalogId.Should().Be("gamecult.aetheria.rts.surfaces.v1"); + catalog.Surfaces.Should().HaveCount(5); + catalog.Surfaces[0].Kind.Should().Be(CultMeshSurfaceKind.Query); + catalog.Surfaces[0].SurfaceId.Should().Be("aetheria.zone.objects.visible"); + catalog.Surfaces[0].RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + catalog.Surfaces[0].Sources.Should().HaveCount(1); + catalog.Find("aetheria.rts.viewport.feed")!.Kind.Should().Be(CultMeshSurfaceKind.LiveFeed); + catalog.Find("aetheria.entity.pilot.move")!.Kind.Should().Be(CultMeshSurfaceKind.Operation); + catalog.Find("aetheria.selection.current")!.Kind.Should().Be(CultMeshSurfaceKind.StatePointer); + catalog.Find("aetheria.selection.current")!.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + catalog.Find("aetheria.selection.current")!.Sources.Should().ContainSingle(); + catalog.Find("aetheria.zone.render")!.Kind.Should().Be(CultMeshSurfaceKind.NativeSliceView); + catalog.Find("aetheria.zone.render")!.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + catalog.Find("missing").Should().BeNull(); + catalog.FindByKind(CultMeshSurfaceKind.Operation) + .Should() + .ContainSingle() + .Which + .SurfaceId + .Should() + .Be("aetheria.entity.pilot.move"); + catalog.FindByKind(CultMeshSurfaceKind.Query).Should().ContainSingle(); + + var index = CultMesh.DescribeSurfaceCatalogIndex(catalog); + index.CatalogId.Should().Be(catalog.CatalogId); + index.Queries.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.zone.objects.visible"); + index.LiveFeeds.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.rts.viewport.feed"); + index.Operations.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.entity.pilot.move"); + index.StatePointers.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.selection.current"); + index.NativeSliceViews.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.zone.render"); + index.ProjectionRecipes.Should().BeEmpty(); + } + + [Test] + public async Task PollingQueryWatcher_TurnsSnapshotsIntoReactiveFeed() + { + var frameId = 0; + var observed = new List(); + var feed = CultMesh.LiveFeed( + "aetheria.rts.viewport.feed", + (_request, _context) => Task.FromResult(frameId), + CultMesh.PollingQueryWatcher( + (_request, _context) => Task.FromResult(frameId), + new CultMeshPollingWatchOptions(TimeSpan.FromMilliseconds(5)))); + + using (feed + .Watch(new MeshViewportRequest(-1, 1), CultMeshQueryContext.ForRuntime("browser-starfire")) + .Subscribe(value => observed.Add(value))) + { + await Task.Delay(35); + frameId = 1; + await Task.Delay(35); + frameId = 1; + await Task.Delay(25); + frameId = 2; + await Task.Delay(35); + } + + frameId = 3; + await Task.Delay(25); + + observed.Should().Equal(0, 1, 2); + } + + [Test] + public void NativeSliceDescriptor_DescribesSharedColumnsWithoutOpeningTransport() + { + var descriptor = new CultMeshNativeSliceViewDescriptor( + "aetheria.zone.render", + "gamecult.aetheria.render_body.v1", + rowCount: 128, + new[] + { + CultMeshNativeSliceColumn.For("position"), + CultMeshNativeSliceColumn.For("velocity") + }, + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "CultCache slab"), + nativeHandle: "cultcache-slab:aetheria-zone-render"); + + descriptor.Route.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + descriptor.Columns.Should().HaveCount(2); + descriptor.Columns[0].Name.Should().Be("position"); + descriptor.DenseRowStrideBytes.Should().Be(16); + descriptor.FindColumn("velocity")!.ElementSizeBytes.Should().Be(8); + + var diagnostic = CultMesh.DescribeNativeSliceView(descriptor); + diagnostic.ViewId.Should().Be("aetheria.zone.render"); + diagnostic.SchemaId.Should().Be("gamecult.aetheria.render_body.v1"); + diagnostic.RowCount.Should().Be(128); + diagnostic.Route.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + diagnostic.NativeHandle.Should().Be("cultcache-slab:aetheria-zone-render"); + diagnostic.DenseRowStrideBytes.Should().Be(16); + diagnostic.Columns.Should().HaveCount(2); + diagnostic.Columns.Should().NotBeSameAs(descriptor.Columns); + } + [Test] public async Task ManagedDocument_Commits_Through_MeshDatabase_And_Watches_Networked_Updates() { @@ -221,4 +1051,173 @@ public sealed class CultMeshStreamingTests [MessagePack.Key(2)] public int Health; } + + [CultDocument("tests.mesh_note", "tests.mesh_note.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class MeshNoteDocument + { + [Key(0)] + public string Schema { get; set; } = string.Empty; + + [Key(1)] + public string Text { get; set; } = string.Empty; + + [Key(2)] + public int Revision { get; set; } + } + + [CultDocument("tests.mesh_note", "tests.mesh_note.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class MeshNoteAliasDocument + { + [Key(0)] + public string Schema { get; set; } = string.Empty; + + [Key(1)] + public string Text { get; set; } = string.Empty; + + [Key(2)] + public int Revision { get; set; } + } + + [CultDocument("tests.mesh_other", "tests.mesh_other.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class MeshOtherDocument + { + [Key(0)] + public string Schema { get; set; } = string.Empty; + + [Key(1)] + public string Text { get; set; } = string.Empty; + } +} + +public readonly struct MeshMoveRequest +{ + public MeshMoveRequest(int entityId, float x, float y) + { + EntityId = entityId; + X = x; + Y = y; + } + + public int EntityId { get; } + public float X { get; } + public float Y { get; } +} + +public readonly struct MeshViewportRequest +{ + public MeshViewportRequest(float minX, float maxX) + { + MinX = minX; + MaxX = maxX; + } + + public float MinX { get; } + public float MaxX { get; } +} + +public readonly struct MeshVec2 +{ + public MeshVec2(float x, float y) + { + X = x; + Y = y; + } + + public float X { get; } + public float Y { get; } +} + +public sealed class MeshAetheriaDomain +{ + private readonly CultMeshVerseContext _context; + + public MeshAetheriaDomain(CultMeshVerseContext context) + { + _context = context; + } + + public MeshEntityFacade Entity(int entityId) + { + return new MeshEntityFacade(_context, entityId); + } + + public MeshZoneFacade Zone(string zoneId) + { + return new MeshZoneFacade(_context, zoneId); + } +} + +public sealed class MeshEntityFacade +{ + public MeshEntityFacade(CultMeshVerseContext context, int entityId) + { + Pilot = new MeshPilotFacade(context, entityId); + } + + public MeshPilotFacade Pilot { get; } +} + +public sealed class MeshPilotFacade +{ + private readonly int _entityId; + private readonly CultMeshBoundOperationHandle _move; + + public MeshPilotFacade(CultMeshVerseContext context, int entityId) + { + _entityId = entityId; + _move = CultMesh.BindOperation( + context, + new CultMeshOperationHandle( + "aetheria.entity.pilot.move", + (request, context) => Task.FromResult(new CultMeshOperationReceipt( + "aetheria.entity.pilot.move", + accepted: request.EntityId == _entityId && + Math.Abs(request.X) > 0 && + context.Claims.Any(claim => claim.Role == "pilot-control"), + context.RouteHint)))); + } + + public Task MoveAsync(MeshVec2 direction, string idempotencyKey) + { + return _move.InvokeAsync( + new MeshMoveRequest(_entityId, direction.X, direction.Y), + idempotencyKey); + } +} + +public sealed class MeshZoneFacade +{ + public MeshZoneFacade(CultMeshVerseContext context, string zoneId) + { + Objects = new MeshObjectsFacade(context, zoneId); + } + + public MeshObjectsFacade Objects { get; } +} + +public sealed class MeshObjectsFacade +{ + private readonly string _zoneId; + private readonly CultMeshBoundQuerySurface _visibleObjects; + + public MeshObjectsFacade(CultMeshVerseContext context, string zoneId) + { + _zoneId = zoneId; + _visibleObjects = CultMesh.BindQuery( + context, + new CultMeshQuerySurface( + "aetheria.zone.objects.visible", + (parameters, context) => Task.FromResult(new[] + { + $"{context.RuntimeId}:{_zoneId}:{parameters.MinX}:{parameters.MaxX}:{context.RouteHint.Kind}" + }))); + } + + public Task VisibleWithinAsync(MeshViewportRequest request) + { + return _visibleObjects.ExecuteAsync(request); + } } From a0b31b8f2aa1f1ab6a0c8a4df70c71b45aab7182 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 14:34:53 +0100 Subject: [PATCH 010/498] Recover schema-stamped cache records across runtimes --- .../cultcache-py/src/cultcache_py/cache.py | 22 ++- .../cultcache-py/src/cultcache_py/stores.py | 41 ++++- .../src/cultnet_py/replication.py | 58 +++++- packages/cultcache-py/tests/test_cultcache.py | 118 ++++++++++++ packages/cultcache-rs/src/lib.rs | 173 +++++++++++++++++- .../cultcache-ts/src/cult-cache-inspector.ts | 69 ++++++- packages/cultcache-ts/src/cult-cache.ts | 15 +- .../single-file-messagepack-backing-store.ts | 55 +++++- packages/cultcache-ts/test/cult-cache.test.ts | 88 +++++++++ .../DirectoryMessagePackBackingStore.cs | 84 ++++++++- .../BackingStoreTests.cs | 75 ++++++++ 11 files changed, 773 insertions(+), 25 deletions(-) diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 0ffdd18a..f7ad1c12 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -17,6 +17,7 @@ class CultCacheError(RuntimeError): @dataclass class _State: documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) + documents_by_schema_name: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) values: dict[str, dict[str, Any]] = field(default_factory=dict) envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict) stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict) @@ -72,7 +73,11 @@ class CultCache: def register_document_type(self, document: DocumentDefinition[Any]) -> None: if document.type in self._state.documents: raise CultCacheError(f"Document type already registered: {document.type}") + schema_name = document.catalog_entry().schema_name + if schema_name in self._state.documents_by_schema_name: + raise CultCacheError(f"Document schema name already registered: {schema_name}") self._state.documents[document.type] = document + self._state.documents_by_schema_name[schema_name] = document if document.name is not None: self.register_name_lookup(document, document.name) for index, extractor in document.indexes.items(): @@ -105,9 +110,18 @@ class CultCache: seen_globals: set[str] = set() for store in [*self._all_specific_stores(), *self._state.generic_stores]: for envelope in store.pull_all(): - document = self._state.documents.get(envelope.type) + document = self._resolve_document_for_envelope(envelope) if document is None: raise CultCacheError(f"Unknown persisted document type: {envelope.type}") + if envelope.type != document.type: + envelope = CultCacheEnvelope( + key=envelope.key, + type=document.type, + payload=envelope.payload, + stored_at=envelope.stored_at, + schema_id=envelope.schema_id, + catalog_entry=envelope.catalog_entry, + ) if document.global_document: if envelope.type in seen_globals and envelope.key == GLOBAL_KEY: raise CultCacheError(f"Duplicate global document for type: {envelope.type}") @@ -361,3 +375,9 @@ class CultCache: self._assert_registered(document) if not document.global_document: raise CultCacheError(f"Document type is not global: {document.type}") + + def _resolve_document_for_envelope(self, envelope: CultCacheEnvelope) -> DocumentDefinition[Any] | None: + document = self._state.documents.get(envelope.type) + if document is not None: + return document + return self._state.documents_by_schema_name.get(envelope.type) diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 3587e717..697882e0 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -178,12 +178,25 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: raise ValueError("CultCache persisted records must declare storedAt") catalog_entry = catalog_by_schema_id.get(schema_id) if catalog_entry is None: - raise ValueError(f'CultCache persisted record "{key}" references missing schema id "{schema_id}"') + schema_version = _infer_schema_version_from_payload(_normalize_payload(payload)) + schema_name = _infer_schema_name(schema_version) if schema_version is not None else None + if schema_name is None: + raise ValueError(f'CultCache persisted record "{key}" references missing schema id "{schema_id}"') + catalog_entry = CultCacheSchemaCatalogEntry( + schema_id=schema_id, + schema_name=schema_name, + schema_version=schema_version, + content_hash=schema_id, + canonical_schema_json="", + compatible_schema_ids=(), + members=(), + ) + normalized_payload = _normalize_payload(payload) envelopes.append( CultCacheEnvelope( key=key, type=catalog_entry.schema_name, - payload=_normalize_payload(payload), + payload=normalized_payload, stored_at=stored_at, schema_id=schema_id, catalog_entry=catalog_entry, @@ -316,3 +329,27 @@ def _normalize_payload(payload: Any) -> bytes: if isinstance(payload, list) and all(isinstance(item, int) and 0 <= item <= 255 for item in payload): return bytes(payload) raise ValueError("CultCache record payload must be binary MessagePack bytes") + + +def _infer_schema_version_from_payload(payload: bytes) -> str | None: + try: + msgpack = SingleFileMessagePackBackingStore._msgpack() + decoded = msgpack.unpackb(payload, raw=False) + except Exception: + return None + if isinstance(decoded, list) and decoded and isinstance(decoded[0], str): + return decoded[0] + if isinstance(decoded, dict): + value = decoded.get("schemaVersion", decoded.get("schema_version")) + return value if isinstance(value, str) else None + return None + + +def _infer_schema_name(schema_version: str) -> str | None: + marker = schema_version.rfind(".v") + if marker <= 0: + return None + version = schema_version[marker + 2:] + if not version or not version.isdigit(): + return None + return schema_version[:marker] diff --git a/packages/cultcache-py/src/cultnet_py/replication.py b/packages/cultcache-py/src/cultnet_py/replication.py index 7290a434..8b18e94f 100644 --- a/packages/cultcache-py/src/cultnet_py/replication.py +++ b/packages/cultcache-py/src/cultnet_py/replication.py @@ -23,6 +23,7 @@ def schema_document_map(documents: Iterable[DocumentDefinition[Any]]) -> dict[st for document in documents: catalog_entry = document.catalog_entry() mapped[catalog_entry.schema_id] = document + mapped[catalog_entry.schema_name] = document for compatible_schema_id in catalog_entry.compatible_schema_ids: mapped[compatible_schema_id] = document return mapped @@ -35,7 +36,7 @@ def apply_raw_document_record( ) -> CultNetAppliedRecord: wire = record.to_wire() if isinstance(record, CultNetRawDocumentRecord) else record schema_id = str(wire["schemaId"]) - document = documents_by_schema_id[schema_id] + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, wire) envelope = raw_record_to_envelope(document, schema_id, wire) value = cache.put_envelope(document, envelope) return CultNetAppliedRecord( @@ -76,7 +77,7 @@ def apply_raw_snapshot( if not isinstance(record, dict): continue schema_id = str(record["schemaId"]) - document = documents_by_schema_id[schema_id] + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, record) envelope = raw_record_to_envelope(document, schema_id, record) batch = batches.setdefault(document.type, (document, [])) batch[1].append((schema_id, envelope)) @@ -120,7 +121,7 @@ def apply_shard_log_response( if change_kind in ("added", "updated") and isinstance(entry.get("put"), dict): put = entry["put"] schema_id = str(put["document"]["schemaId"]) - if schema_id not in documents_by_schema_id: + if not can_resolve_document_for_raw_record(documents_by_schema_id, schema_id, put["document"]): continue result = apply_raw_document_record(cache, documents_by_schema_id, put["document"]) applied.append(CultNetAppliedRecord(result.schema_id, result.record_key, str(change_kind), result.value)) @@ -134,3 +135,54 @@ def apply_shard_log_response( cache.delete(document, record_key) applied.append(CultNetAppliedRecord(schema_id, record_key, "removed", None)) return applied + + +def can_resolve_document_for_raw_record( + documents_by_schema_id: dict[str, DocumentDefinition[Any]], + schema_id: str, + record: dict[str, Any], +) -> bool: + try: + resolve_document_for_raw_record(documents_by_schema_id, schema_id, record) + return True + except KeyError: + return False + + +def resolve_document_for_raw_record( + documents_by_schema_id: dict[str, DocumentDefinition[Any]], + schema_id: str, + record: dict[str, Any], +) -> DocumentDefinition[Any]: + document = documents_by_schema_id.get(schema_id) + if document is not None: + return document + schema_version = _infer_schema_version_from_payload(bytes(record["payload"])) + schema_name = _infer_schema_name(schema_version) if schema_version is not None else None + if schema_name is not None and schema_name in documents_by_schema_id: + return documents_by_schema_id[schema_name] + raise KeyError(schema_id) + + +def _infer_schema_version_from_payload(payload: bytes) -> str | None: + try: + import msgpack # type: ignore + decoded = msgpack.unpackb(payload, raw=False) + except Exception: + return None + if isinstance(decoded, list) and decoded and isinstance(decoded[0], str): + return decoded[0] + if isinstance(decoded, dict): + value = decoded.get("schemaVersion", decoded.get("schema_version")) + return value if isinstance(value, str) else None + return None + + +def _infer_schema_name(schema_version: str) -> str | None: + marker = schema_version.rfind(".v") + if marker <= 0: + return None + version = schema_version[marker + 2:] + if not version or not version.isdigit(): + return None + return schema_version[:marker] diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 16d72d98..1f5660ec 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -351,6 +351,53 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(raw[2][0][0], "app") self.assertEqual(raw[2][0][1], "settings") + def test_messagepack_store_recovers_schema_stamped_record_missing_catalog_entry(self) -> None: + try: + import msgpack # type: ignore + except ModuleNotFoundError: + self.skipTest("msgpack optional dependency is not installed") + + with tempfile.TemporaryDirectory() as tmp: + document = define_database_entry_type( + "runtime-policy", + [ + ("schema_version", 0), + ("name", 1), + ("value", 2), + ], + schema_id="schema-current", + schema_name="tests.schema_stamped_entry", + schema_version="tests.schema_stamped_entry.v1", + ) + store_path = Path(tmp) / "missing-catalog.msgpack" + store_path.write_bytes(msgpack.packb([ + "cultcache.store.v1", + [], + [[ + "record-1", + "sha256:stale-schema-id-from-cold-record", + "2026-06-25T12:00:00Z", + msgpack.packb([ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ], use_bin_type=True), + ]], + ], use_bin_type=True)) + + cache = ( + CultCache.builder() + .register_document_type(document) + .add_generic_store(SingleFileMessagePackBackingStore(store_path)) + .build() + ) + + cache.pull_all_backing_stores() + self.assertEqual(cache.get_required(document, "record-1")["value"], "still readable") + envelope = cache.get_required_envelope(document, "record-1") + self.assertEqual(envelope.type, "runtime-policy") + self.assertEqual(envelope.schema_id, "sha256:stale-schema-id-from-cold-record") + def test_interop_cli_helpers_round_trip_v1_store(self) -> None: with tempfile.TemporaryDirectory() as tmp: store_path = str(Path(tmp) / "cache.msgpack") @@ -1759,6 +1806,77 @@ class CultCacheTests(unittest.TestCase): self.assertEqual([change.change_kind for change in typed_applied_log], ["updated", "removed"]) self.assertIsNone(cache.get(document, "item:1")) + def test_cultnet_replication_helpers_recover_schema_stamped_raw_records(self) -> None: + document = define_database_entry_type( + "replica.runtime-policy", + [ + ("schema_version", 0), + ("name", 1), + ("value", 2), + ], + schema_id="replica.runtime-policy.current", + schema_name="replica.runtime_policy", + schema_version="replica.runtime_policy.v1", + ) + cache = CultCache() + cache.register_document_type(document) + stale_schema_id = "sha256:stale-replica-runtime-policy" + payload = document.encode_payload({ + "schema_version": "replica.runtime_policy.v1", + "name": "policy", + "value": "still synced", + }) + record = { + "schemaId": stale_schema_id, + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": payload, + } + snapshot = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "snapshot-stale", + "documents": [record], + } + + applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) + self.assertEqual(applied_snapshot[0].schema_id, stale_schema_id) + self.assertEqual(cache.get_required(document, "policy:1")["value"], "still synced") + direct_applied = apply_raw_document_record(cache, schema_document_map([document]), record) + self.assertEqual(direct_applied.schema_id, stale_schema_id) + + shard_log = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-stale", + "shardId": "interop", + "shardEpoch": 1, + "resyncRequired": False, + "entries": [ + { + "sequence": 0, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-stale", + "document": { + **record, + "payload": document.encode_payload({ + "schema_version": "replica.runtime_policy.v1", + "name": "policy", + "value": "updated through log", + }), + }, + "shardId": "interop", + "shardEpoch": 1, + }, + } + ], + } + + applied_log = apply_shard_log_response(cache, [document], shard_log) + self.assertEqual(applied_log[0].schema_id, stale_schema_id) + self.assertEqual(cache.get_required(document, "policy:1")["value"], "updated through log") + def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None: import msgpack # type: ignore from cultnet_py import read_frame, write_frame diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index cb56b8a8..67e88cdf 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -252,6 +252,7 @@ struct CultCacheStoreRegistration { pub struct CultCache { definitions: BTreeMap, + schema_name_definitions: BTreeMap, entries: BTreeMap, stores: Vec, } @@ -260,6 +261,7 @@ impl CultCache { pub fn new() -> Self { Self { definitions: BTreeMap::new(), + schema_name_definitions: BTreeMap::new(), entries: BTreeMap::new(), stores: Vec::new(), } @@ -279,7 +281,18 @@ impl CultCache { T::TYPE )); } + if let Some(existing_type) = self.schema_name_definitions.get(T::SCHEMA_NAME) + && existing_type != T::TYPE + { + return Err(anyhow!( + "CultCache schema name {:?} is already registered for type {:?}", + T::SCHEMA_NAME, + existing_type + )); + } self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); + self.schema_name_definitions + .insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string()); Ok(()) } @@ -310,14 +323,20 @@ impl CultCache { pub fn pull_all_backing_stores(&mut self) -> Result<()> { self.entries.clear(); let known_types: BTreeSet = self.definitions.keys().cloned().collect(); + let schema_name_definitions = self.schema_name_definitions.clone(); for registration in &mut self.stores { - for entry in registration.store.pull_all()? { - if !known_types.contains(&entry.r#type) { + for mut entry in registration.store.pull_all()? { + let Some(canonical_type) = resolve_registered_type( + &known_types, + &schema_name_definitions, + &entry.r#type, + ) else { return Err(anyhow!( "No schema is registered for persisted entry type {:?}", entry.r#type )); - } + }; + entry.r#type = canonical_type; self.entries.insert(entry_id(&entry), entry); } } @@ -522,6 +541,7 @@ impl CultCache { .filter_map(|(index, registration)| registration.types.is_empty().then_some(index)) .collect() } + } impl Default for CultCache { @@ -538,6 +558,18 @@ fn entry_id_parts(r#type: &str, key: &str) -> String { format!("{type}::{key}", type = r#type) } +fn resolve_registered_type( + known_types: &BTreeSet, + schema_name_definitions: &BTreeMap, + persisted_type: &str, +) -> Option { + if known_types.contains(persisted_type) { + return Some(persisted_type.to_string()); + } + + schema_name_definitions.get(persisted_type).cloned() +} + fn now_utc_second() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } @@ -605,13 +637,25 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result> { .2 .into_iter() .map(|record| { - let r#type = catalog.get(&record.1).cloned().ok_or_else(|| { - anyhow!( - "CultCache record {:?} references missing schema {:?}", - record.0, - record.1 - ) - })?; + let r#type = match catalog.get(&record.1) { + Some(schema_name) => schema_name.clone(), + None => { + let schema_version = infer_schema_version_from_payload(&record.3).ok_or_else(|| { + anyhow!( + "CultCache record {:?} references missing schema {:?}", + record.0, + record.1 + ) + })?; + infer_schema_name(&schema_version).ok_or_else(|| { + anyhow!( + "CultCache record {:?} references missing schema {:?}", + record.0, + record.1 + ) + })? + } + }; Ok(CultCacheEnvelope { key: record.0, r#type, @@ -623,6 +667,68 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result> { .collect() } +fn infer_schema_version_from_payload(payload: &[u8]) -> Option { + let mut offset = 0usize; + read_array_header(payload, &mut offset)?; + read_string(payload, &mut offset) +} + +fn read_array_header(payload: &[u8], offset: &mut usize) -> Option { + let marker = *payload.get(*offset)?; + *offset += 1; + match marker { + 0x90..=0x9f => Some((marker & 0x0f) as u32), + 0xdc => { + let bytes = payload.get(*offset..(*offset + 2))?; + *offset += 2; + Some(u16::from_be_bytes([bytes[0], bytes[1]]) as u32) + } + 0xdd => { + let bytes = payload.get(*offset..(*offset + 4))?; + *offset += 4; + Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + _ => None, + } +} + +fn read_string(payload: &[u8], offset: &mut usize) -> Option { + let marker = *payload.get(*offset)?; + *offset += 1; + let length = match marker { + 0xa0..=0xbf => (marker & 0x1f) as usize, + 0xd9 => { + let length = *payload.get(*offset)? as usize; + *offset += 1; + length + } + 0xda => { + let bytes = payload.get(*offset..(*offset + 2))?; + *offset += 2; + u16::from_be_bytes([bytes[0], bytes[1]]) as usize + } + 0xdb => { + let bytes = payload.get(*offset..(*offset + 4))?; + *offset += 4; + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize + } + _ => return None, + }; + + let bytes = payload.get(*offset..(*offset + length))?; + std::str::from_utf8(bytes).ok().map(str::to_string) +} + +fn infer_schema_name(schema_version: &str) -> Option { + let marker = schema_version.rfind(".v")?; + let version = schema_version.get((marker + 2)..)?; + if marker == 0 || version.is_empty() || !version.bytes().all(|value| value.is_ascii_digit()) { + return None; + } + + Some(schema_version[..marker].to_string()) +} + fn escape_json_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } @@ -659,6 +765,17 @@ mod tests { body: String, } + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "runtime-policy", schema = "tests.schema_stamped_entry")] + struct SchemaStamped { + #[cultcache(key = 0)] + schema_version: String, + #[cultcache(key = 1)] + name: String, + #[cultcache(key = 2)] + value: String, + } + cultcache_registry!(TestEntries { Settings, Note }); #[test] @@ -882,4 +999,40 @@ mod tests { ); Ok(()) } + + #[test] + fn messagepack_store_recovers_schema_stamped_records_missing_catalog_entries() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("missing-catalog.msgpack"); + let expected = SchemaStamped { + schema_version: "tests.schema_stamped_entry.v1".to_string(), + name: "schema-stamped".to_string(), + value: "still readable".to_string(), + }; + let snapshot = PersistedStoreSnapshot( + "cultcache.store.v1".to_string(), + Vec::new(), + vec![PersistedRecord( + "record-1".to_string(), + "sha256:stale-schema-id-from-cold-record".to_string(), + "2026-06-25T12:00:00Z".to_string(), + rmp_serde::to_vec(&expected)?, + )], + ); + fs::write(&store_path, rmp_serde::to_vec(&snapshot)?)?; + + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); + cache.pull_all_backing_stores()?; + + assert_eq!(cache.get_required::("record-1")?, expected); + assert_eq!( + cache + .get_required_envelope::("record-1")? + .schema_id, + Some("sha256:stale-schema-id-from-cold-record".to_string()) + ); + Ok(()) + } } diff --git a/packages/cultcache-ts/src/cult-cache-inspector.ts b/packages/cultcache-ts/src/cult-cache-inspector.ts index da91da41..1301fe47 100644 --- a/packages/cultcache-ts/src/cult-cache-inspector.ts +++ b/packages/cultcache-ts/src/cult-cache-inspector.ts @@ -74,13 +74,31 @@ function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: unk catalogBySchemaId.set(compatibleSchemaId, entry); } } + const records = recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)); + for (const record of records) { + if (!catalog.some((entry) => entry.schemaId === record.schemaId)) { + const schemaVersion = inferSchemaVersionFromPayloadPreview(record.payloadPreview); + const schemaName = schemaVersion ? inferSchemaName(schemaVersion) : undefined; + if (schemaName) { + catalog.push({ + schemaId: record.schemaId, + schemaName, + schemaVersion: schemaVersion!, + contentHash: record.schemaId, + canonicalSchemaJson: "", + compatibleSchemaIds: [], + members: [], + }); + } + } + } return { filePath, fileSizeBytes, format: STORE_FORMAT_VERSION, catalog, - records: recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)), + records, }; } @@ -184,8 +202,8 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map(); readonly #schemaIdDefinitions = new Map(); + readonly #schemaNameDefinitions = new Map(); readonly #entries = new Map(); readonly #typeEntryIds = new Map>(); readonly #nameToKeyMaps = new Map>(); @@ -121,6 +122,13 @@ export class CultCache { } this.#schemaIdDefinitions.set(schemaId, registered); } + const existingBySchemaName = this.#schemaNameDefinitions.get(registered.catalogEntry.schemaName); + if (existingBySchemaName && existingBySchemaName !== registered) { + throw new Error( + `CultCache schema name "${registered.catalogEntry.schemaName}" is already registered for type "${existingBySchemaName.definition.type}".`, + ); + } + this.#schemaNameDefinitions.set(registered.catalogEntry.schemaName, registered); this.#rebuildDefinitionLookups(registered); return definition; } @@ -203,6 +211,7 @@ export class CultCache { const value = registered.formatter.decode(payload); this.#applyHydratedEntry(registered, { ...entry, + type: registered.definition.type, payload, }, value, "pull"); } @@ -643,10 +652,12 @@ export class CultCache { #resolveDefinitionForEnvelope(entry: CultCacheEnvelope): RegisteredDefinition | undefined { if (entry.schemaId) { - return this.#schemaIdDefinitions.get(entry.schemaId) ?? this.#definitions.get(entry.type); + return this.#schemaIdDefinitions.get(entry.schemaId) + ?? this.#schemaNameDefinitions.get(entry.type) + ?? this.#definitions.get(entry.type); } - return this.#definitions.get(entry.type); + return this.#schemaNameDefinitions.get(entry.type) ?? this.#definitions.get(entry.type); } #resolveRoute(type: string): StoreRoute { diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 3ed89214..58dbd48d 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -52,7 +52,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { const snapshot = decodeSnapshot(decoded); if (snapshot) { return snapshot.records.map((record) => { - const catalogEntry = snapshot.catalogBySchemaId.get(record.schemaId); + const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId); if (!catalogEntry) { throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`); } @@ -282,6 +282,59 @@ function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined { return { catalogBySchemaId, records }; } +function resolveCatalogEntryForRecord( + record: PersistedRecord, + catalogBySchemaId: Map, +): CultCacheSchemaCatalogEntry | undefined { + const existing = catalogBySchemaId.get(record.schemaId); + if (existing) { + return existing; + } + + const schemaVersion = inferSchemaVersionFromPayload(record.payload); + if (!schemaVersion) { + return undefined; + } + + const schemaName = inferSchemaName(schemaVersion); + if (!schemaName) { + return undefined; + } + + return { + schemaId: record.schemaId, + schemaName, + schemaVersion, + contentHash: record.schemaId, + canonicalSchemaJson: "", + compatibleSchemaIds: [], + members: [], + }; +} + +function inferSchemaVersionFromPayload(payload: Uint8Array): string | undefined { + try { + const decoded = decode(payload); + if (Array.isArray(decoded) && typeof decoded[0] === "string") { + return decoded[0]; + } + + if (isObject(decoded)) { + const schemaVersion = decoded.schemaVersion ?? decoded.schema_version; + return typeof schemaVersion === "string" ? schemaVersion : undefined; + } + + return undefined; + } catch { + return undefined; + } +} + +function inferSchemaName(schemaVersion: string): string | undefined { + const match = /^(.*)\.v\d+$/.exec(schemaVersion); + return match?.[1]; +} + function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry { if (!Array.isArray(value)) { throw new Error("CultCache schema catalog entries must be MessagePack arrays."); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 1067eecd..fb206eb6 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -437,6 +437,58 @@ test("SingleFileMessagePackBackingStore reads CultCache v1 snapshots by schema i assert.equal(cache.getRequiredEnvelope(noteDocument, "record-1").schemaId, "schema-1"); }); +test("SingleFileMessagePackBackingStore recovers schema-stamped records missing catalog entries", async () => { + const stampedDocument = defineDocumentType({ + type: "runtime-policy", + schema: z.tuple([ + z.literal("tests.schema_stamped_entry.v1"), + z.string(), + z.string(), + ]), + schemaId: "schema-current", + schemaName: "tests.schema_stamped_entry", + schemaVersion: "tests.schema_stamped_entry.v1", + compatibleSchemaIds: ["schema-current"], + }); + + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "missing-catalog.msgpack"); + await writeFile( + storePath, + encode([ + "cultcache.store.v1", + [], + [ + [ + "record-1", + "sha256:stale-schema-id-from-cold-record", + "2026-06-25T12:00:00Z", + encode([ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ]), + ], + ], + ]), + ); + + const cache = CultCache.builder() + .withDocumentType(stampedDocument) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + + await cache.pullAllBackingStores(); + assert.deepEqual(cache.getRequired(stampedDocument, "record-1"), [ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ]); + assert.equal( + cache.getRequiredEnvelope(stampedDocument, "record-1").schemaId, + "sha256:stale-schema-id-from-cold-record", + ); +}); + test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => { const noteDocument = defineDocumentType({ type: "note", @@ -604,6 +656,42 @@ test("CultCache inspector decodes v1 store catalog and record payloads from byte ]); }); +test("CultCache inspector recovers schema-stamped records missing catalog entries", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-inspector-")); + const file = join(tempDir, "missing-catalog.cc"); + + await writeFile( + file, + encode([ + "cultcache.store.v1", + [], + [ + [ + "record-1", + "sha256:stale-schema-id-from-cold-record", + "2026-06-25T12:00:00Z", + encode([ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ]), + ], + ], + ]), + ); + + const inspection = inspectCultCacheBytes(file, await readFile(file)); + assert.equal(inspection.catalog.length, 1); + assert.equal(inspection.catalog[0]?.schemaId, "sha256:stale-schema-id-from-cold-record"); + assert.equal(inspection.catalog[0]?.schemaName, "tests.schema_stamped_entry"); + assert.equal(inspection.records[0]?.schemaName, "tests.schema_stamped_entry"); + assert.deepEqual(inspection.records[0]?.payloadPreview, [ + "tests.schema_stamped_entry.v1", + "schema-stamped", + "still readable", + ]); +}); + interface InteropNote { schemaVersion: "cultcache.interop_note.v1"; documentId: string; diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 991037da..4792310f 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; +using MessagePack; namespace GameCult.Caching.MessagePack; @@ -51,8 +52,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var reports = new List(); foreach (var record in manifest.Records) { - reports.Add(Registry.ResolvePersistedSchemaReport(record.SchemaId, manifest.SchemaCatalog)); - var stored = ToStoredDocument(record, manifest.SchemaCatalog, CultDocumentMessagePackSerialization.DeserializeUntyped); + var catalog = ResolveCatalogForRecord(record, manifest.SchemaCatalog); + reports.Add(Registry.ResolvePersistedSchemaReport(record.SchemaId, catalog)); + var stored = ToStoredDocument(record, catalog, CultDocumentMessagePackSerialization.DeserializeUntyped); Entries[stored.Key.Value] = stored; _dirtyKeys[stored.Key.Value] = true; EntryAdded.OnNext(stored); @@ -63,8 +65,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore foreach (var recordFile in _recordDirectory.EnumerateFiles("*.msgpack").OrderBy(file => file.Name, StringComparer.Ordinal)) { var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(File.ReadAllBytes(recordFile.FullName)); - reports.Add(Registry.ResolvePersistedSchemaReport(record.SchemaId, manifest.SchemaCatalog)); - var stored = ToStoredDocument(record, manifest.SchemaCatalog, CultDocumentMessagePackSerialization.DeserializeUntyped); + var catalog = ResolveCatalogForRecord(record, manifest.SchemaCatalog); + reports.Add(Registry.ResolvePersistedSchemaReport(record.SchemaId, catalog)); + var stored = ToStoredDocument(record, catalog, CultDocumentMessagePackSerialization.DeserializeUntyped); Entries[stored.Key.Value] = stored; EntryAdded.OnNext(stored); } @@ -167,6 +170,79 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore }; } + private IReadOnlyCollection ResolveCatalogForRecord( + CultPersistedRecord record, + IReadOnlyCollection manifestCatalog) + { + if (manifestCatalog.Any(entry => string.Equals(entry.SchemaId, record.SchemaId, StringComparison.Ordinal))) + { + return manifestCatalog; + } + + var schemaVersion = TryReadPayloadSchemaVersion(record.Payload); + if (string.IsNullOrWhiteSpace(schemaVersion)) + { + return manifestCatalog; + } + + var schemaName = InferSchemaName(schemaVersion); + if (string.IsNullOrWhiteSpace(schemaName)) + { + return manifestCatalog; + } + + return manifestCatalog + .Append(new CultSchemaCatalogEntry + { + SchemaId = record.SchemaId, + SchemaName = schemaName, + SchemaVersion = schemaVersion, + ContentHash = record.SchemaId, + CanonicalSchemaJson = "", + CompatibleSchemaIds = Array.Empty(), + Members = Array.Empty() + }) + .ToArray(); + } + + private static string TryReadPayloadSchemaVersion(byte[] payload) + { + if (payload == null || payload.Length == 0) + { + return ""; + } + + try + { + var reader = new MessagePackReader(payload); + if (reader.NextMessagePackType != MessagePackType.Array) + { + return ""; + } + + if (reader.ReadArrayHeader() <= 0) + { + return ""; + } + + return reader.NextMessagePackType == MessagePackType.String + ? reader.ReadString() ?? "" + : ""; + } + catch + { + return ""; + } + } + + private static string InferSchemaName(string schemaVersion) + { + var versionMarker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal); + return versionMarker > 0 && versionMarker + 2 < schemaVersion.Length && char.IsDigit(schemaVersion[versionMarker + 2]) + ? schemaVersion.Substring(0, versionMarker) + : ""; + } + private string RecordPath(string key) => Path.Combine(_recordDirectory.FullName, $"{HashKey(key)}.msgpack"); private static string HashKey(string key) diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index e08a2ebc..2b0b8d1f 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -221,6 +221,67 @@ namespace GameCult.Caching.Tests } } + [Test] + public async Task DirectoryMessagePackBackingStore_Loads_Record_When_Manifest_Misses_Catalog_Entry() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); + var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); + var recordPath = Path.Combine(recordsPath, "stale-schema-record.msgpack"); + + try + { + Directory.CreateDirectory(recordsPath); + + var entry = new SchemaStampedTestEntry + { + SchemaVersion = "tests.schema_stamped_entry.v1", + Name = "schema-stamped", + Value = "still readable" + }; + var record = new CultPersistedRecord + { + Key = "record:stale", + SchemaId = "sha256:stale-schema-id-from-cold-record", + StoredAt = "2026-06-25T12:00:00Z", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped(entry, typeof(SchemaStampedTestEntry)) + }; + var manifest = new CultPersistedStoreSnapshot + { + FormatVersion = "cultcache.store.v1.directory", + SchemaCatalog = Array.Empty(), + Records = Array.Empty() + }; + + File.WriteAllBytes(filePath, CultDocumentMessagePackSerialization.SerializeSnapshot(manifest)); + File.WriteAllBytes(recordPath, CultDocumentMessagePackSerialization.SerializePersistedRecord(record)); + + var readStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); + var readCache = new CultCache(); + readCache.AddBackingStore(readStore); + await readCache.PullAllBackingStoresAsync(); + + var loaded = readCache.Get(new CultRecordKey("record:stale")); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded!.Value, Is.EqualTo("still readable")); + Assert.That(readStore.LastSchemaMigrationReports, Has.Count.EqualTo(1)); + Assert.That(readStore.LastSchemaMigrationReports[0].PersistedSchemaName, Is.EqualTo("tests.schema_stamped_entry")); + Assert.That(readStore.LastSchemaMigrationReports[0].Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + if (Directory.Exists(recordsPath)) + { + Directory.Delete(recordsPath, recursive: true); + } + } + } + [Test] public async Task CultCache_RuntimeType_Upsert_Snapshot_And_Remove_Work_For_Editor_Tooling() { @@ -507,6 +568,20 @@ namespace GameCult.Caching.Tests public int Health; } + [CultDocument("tests.schema_stamped_entry", "tests.schema_stamped_entry.v1")] + internal sealed class SchemaStampedTestEntry + { + [Key(0)] + public string SchemaVersion = string.Empty; + + [Key(1)] + [CultName] + public string Name = string.Empty; + + [Key(2)] + public string Value = string.Empty; + } + [CultDocument("tests.named_entry", "tests.named_entry.v2")] internal sealed class NamedTestEntryAdditive { From 4f306edca6af051dc7d8838922d51d62690486f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 14:34:59 +0100 Subject: [PATCH 011/498] Add shared geometry value primitives --- .../CultGeometryPrimitives.cs | 308 ++++++++++++++++++ .../GameCult.Geometry.csproj | 1 + src/GameCult.Geometry/README.md | 23 +- .../GeometryDocumentTests.cs | 55 ++++ 4 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 src/GameCult.Geometry/CultGeometryPrimitives.cs diff --git a/src/GameCult.Geometry/CultGeometryPrimitives.cs b/src/GameCult.Geometry/CultGeometryPrimitives.cs new file mode 100644 index 00000000..99587b8e --- /dev/null +++ b/src/GameCult.Geometry/CultGeometryPrimitives.cs @@ -0,0 +1,308 @@ +using System; +using System.Text.Json.Serialization; +using MessagePack; + +namespace GameCult.Geometry +{ + /// + /// Deterministic two-dimensional float vector for cross-runtime geometry and query contracts. + /// + [MessagePackObject] + public readonly struct CultVec2 : IEquatable + { + /// Creates a two-dimensional vector. + public CultVec2(float x, float y) + { + X = x; + Y = y; + } + + /// Gets the x component. + [Key(0)] + public float X { get; } + + /// Gets the y component. + [Key(1)] + public float Y { get; } + + /// Gets the zero vector. + [IgnoreMember] + [JsonIgnore] + public static CultVec2 Zero => new(0f, 0f); + + /// Gets the squared vector length. + [IgnoreMember] + [JsonIgnore] + public float LengthSquared => (X * X) + (Y * Y); + + /// Returns whether this vector is equal to another vector. + public bool Equals(CultVec2 other) => X.Equals(other.X) && Y.Equals(other.Y); + + /// + public override bool Equals(object? obj) => obj is CultVec2 other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(X, Y); + + /// + public override string ToString() => $"({X}, {Y})"; + + public static CultVec2 operator +(CultVec2 left, CultVec2 right) => new(left.X + right.X, left.Y + right.Y); + public static CultVec2 operator -(CultVec2 left, CultVec2 right) => new(left.X - right.X, left.Y - right.Y); + public static CultVec2 operator *(CultVec2 value, float scalar) => new(value.X * scalar, value.Y * scalar); + public static CultVec2 operator /(CultVec2 value, float scalar) => new(value.X / scalar, value.Y / scalar); + public static bool operator ==(CultVec2 left, CultVec2 right) => left.Equals(right); + public static bool operator !=(CultVec2 left, CultVec2 right) => !left.Equals(right); + } + + /// + /// Deterministic three-dimensional float vector for cross-runtime geometry and query contracts. + /// + [MessagePackObject] + public readonly struct CultVec3 : IEquatable + { + /// Creates a three-dimensional vector. + public CultVec3(float x, float y, float z) + { + X = x; + Y = y; + Z = z; + } + + /// Gets the x component. + [Key(0)] + public float X { get; } + + /// Gets the y component. + [Key(1)] + public float Y { get; } + + /// Gets the z component. + [Key(2)] + public float Z { get; } + + /// Gets the zero vector. + [IgnoreMember] + [JsonIgnore] + public static CultVec3 Zero => new(0f, 0f, 0f); + + /// Gets the XY plane projection. + [IgnoreMember] + [JsonIgnore] + public CultVec2 Xy => new(X, Y); + + /// Gets the XZ plane projection for Unity compatibility adapters. + [IgnoreMember] + [JsonIgnore] + public CultVec2 Xz => new(X, Z); + + /// Gets the squared vector length. + [IgnoreMember] + [JsonIgnore] + public float LengthSquared => (X * X) + (Y * Y) + (Z * Z); + + /// Returns whether this vector is equal to another vector. + public bool Equals(CultVec3 other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); + + /// + public override bool Equals(object? obj) => obj is CultVec3 other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(X, Y, Z); + + /// + public override string ToString() => $"({X}, {Y}, {Z})"; + + public static CultVec3 operator +(CultVec3 left, CultVec3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); + public static CultVec3 operator -(CultVec3 left, CultVec3 right) => new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); + public static CultVec3 operator *(CultVec3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar); + public static CultVec3 operator /(CultVec3 value, float scalar) => new(value.X / scalar, value.Y / scalar, value.Z / scalar); + public static bool operator ==(CultVec3 left, CultVec3 right) => left.Equals(right); + public static bool operator !=(CultVec3 left, CultVec3 right) => !left.Equals(right); + } + + /// + /// Axis-aligned two-dimensional rectangle represented by canonical min and max corners. + /// + [MessagePackObject] + public readonly struct CultRect : IEquatable + { + /// Creates a rectangle and canonicalizes min/max corners. + public CultRect(CultVec2 min, CultVec2 max) + { + Min = new CultVec2(Math.Min(min.X, max.X), Math.Min(min.Y, max.Y)); + Max = new CultVec2(Math.Max(min.X, max.X), Math.Max(min.Y, max.Y)); + } + + /// Creates a rectangle and canonicalizes min/max corners. + public CultRect(float minX, float minY, float maxX, float maxY) + : this(new CultVec2(minX, minY), new CultVec2(maxX, maxY)) + { + } + + /// Gets the canonical minimum corner. + [Key(0)] + public CultVec2 Min { get; } + + /// Gets the canonical maximum corner. + [Key(1)] + public CultVec2 Max { get; } + + /// Gets the rectangle size. + [IgnoreMember] + [JsonIgnore] + public CultVec2 Size => Max - Min; + + /// Gets the rectangle center. + [IgnoreMember] + [JsonIgnore] + public CultVec2 Center => (Min + Max) * 0.5f; + + /// Returns whether the point lies inside or on the rectangle edge. + public bool Contains(CultVec2 point) + { + return point.X >= Min.X && + point.X <= Max.X && + point.Y >= Min.Y && + point.Y <= Max.Y; + } + + /// Returns whether this rectangle intersects another rectangle. + public bool Intersects(CultRect other) + { + return Min.X <= other.Max.X && + Max.X >= other.Min.X && + Min.Y <= other.Max.Y && + Max.Y >= other.Min.Y; + } + + /// Returns whether this rectangle is equal to another rectangle. + public bool Equals(CultRect other) => Min == other.Min && Max == other.Max; + + /// + public override bool Equals(object? obj) => obj is CultRect other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Min, Max); + + /// + public override string ToString() => $"[{Min}..{Max}]"; + + public static bool operator ==(CultRect left, CultRect right) => left.Equals(right); + public static bool operator !=(CultRect left, CultRect right) => !left.Equals(right); + } + + /// + /// Two-dimensional circle for spatial queries. + /// + [MessagePackObject] + public readonly struct CultCircle : IEquatable + { + /// Creates a circle. + public CultCircle(CultVec2 center, float radius) + { + if (radius < 0f) + { + throw new ArgumentOutOfRangeException(nameof(radius), "Radius cannot be negative."); + } + + Center = center; + Radius = radius; + } + + /// Gets the circle center. + [Key(0)] + public CultVec2 Center { get; } + + /// Gets the circle radius. + [Key(1)] + public float Radius { get; } + + /// Gets the circle bounds. + [IgnoreMember] + [JsonIgnore] + public CultRect Bounds => new( + Center.X - Radius, + Center.Y - Radius, + Center.X + Radius, + Center.Y + Radius); + + /// Returns whether the point lies inside or on the circle edge. + public bool Contains(CultVec2 point) + { + var delta = point - Center; + return delta.LengthSquared <= Radius * Radius; + } + + /// Returns whether this circle intersects a rectangle. + public bool Intersects(CultRect rect) + { + var clampedX = Math.Max(rect.Min.X, Math.Min(Center.X, rect.Max.X)); + var clampedY = Math.Max(rect.Min.Y, Math.Min(Center.Y, rect.Max.Y)); + return Contains(new CultVec2(clampedX, clampedY)); + } + + /// Returns whether this circle is equal to another circle. + public bool Equals(CultCircle other) => Center == other.Center && Radius.Equals(other.Radius); + + /// + public override bool Equals(object? obj) => obj is CultCircle other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Center, Radius); + + public static bool operator ==(CultCircle left, CultCircle right) => left.Equals(right); + public static bool operator !=(CultCircle left, CultCircle right) => !left.Equals(right); + } + + /// + /// Three-dimensional sphere for physics and spatial queries. + /// + [MessagePackObject] + public readonly struct CultSphere : IEquatable + { + /// Creates a sphere. + public CultSphere(CultVec3 center, float radius) + { + if (radius < 0f) + { + throw new ArgumentOutOfRangeException(nameof(radius), "Radius cannot be negative."); + } + + Center = center; + Radius = radius; + } + + /// Gets the sphere center. + [Key(0)] + public CultVec3 Center { get; } + + /// Gets the sphere radius. + [Key(1)] + public float Radius { get; } + + /// Gets the XY circle projection. + [IgnoreMember] + [JsonIgnore] + public CultCircle XyCircle => new(Center.Xy, Radius); + + /// Returns whether the point lies inside or on the sphere surface. + public bool Contains(CultVec3 point) + { + var delta = point - Center; + return delta.LengthSquared <= Radius * Radius; + } + + /// Returns whether this sphere is equal to another sphere. + public bool Equals(CultSphere other) => Center == other.Center && Radius.Equals(other.Radius); + + /// + public override bool Equals(object? obj) => obj is CultSphere other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Center, Radius); + + public static bool operator ==(CultSphere left, CultSphere right) => left.Equals(right); + public static bool operator !=(CultSphere left, CultSphere right) => !left.Equals(right); + } +} diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index 23999af8..a0192f69 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -10,6 +10,7 @@ + diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 57112b08..200f14ed 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -1,7 +1,8 @@ # GameCult.Geometry -`GameCult.Geometry` defines CultCache-native documents for procedural geometry -domains, LOD build requests, selected-cut manifests, and mesh chunk artifacts. +`GameCult.Geometry` defines shared geometry values plus CultCache-native +documents for procedural geometry domains, LOD build requests, selected-cut +manifests, and mesh chunk artifacts. The package is deliberately substrate-shaped: @@ -14,6 +15,22 @@ The package is deliberately substrate-shaped: the xeno boundary, but the live machine eats and emits typed CultCache documents. +## Value Primitives + +The small value layer is for cross-runtime query contracts, physics probes, +native slices, and UI surfaces: + +- `CultVec2` and `CultVec3`: whole-vector values for positions, velocities, + accelerations, and query inputs. SoA layouts may store them efficiently, but + the semantic API does not force callers to split x/y/z into unrelated fields. +- `CultRect`: canonical XY viewport/query rectangle stored as min/max corners. +- `CultCircle`: 2D influence/query brush with rect intersection helpers. +- `CultSphere`: 3D query primitive with whole-vector center and XY projection. + +Aetheria object viewports, gravity influence brushes, Ymir overlap/cast query +fixtures, and Unity native render views should use these shapes or generated +runtime equivalents instead of bespoke `{ minX, minY, maxX, maxY }` payloads. + ## Runtime Path `vg-csg` emits domain documents, selected-cut manifests, and chunk artifacts as @@ -55,6 +72,8 @@ cache miss; that is split-brain geometry. ## Documents +- `CultVec2`, `CultVec3`, `CultRect`, `CultCircle`, `CultSphere`: shared value + primitives for query and view contracts. - `CultGeometryDomainDocument`: one hierarchical domain tree, suitable for Ragnarok/Fensalir-style feature DSL output. - `CultGeometryBuildRequest`: one LOD/frustum/budget request for workers. diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index 1723b852..b39b5182 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -3,17 +3,72 @@ using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Mesh; using GameCult.Networking; +using MessagePack; using NUnit.Framework; using R3; using System; using System.Collections.Generic; using System.IO; +using System.Text.Json; using System.Threading.Tasks; namespace GameCult.Geometry.Tests { public sealed class GeometryDocumentTests { + [Test] + public void GeometryPrimitives_CanonicalizeAndIntersect() + { + var viewport = new CultRect(8f, 4f, -8f, -4f); + + viewport.Min.Should().Be(new CultVec2(-8f, -4f)); + viewport.Max.Should().Be(new CultVec2(8f, 4f)); + viewport.Center.Should().Be(CultVec2.Zero); + viewport.Contains(new CultVec2(2f, 3f)).Should().BeTrue(); + viewport.Intersects(new CultRect(7f, 3f, 10f, 5f)).Should().BeTrue(); + viewport.Intersects(new CultRect(9f, 5f, 10f, 6f)).Should().BeFalse(); + + var gravityBrush = new CultCircle(new CultVec2(10f, 0f), 3f); + gravityBrush.Intersects(viewport).Should().BeTrue(); + gravityBrush.Contains(new CultVec2(12f, 0f)).Should().BeTrue(); + gravityBrush.Contains(new CultVec2(14f, 0f)).Should().BeFalse(); + } + + [Test] + public void GeometryPrimitives_PreserveWholeVectorsForSoaAndPhysicsQueries() + { + var sphere = new CultSphere(new CultVec3(1f, 2f, 3f), 5f); + + sphere.Contains(new CultVec3(1f, 6f, 3f)).Should().BeTrue(); + sphere.Contains(new CultVec3(1f, 8f, 3f)).Should().BeFalse(); + sphere.Center.Xy.Should().Be(new CultVec2(1f, 2f)); + sphere.Center.Xz.Should().Be(new CultVec2(1f, 3f)); + sphere.XyCircle.Bounds.Should().Be(new CultRect(-4f, -3f, 6f, 7f)); + } + + [Test] + public void GeometryPrimitives_RoundTripThroughMessagePack() + { + var rect = new CultRect(new CultVec2(5f, -2f), new CultVec2(-1f, 7f)); + var payload = MessagePackSerializer.Serialize(rect); + var decoded = MessagePackSerializer.Deserialize(payload); + + decoded.Should().Be(rect); + decoded.Min.Should().Be(new CultVec2(-1f, -2f)); + decoded.Max.Should().Be(new CultVec2(5f, 7f)); + } + + [Test] + public void GeometryPrimitives_JsonOmitsComputedConvenienceProperties() + { + var json = JsonSerializer.Serialize(new CultVec3(1f, 2f, 3f)); + + json.Should().Be("""{"X":1,"Y":2,"Z":3}"""); + json.Should().NotContain("LengthSquared"); + json.Should().NotContain("Xy"); + json.Should().NotContain("Xz"); + } + [Test] public void GeometryDocuments_Register_AsCultCacheSchemas() { From 6e000a71a9bd319559cb4bb6d621d78c2e2aafdf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 14:35:08 +0100 Subject: [PATCH 012/498] Mirror CultMesh primitives in TypeScript --- packages/cultmesh-ts/README.md | 148 + packages/cultmesh-ts/src/index.ts | 2694 +++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 1127 +++++++ .../docs/cross-runtime-primitives-roadmap.md | 298 ++ 4 files changed, 4267 insertions(+) create mode 100644 src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index e0481ed7..3772ef92 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -11,6 +11,154 @@ frame body path. The TypeScript projection keeps the same local catalog ergonomics as the other runtimes: sorted views, direct lookup, and unsubscribe-able watches. +## Shared Primitives + +CultMesh primitives are shared cross-runtime developer affordances, not just +DTOs. They bundle the typed surface, metadata, routing hints, diagnostics, and +fast-path hooks that let application code feel like it is talking to native +state while CultMesh chooses the right local, shared-memory, IPC, network, or +WASM route. Runtime code can ask for a whole XY rect, then adapt it to the +current wire schema at the edge: + +```ts +import { CultMesh } from "cultmesh-ts"; + +const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300); +const request = CultMesh.viewportRequest(viewport, [3, 8]); +``` + +The request currently projects to the compact `{ minX, minY, maxX, maxY }` +shape used by Aetheria viewport queries, while the authored code keeps the +designer-facing concept as a `CultMeshRect`. + +Typed operations follow the same rule: client code holds a method-shaped handle +and a runtime context, while CultMesh hides the route and authority metadata +from call sites that do not need to inspect it. + +```ts +const move = CultMesh.operation("game.pilot.move.v1", async (request, context) => { + return submitToBestRoute(request, context); +}); + +await move.invoke({ direction: CultMesh.vec2(1, 0) }, CultMesh.operationContext("rts-client", { + routeHint: CultMesh.routeHint("network", "starfire Verse"), +})); +``` + +Typed query surfaces can be read once or watched through the same handle. A +projection recipe keeps its source metadata, route preference, and reactive +watcher when it becomes a query surface: + +```ts +const objects = CultMesh.projectionRecipe( + "aetheria.zone.objects.visible", + [CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")], + projectObjects, + { + routeHint: CultMesh.routeHint("shared-memory", "co-located frame slab"), + watchProjection: watchObjects, + }, +).asQuerySurface(); + +const stop = objects.watch( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -10, 10, 10)) }, + "browser-starfire", + (next) => render(next), +); +``` + +State pointers are the same kind of managed surface for UI and tools. They can +advertise source documents, inherit a Verse route when bound, and resolve +through that Verse without caller-side context plumbing: + +```ts +const framePointer = CultMesh.statePointer( + "daemon:aetheria.frame.latest.v1", + async (context) => readFrameViaBestRoute(context), + undefined, + { + sources: [CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")], + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon frame"), + }, +); + +const frame = await CultMesh + .bindStatePointer(verse.withRoute("ipc", "Bifrost tool bridge"), framePointer) + .resolve(); + +const stateBinding = CultMesh.stateBinding("value", framePointer); +const stateBindingRecord = CultMesh.stateBindingRecord(stateBinding); +const restoredStateBinding = CultMesh.stateBindingFromRecord(stateBindingRecord); +``` + +Eve/CultUI lowerers that still receive authored state refs should use a named +state-ref resolver instead of local string utilities. Resolvers compose, carry +source and route metadata, and can be described by tools: + +```ts +const daemonRefs = CultMesh.stateRefResolver( + "aetheria.daemon.refs", + (stateRef, context) => resolveDaemonStateRef(stateRef, context), + { + sources: [CultMesh.projectionSource("daemon:aetheria.frame.latest.v1")], + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon state"), + }, +); + +const catalogRefs = CultMesh.stateRefResolver( + "aetheria.item_stats.refs", + (stateRef) => resolveItemStatRef(stateRef), +); + +const resolver = daemonRefs.or(catalogRefs); +const value = resolver.resolve("aetheria:daemon:item-stat:laser.output"); +const diagnostic = CultMesh.describeStateRefResolver(resolver); +``` + +Operation surfaces should preserve typed CultMesh identity all the way through +UI/runtime boundaries. Use invocation records and payload records at persistence +edges instead of local route parsers or string-map conventions: + +```ts +const binding = CultMesh.operationBinding("gamecult.aetheria.pilot.set_target.v1", { + schemaId: "gamecult.aetheria.pilot.set_target.request.v1", + routeHint: CultMesh.routeHint("network", "co-op Verse"), +}); +const bindingRecord = CultMesh.operationBindingRecord(binding); +const restoredBinding = CultMesh.operationBindingFromRecord(bindingRecord); + +const invocation = CultMesh.operationInvocation(binding, { + idempotencyKey: "target:starfire:42", +}); +const invocationRecord = CultMesh.operationInvocationRecord(invocation); +const payloadRecord = CultMesh.operationPayload({ targetEntityKey: "bandit-7" }).toRecord(); +const restored = CultMesh.operationInvocationFromRecord(invocationRecord); +``` + +Route fields use the same shared primitive: + +```ts +const routeRecord = CultMesh.routeRecord(CultMesh.routeHint("shared-memory", "co-located slab")); +const route = CultMesh.routeFromRecord(routeRecord); +``` + +When a client needs one coherent live view composed from several query surfaces, +use a live feed instead of putting a refresh loop in the UI: + +```ts +const viewportFeed = CultMesh.liveFeed( + "aetheria.rts.viewport.feed", + snapshotViewport, + { + sources: objects.sources, + routeHint: objects.routeHint, + watchFeed: CultMesh.pollingQueryWatcher(snapshotViewport, { intervalMs: 50 }), + }, +); + +const stopFeed = viewportFeed.watch(request, "browser-starfire", render); +``` + ```ts import { CultMesh } from "cultmesh-ts"; diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 53225705..b87167d8 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -29,6 +29,2056 @@ import { type CultNetWireContract, } from "cultnet-ts"; +export interface CultMeshVec2 { + readonly x: number; + readonly y: number; +} + +export interface CultMeshRect { + readonly min: CultMeshVec2; + readonly max: CultMeshVec2; +} + +export interface CultMeshViewportRequest { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; + readonly controlledEntityIndices?: readonly number[]; +} + +export type CultMeshLocalityKind = + | "automatic" + | "in-process" + | "shared-memory" + | "ipc" + | "network" + | "wasm"; + +export interface CultMeshRouteHint { + readonly kind: CultMeshLocalityKind; + readonly description?: string; +} + +export interface CultMeshRouteRecord { + readonly kind: string; + readonly description: string; +} + +export interface CultMeshAuthorityClaim { + readonly role: string; + readonly shardId?: string; + readonly leaseId?: string; +} + +export interface CultMeshVerseContext { + readonly verseId: string; + readonly runtimeId: string; + readonly routeHint: CultMeshRouteHint; + readonly claims: readonly CultMeshAuthorityClaim[]; +} + +export interface CultMeshOperationContext { + readonly runtimeId: string; + readonly claims: readonly CultMeshAuthorityClaim[]; + readonly routeHint: CultMeshRouteHint; + readonly idempotencyKey?: string; +} + +export interface CultMeshOperationReceipt { + readonly operationId: string; + readonly accepted: boolean; + readonly route: CultMeshRouteHint; + readonly diagnostic?: string; +} + +export interface CultMeshQueryContext { + readonly runtimeId: string; + readonly routeHint: CultMeshRouteHint; +} + +export type CultMeshUnsubscribe = () => void; + +export type CultMeshStatePointerResolver = ( + context: CultMeshQueryContext, +) => Promise; + +export type CultMeshStatePointerWatcher = ( + context: CultMeshQueryContext, + callback: (value: T) => void, +) => CultMeshUnsubscribe; + +export type CultMeshMutableStatePointerReplacer = ( + context: CultMeshQueryContext, + value: T, +) => Promise; + +export type CultMeshQueryWatcher = ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, +) => CultMeshUnsubscribe; + +export type CultMeshLiveFeedWatcher = ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, +) => CultMeshUnsubscribe; + +export interface CultMeshPollingWatchOptions { + readonly intervalMs?: number; + readonly emitInitial?: boolean; + readonly equals?: (left: TResult, right: TResult) => boolean; +} + +export interface CultMeshNativeSliceColumn { + readonly name: string; + readonly valueType: string; + readonly elementSizeBytes: number; +} + +export interface CultMeshNativeSliceViewDescriptor { + readonly viewId: string; + readonly schemaId: string; + readonly rowCount: number; + readonly columns: readonly CultMeshNativeSliceColumn[]; + readonly route: CultMeshRouteHint; + readonly nativeHandle?: string; +} + +export interface CultMeshProjectionSource { + readonly sourceId: string; + readonly schemaId?: string; + readonly description?: string; +} + +export interface CultMeshStateBindingDescriptor { + readonly targetProp: string; + readonly pointerId: string; + readonly sourceId?: string; + readonly schemaId?: string; + readonly routeHint: CultMeshRouteHint; +} + +export interface CultMeshStateBindingRecord { + readonly targetProp: string; + readonly pointerId: string; + readonly sourceId: string; + readonly schemaId: string; + readonly routeKind: string; + readonly routeDescription: string; +} + +export interface CultMeshOperationBindingDescriptor { + readonly operationId: string; + readonly label: string; + readonly schemaId: string; + readonly routeHint: CultMeshRouteHint; +} + +export interface CultMeshOperationBindingRecord { + readonly operationId: string; + readonly label: string; + readonly schemaId: string; + readonly routeKind: string; + readonly routeDescription: string; +} + +export interface CultMeshOperationInvocationDescriptor { + readonly operationId: string; + readonly schemaId: string; + readonly routeHint: CultMeshRouteHint; + readonly idempotencyKey?: string; +} + +export interface CultMeshOperationInvocationRecord { + readonly operationId: string; + readonly schemaId: string; + readonly routeKind: string; + readonly routeDescription: string; + readonly idempotencyKey: string; +} + +export interface CultMeshOperationPayload { + readonly fields: Readonly>; + getString(key: string, defaultValue?: string): string; + getInt(key: string, defaultValue?: number): number; + getDouble(key: string, defaultValue?: number): number; + getBoolean(key: string, defaultValue?: boolean): boolean; + with(key: string, value: string | number | boolean): CultMeshOperationPayload; + toRecord(): Readonly>; +} + +export interface CultMeshQuerySurfaceDiagnostic { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshOperationHandleDiagnostic { + readonly operationId: string; +} + +export interface CultMeshStatePointerDiagnostic { + readonly pointerId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshStateRefResolverDiagnostic { + readonly resolverId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshProjectionRecipeDiagnostic { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshLiveFeedDiagnostic { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export type CultMeshSurfaceKind = + | "query" + | "projection-recipe" + | "live-feed" + | "operation" + | "state-pointer" + | "native-slice-view"; + +export interface CultMeshSurfaceDiagnostic { + readonly kind: CultMeshSurfaceKind; + readonly surfaceId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; +} + +export interface CultMeshSurfaceCatalogDiagnostic { + readonly catalogId: string; + readonly surfaces: readonly CultMeshSurfaceDiagnostic[]; +} + +export interface CultMeshSurfaceCatalogIndexDiagnostic { + readonly catalogId: string; + readonly queries: readonly CultMeshSurfaceDiagnostic[]; + readonly projectionRecipes: readonly CultMeshSurfaceDiagnostic[]; + readonly liveFeeds: readonly CultMeshSurfaceDiagnostic[]; + readonly operations: readonly CultMeshSurfaceDiagnostic[]; + readonly statePointers: readonly CultMeshSurfaceDiagnostic[]; + readonly nativeSliceViews: readonly CultMeshSurfaceDiagnostic[]; +} + +export interface CultMeshNativeSliceViewDiagnostic { + readonly viewId: string; + readonly schemaId: string; + readonly rowCount: number; + readonly columns: readonly CultMeshNativeSliceColumn[]; + readonly route: CultMeshRouteHint; + readonly nativeHandle?: string; + readonly denseRowStrideBytes: number; +} + +export class CultMeshOperationContextBuilder { + readonly #runtimeId: string; + #claims: CultMeshAuthorityClaim[] = []; + #routeHint: CultMeshRouteHint = cultMeshRouteHint(); + #idempotencyKey: string | undefined; + + public constructor(runtimeId: string) { + requireNonEmpty(runtimeId, "runtimeId"); + this.#runtimeId = runtimeId; + } + + public claim( + role: string, + options: { shardId?: string; leaseId?: string } = {}, + ): this { + this.#claims.push(cultMeshAuthorityClaim(role, options)); + return this; + } + + public claims(claims: readonly CultMeshAuthorityClaim[]): this { + this.#claims.push(...claims); + return this; + } + + public route(kind: CultMeshLocalityKind, description?: string): this { + this.#routeHint = cultMeshRouteHint(kind, description); + return this; + } + + public idempotency(key: string): this { + requireNonEmpty(key, "idempotencyKey"); + this.#idempotencyKey = key; + return this; + } + + public build(): CultMeshOperationContext { + return cultMeshOperationContext(this.#runtimeId, { + claims: this.#claims, + routeHint: this.#routeHint, + idempotencyKey: this.#idempotencyKey, + }); + } +} + +export class CultMeshQueryContextBuilder { + readonly #runtimeId: string; + #routeHint: CultMeshRouteHint = cultMeshRouteHint(); + + public constructor(runtimeId: string) { + requireNonEmpty(runtimeId, "runtimeId"); + this.#runtimeId = runtimeId; + } + + public route(kind: CultMeshLocalityKind, description?: string): this { + this.#routeHint = cultMeshRouteHint(kind, description); + return this; + } + + public build(): CultMeshQueryContext { + return cultMeshQueryContext(this.#runtimeId, { + routeHint: this.#routeHint, + }); + } +} + +export class CultMeshVerse { + public constructor(public readonly context: CultMeshVerseContext) { + requireNonEmpty(context.verseId, "verseId"); + requireNonEmpty(context.runtimeId, "runtimeId"); + } + + public get verseId(): string { + return this.context.verseId; + } + + public get runtimeId(): string { + return this.context.runtimeId; + } + + public use(schemaFactory: (context: CultMeshVerseContext) => TSchema): TSchema { + return schemaFactory(this.context); + } + + public withRoute(kind: CultMeshLocalityKind, description?: string): CultMeshVerse { + return new CultMeshVerse({ + ...this.context, + routeHint: cultMeshRouteHint(kind, description), + }); + } + + public withClaim(role: string, options: { shardId?: string; leaseId?: string } = {}): CultMeshVerse { + return new CultMeshVerse({ + ...this.context, + claims: [...this.context.claims, cultMeshAuthorityClaim(role, options)], + }); + } + + public operationContext(options: { idempotencyKey?: string } = {}): CultMeshOperationContext { + return cultMeshOperationContextFromVerse(this.context, options); + } + + public queryContext(): CultMeshQueryContext { + return cultMeshQueryContextFromVerse(this.context); + } + + public bindOperation( + operation: CultMeshOperationHandle, + ): CultMeshBoundOperationHandle { + return cultMeshBindOperation(this.context, operation); + } + + public bindQuery( + query: CultMeshQuerySurface, + ): CultMeshBoundQuerySurface { + return cultMeshBindQuery(this.context, query); + } + + public bindLiveFeed( + feed: CultMeshLiveFeed, + ): CultMeshBoundLiveFeed { + return cultMeshBindLiveFeed(this.context, feed); + } + + public bindStatePointer( + pointer: CultMeshStatePointer, + ): CultMeshBoundStatePointer { + return cultMeshBindStatePointer(this.context, pointer); + } + + public bindMutableStatePointer( + pointer: CultMeshMutableStatePointer, + ): CultMeshBoundMutableStatePointer { + return cultMeshBindMutableStatePointer(this.context, pointer); + } +} + +export class CultMeshOperationHandle { + public constructor( + public readonly operationId: string, + private readonly invokeOperation: ( + request: TRequest, + context: CultMeshOperationContext, + ) => Promise, + ) { + requireNonEmpty(operationId, "operationId"); + } + + public invoke( + request: TRequest, + context: CultMeshOperationContext | string, + ): Promise { + return this.invokeOperation( + request, + typeof context === "string" ? cultMeshOperationContext(context) : context, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundOperationHandle { + return cultMeshBindOperation(verse, this); + } +} + +export class CultMeshBoundOperationHandle { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly operation: CultMeshOperationHandle, + ) {} + + public get operationId(): string { + return this.operation.operationId; + } + + public invoke( + request: TRequest, + options: { idempotencyKey?: string } = {}, + ): Promise { + return this.operation.invoke( + request, + cultMeshOperationContextFromVerse(this.verse, options), + ); + } +} + +export class CultMeshQuerySurface { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly queryId: string, + private readonly executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchQuery?: CultMeshQueryWatcher; + } = {}, + ) { + requireNonEmpty(queryId, "queryId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchQuery = options.watchQuery; + } + + private readonly watchQuery: CultMeshQueryWatcher | undefined; + + public execute( + parameters: TParameters, + context: CultMeshQueryContext | string, + ): Promise { + return this.executeQuery( + parameters, + typeof context === "string" ? cultMeshQueryContext(context) : context, + ); + } + + public query(parameters: TParameters, context: CultMeshQueryContext | string): Promise { + return this.execute(parameters, context); + } + + public watch( + parameters: TParameters, + context: CultMeshQueryContext | string, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + if (!this.watchQuery) { + throw new Error(`Query surface '${this.queryId}' does not support watches.`); + } + + return this.watchQuery( + parameters, + typeof context === "string" ? cultMeshQueryContext(context) : context, + callback, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundQuerySurface { + return cultMeshBindQuery(verse, this); + } +} + +export class CultMeshBoundQuerySurface { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly query: CultMeshQuerySurface, + ) {} + + public get queryId(): string { + return this.query.queryId; + } + + public get sources(): readonly CultMeshProjectionSource[] { + return this.query.sources; + } + + public get routeHint(): CultMeshRouteHint { + return this.query.routeHint; + } + + public execute(parameters: TParameters): Promise { + return this.query.execute(parameters, cultMeshQueryContextFromVerse(this.verse)); + } + + public queryOnce(parameters: TParameters): Promise { + return this.execute(parameters); + } + + public watch( + parameters: TParameters, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + return this.query.watch(parameters, cultMeshQueryContextFromVerse(this.verse), callback); + } +} + +export class CultMeshLiveFeed { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly feedId: string, + private readonly snapshotFeed: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchFeed?: CultMeshLiveFeedWatcher; + } = {}, + ) { + requireNonEmpty(feedId, "feedId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchFeed = options.watchFeed; + } + + private readonly watchFeed: CultMeshLiveFeedWatcher | undefined; + + public snapshot( + parameters: TParameters, + context: CultMeshQueryContext | string, + ): Promise { + return this.snapshotFeed( + parameters, + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + ); + } + + public watch( + parameters: TParameters, + context: CultMeshQueryContext | string, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + if (!this.watchFeed) { + throw new Error(`Live feed '${this.feedId}' does not support watches.`); + } + + return this.watchFeed( + parameters, + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + callback, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundLiveFeed { + return cultMeshBindLiveFeed(verse, this); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + + return cultMeshQueryContext(context.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshBoundLiveFeed { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly feed: CultMeshLiveFeed, + ) {} + + public get feedId(): string { + return this.feed.feedId; + } + + public get sources(): readonly CultMeshProjectionSource[] { + return this.feed.sources; + } + + public get routeHint(): CultMeshRouteHint { + return this.feed.routeHint; + } + + public snapshot(parameters: TParameters): Promise { + return this.feed.snapshot(parameters, cultMeshQueryContextFromVerse(this.verse)); + } + + public watch( + parameters: TParameters, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + return this.feed.watch(parameters, cultMeshQueryContextFromVerse(this.verse), callback); + } +} + +export class CultMeshStatePointer { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly pointerId: string, + private readonly resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + private readonly watchPointer?: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ) { + requireNonEmpty(pointerId, "pointerId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + } + + public resolve(context?: CultMeshQueryContext | string): Promise { + return (this.resolvePointer as CultMeshStatePointerResolver)( + this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ), + ); + } + + public watch(callback: (value: T) => void): CultMeshUnsubscribe; + public watch(context: CultMeshQueryContext | string, callback: (value: T) => void): CultMeshUnsubscribe; + public watch( + contextOrCallback: CultMeshQueryContext | string | ((value: T) => void), + maybeCallback?: (value: T) => void, + ): CultMeshUnsubscribe { + if (!this.watchPointer) { + throw new Error(`State pointer '${this.pointerId}' does not support watches.`); + } + + const callback = + typeof contextOrCallback === "function" ? contextOrCallback : maybeCallback; + if (!callback) { + throw new Error(`State pointer '${this.pointerId}' requires a watch callback.`); + } + + if (this.watchPointer.length <= 1) { + return (this.watchPointer as (callback: (value: T) => void) => CultMeshUnsubscribe)(callback); + } + + const context = + typeof contextOrCallback === "function" + ? undefined + : typeof contextOrCallback === "string" + ? cultMeshQueryContext(contextOrCallback) + : contextOrCallback; + return (this.watchPointer as CultMeshStatePointerWatcher)( + this.resolveContext(context), + callback, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundStatePointer { + return cultMeshBindStatePointer(verse, this); + } + + private resolveContext(context?: CultMeshQueryContext): CultMeshQueryContext { + const resolved = context ?? cultMeshQueryContext("local"); + if (resolved.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return resolved; + } + + return cultMeshQueryContext(resolved.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshBoundStatePointer { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly pointer: CultMeshStatePointer, + ) {} + + public get pointerId(): string { + return this.pointer.pointerId; + } + + public get sources(): readonly CultMeshProjectionSource[] { + return this.pointer.sources; + } + + public get routeHint(): CultMeshRouteHint { + return this.pointer.routeHint; + } + + public resolve(): Promise { + return this.pointer.resolve(cultMeshQueryContextFromVerse(this.verse)); + } + + public watch(callback: (value: T) => void): CultMeshUnsubscribe { + return this.pointer.watch(cultMeshQueryContextFromVerse(this.verse), callback); + } +} + +export class CultMeshMutableStatePointer { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly pointerId: string, + private readonly resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + private readonly watchPointer: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + private readonly replacePointer: + | ((value: T) => Promise) + | CultMeshMutableStatePointerReplacer, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ) { + requireNonEmpty(pointerId, "pointerId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + } + + public resolve(context?: CultMeshQueryContext | string): Promise { + return (this.resolvePointer as CultMeshStatePointerResolver)( + this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ), + ); + } + + public read(context?: CultMeshQueryContext | string): Promise { + return this.resolve(context); + } + + public replace(value: T): Promise; + public replace(context: CultMeshQueryContext | string, value: T): Promise; + public replace( + contextOrValue: CultMeshQueryContext | string | T, + maybeValue?: T, + ): Promise { + if (this.replacePointer.length <= 1) { + return (this.replacePointer as (value: T) => Promise)( + contextOrValue as T, + ); + } + + const context = + typeof contextOrValue === "string" + ? cultMeshQueryContext(contextOrValue) + : isCultMeshQueryContext(contextOrValue) + ? contextOrValue + : undefined; + const value = context ? maybeValue : (contextOrValue as T); + if (value === undefined) { + throw new Error(`Mutable state pointer '${this.pointerId}' requires a replacement value.`); + } + + return (this.replacePointer as CultMeshMutableStatePointerReplacer)( + this.resolveContext(context), + value, + ); + } + + public watch(callback: (value: T) => void): CultMeshUnsubscribe; + public watch(context: CultMeshQueryContext | string, callback: (value: T) => void): CultMeshUnsubscribe; + public watch( + contextOrCallback: CultMeshQueryContext | string | ((value: T) => void), + maybeCallback?: (value: T) => void, + ): CultMeshUnsubscribe { + const callback = + typeof contextOrCallback === "function" ? contextOrCallback : maybeCallback; + if (!callback) { + throw new Error(`Mutable state pointer '${this.pointerId}' requires a watch callback.`); + } + + if (this.watchPointer.length <= 1) { + return (this.watchPointer as (callback: (value: T) => void) => CultMeshUnsubscribe)(callback); + } + + const context = + typeof contextOrCallback === "function" + ? undefined + : typeof contextOrCallback === "string" + ? cultMeshQueryContext(contextOrCallback) + : contextOrCallback; + return (this.watchPointer as CultMeshStatePointerWatcher)( + this.resolveContext(context), + callback, + ); + } + + public asStatePointer(): CultMeshStatePointer { + return cultMeshStatePointer( + this.pointerId, + (context) => this.resolve(context), + (context, callback) => this.watch(context, callback), + { + routeHint: this.routeHint, + sources: this.sources, + }, + ); + } + + public bind( + verse: CultMeshVerseContext | CultMeshVerse, + ): CultMeshBoundMutableStatePointer { + return cultMeshBindMutableStatePointer(verse, this); + } + + private resolveContext(context?: CultMeshQueryContext): CultMeshQueryContext { + const resolved = context ?? cultMeshQueryContext("local"); + if (resolved.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return resolved; + } + + return cultMeshQueryContext(resolved.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshBoundMutableStatePointer { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly pointer: CultMeshMutableStatePointer, + ) {} + + public get pointerId(): string { + return this.pointer.pointerId; + } + + public get sources(): readonly CultMeshProjectionSource[] { + return this.pointer.sources; + } + + public get routeHint(): CultMeshRouteHint { + return this.pointer.routeHint; + } + + public resolve(): Promise { + return this.pointer.resolve(cultMeshQueryContextFromVerse(this.verse)); + } + + public read(): Promise { + return this.resolve(); + } + + public replace(value: T): Promise { + return this.pointer.replace(cultMeshQueryContextFromVerse(this.verse), value); + } + + public watch(callback: (value: T) => void): CultMeshUnsubscribe { + return this.pointer.watch(cultMeshQueryContextFromVerse(this.verse), callback); + } + + public asStatePointer(): CultMeshBoundStatePointer { + return cultMeshBindStatePointer(this.verse, this.pointer.asStatePointer()); + } +} + +export class CultMeshProjectionRecipe { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly projectionId: string, + sources: readonly CultMeshProjectionSource[], + private readonly projectProjection: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + routeHint?: CultMeshRouteHint; + watchProjection?: ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, + ) => CultMeshUnsubscribe; + } = {}, + ) { + requireNonEmpty(projectionId, "projectionId"); + this.sources = [...sources]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchProjection = options.watchProjection; + } + + private readonly watchProjection: + | (( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, + ) => CultMeshUnsubscribe) + | undefined; + + public project( + parameters: TParameters, + context: CultMeshQueryContext | string, + ): Promise { + return this.projectProjection( + parameters, + this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ), + ); + } + + public watch( + parameters: TParameters, + context: CultMeshQueryContext | string, + callback: (value: TResult) => void, + ): CultMeshUnsubscribe { + if (!this.watchProjection) { + throw new Error(`Projection recipe '${this.projectionId}' does not support watches.`); + } + + return this.watchProjection( + parameters, + this.resolveContext( + typeof context === "string" ? cultMeshQueryContext(context) : context, + ), + callback, + ); + } + + public asQuerySurface(): CultMeshQuerySurface { + return cultMeshQuery(this.projectionId, (parameters, context) => + this.project(parameters, context), + { + sources: this.sources, + routeHint: this.routeHint, + watchQuery: this.watchProjection + ? (parameters, context, callback) => this.watch(parameters, context, callback) + : undefined, + }, + ); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + + return cultMeshQueryContext(context.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export function cultMeshOperationContextFor(runtimeId: string): CultMeshOperationContextBuilder { + return new CultMeshOperationContextBuilder(runtimeId); +} + +export function cultMeshQueryContextFor(runtimeId: string): CultMeshQueryContextBuilder { + return new CultMeshQueryContextBuilder(runtimeId); +} + +export function cultMeshVec2(x: number, y: number): CultMeshVec2 { + return { x, y }; +} + +export function cultMeshRect(min: CultMeshVec2, max: CultMeshVec2): CultMeshRect { + return { + min: cultMeshVec2(Math.min(min.x, max.x), Math.min(min.y, max.y)), + max: cultMeshVec2(Math.max(min.x, max.x), Math.max(min.y, max.y)), + }; +} + +export function cultMeshRectFromBounds( + minX: number, + minY: number, + maxX: number, + maxY: number, +): CultMeshRect { + return cultMeshRect(cultMeshVec2(minX, minY), cultMeshVec2(maxX, maxY)); +} + +export function cultMeshViewportRequest( + viewport: CultMeshRect, + controlledEntityIndices?: readonly number[], +): CultMeshViewportRequest { + return { + minX: viewport.min.x, + minY: viewport.min.y, + maxX: viewport.max.x, + maxY: viewport.max.y, + controlledEntityIndices, + }; +} + +export function cultMeshRouteHint( + kind: CultMeshLocalityKind = "automatic", + description?: string, +): CultMeshRouteHint { + return description ? { kind, description } : { kind }; +} + +export function cultMeshRouteRecord( + routeHint?: CultMeshRouteHint, +): CultMeshRouteRecord; +export function cultMeshRouteRecord( + kind?: string, + description?: string, +): CultMeshRouteRecord; +export function cultMeshRouteRecord( + routeOrKind?: CultMeshRouteHint | string, + description = "", +): CultMeshRouteRecord { + if (typeof routeOrKind === "string" || routeOrKind === undefined) { + return { + kind: routeOrKind ?? "", + description, + }; + } + + return { + kind: routeOrKind.kind, + description: routeOrKind.description ?? "", + }; +} + +export function cultMeshRouteFromRecord( + record: Partial, + fallback: CultMeshRouteHint = cultMeshRouteHint(), +): CultMeshRouteHint { + return cultMeshRouteHint( + parseCultMeshLocalityKind(record.kind, fallback.kind), + nonBlankOr(record.description, fallback.description), + ); +} + +export function cultMeshAuthorityClaim( + role: string, + options: { shardId?: string; leaseId?: string } = {}, +): CultMeshAuthorityClaim { + requireNonEmpty(role, "role"); + return { + role, + shardId: options.shardId, + leaseId: options.leaseId, + }; +} + +export function cultMeshVerseContext( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, +): CultMeshVerseContext { + requireNonEmpty(verseId, "verseId"); + requireNonEmpty(runtimeId, "runtimeId"); + return { + verseId, + runtimeId, + routeHint: options.routeHint ?? cultMeshRouteHint(), + claims: options.claims ? [...options.claims] : [], + }; +} + +export function cultMeshVerse( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, +): CultMeshVerse { + return new CultMeshVerse(cultMeshVerseContext(verseId, runtimeId, options)); +} + +export function cultMeshOperationContextFromVerse( + context: CultMeshVerseContext, + options: { idempotencyKey?: string } = {}, +): CultMeshOperationContext { + return cultMeshOperationContext(context.runtimeId, { + claims: context.claims, + routeHint: context.routeHint, + idempotencyKey: options.idempotencyKey, + }); +} + +export function cultMeshQueryContextFromVerse( + context: CultMeshVerseContext, +): CultMeshQueryContext { + return cultMeshQueryContext(context.runtimeId, { + routeHint: context.routeHint, + }); +} + +export function cultMeshBindOperation( + verse: CultMeshVerseContext | CultMeshVerse, + operation: CultMeshOperationHandle, +): CultMeshBoundOperationHandle { + return new CultMeshBoundOperationHandle(resolveCultMeshVerseContext(verse), operation); +} + +export function cultMeshBindQuery( + verse: CultMeshVerseContext | CultMeshVerse, + query: CultMeshQuerySurface, +): CultMeshBoundQuerySurface { + return new CultMeshBoundQuerySurface(resolveCultMeshVerseContext(verse), query); +} + +export function cultMeshBindLiveFeed( + verse: CultMeshVerseContext | CultMeshVerse, + feed: CultMeshLiveFeed, +): CultMeshBoundLiveFeed { + return new CultMeshBoundLiveFeed(resolveCultMeshVerseContext(verse), feed); +} + +export function cultMeshBindStatePointer( + verse: CultMeshVerseContext | CultMeshVerse, + pointer: CultMeshStatePointer, +): CultMeshBoundStatePointer { + return new CultMeshBoundStatePointer(resolveCultMeshVerseContext(verse), pointer); +} + +export function cultMeshBindMutableStatePointer( + verse: CultMeshVerseContext | CultMeshVerse, + pointer: CultMeshMutableStatePointer, +): CultMeshBoundMutableStatePointer { + return new CultMeshBoundMutableStatePointer(resolveCultMeshVerseContext(verse), pointer); +} + +function resolveCultMeshVerseContext(verse: CultMeshVerseContext | CultMeshVerse): CultMeshVerseContext { + return verse instanceof CultMeshVerse ? verse.context : verse; +} + +function isCultMeshQueryContext(value: unknown): value is CultMeshQueryContext { + if (typeof value !== "object" || value === null) { + return false; + } + + const maybeContext = value as Partial; + return typeof maybeContext.runtimeId === "string" && + typeof maybeContext.routeHint === "object" && + maybeContext.routeHint !== null; +} + +export function cultMeshOperationContext( + runtimeId: string, + options: { + claims?: readonly CultMeshAuthorityClaim[]; + routeHint?: CultMeshRouteHint; + idempotencyKey?: string; + } = {}, +): CultMeshOperationContext { + requireNonEmpty(runtimeId, "runtimeId"); + return { + runtimeId, + claims: options.claims ? [...options.claims] : [], + routeHint: options.routeHint ?? cultMeshRouteHint(), + idempotencyKey: options.idempotencyKey, + }; +} + +export function cultMeshQueryContext( + runtimeId: string, + options: { routeHint?: CultMeshRouteHint } = {}, +): CultMeshQueryContext { + requireNonEmpty(runtimeId, "runtimeId"); + return { + runtimeId, + routeHint: options.routeHint ?? cultMeshRouteHint(), + }; +} + +export function cultMeshOperationReceipt( + operationId: string, + accepted: boolean, + options: { route?: CultMeshRouteHint; diagnostic?: string } = {}, +): CultMeshOperationReceipt { + requireNonEmpty(operationId, "operationId"); + return { + operationId, + accepted, + route: options.route ?? cultMeshRouteHint(), + diagnostic: options.diagnostic, + }; +} + +export function cultMeshOperation( + operationId: string, + invokeOperation: ( + request: TRequest, + context: CultMeshOperationContext, + ) => Promise, +): CultMeshOperationHandle { + return new CultMeshOperationHandle(operationId, invokeOperation); +} + +export function cultMeshQuery( + queryId: string, + executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchQuery?: CultMeshQueryWatcher; + } = {}, +): CultMeshQuerySurface { + return new CultMeshQuerySurface(queryId, executeQuery, options); +} + +export function cultMeshDescribeQuerySurface( + query: { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, +): CultMeshQuerySurfaceDiagnostic { + return { + queryId: query.queryId, + routeHint: query.routeHint, + sources: [...query.sources], + }; +} + +export function cultMeshDescribeOperationHandle( + operation: { + readonly operationId: string; + }, +): CultMeshOperationHandleDiagnostic { + return { + operationId: operation.operationId, + }; +} + +export function cultMeshDescribeStatePointer( + pointer: { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + }, +): CultMeshStatePointerDiagnostic { + return { + pointerId: pointer.pointerId, + routeHint: pointer.routeHint ?? cultMeshRouteHint(), + sources: [...(pointer.sources ?? [])], + }; +} + +export function cultMeshLiveFeed( + feedId: string, + snapshotFeed: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchFeed?: CultMeshLiveFeedWatcher; + } = {}, +): CultMeshLiveFeed { + return new CultMeshLiveFeed(feedId, snapshotFeed, options); +} + +export function cultMeshDescribeLiveFeed( + feed: { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, +): CultMeshLiveFeedDiagnostic { + return { + feedId: feed.feedId, + routeHint: feed.routeHint, + sources: [...feed.sources], + }; +} + +export function cultMeshPollingQueryWatcher( + executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: CultMeshPollingWatchOptions = {}, +): CultMeshQueryWatcher { + const intervalMs = Math.max(1, options.intervalMs ?? 50); + const emitInitial = options.emitInitial ?? true; + return (parameters, context, callback) => { + let disposed = false; + let running = false; + let last: TResult | undefined; + let hasLast = false; + let timer: ReturnType | undefined; + + const poll = async () => { + if (disposed || running) { + return; + } + + running = true; + try { + const next = await executeQuery(parameters, context); + const changed = !hasLast || !(options.equals?.(last as TResult, next) ?? Object.is(last, next)); + const shouldEmit = hasLast ? changed : emitInitial; + last = next; + hasLast = true; + if (shouldEmit) { + callback(next); + } + } finally { + running = false; + } + }; + + void poll(); + timer = setInterval(() => { + void poll(); + }, intervalMs); + + return () => { + disposed = true; + if (timer) { + clearInterval(timer); + } + }; + }; +} + +export function cultMeshStatePointer( + pointerId: string, + resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + watchPointer?: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshStatePointer { + return new CultMeshStatePointer(pointerId, resolvePointer, watchPointer, options); +} + +export function cultMeshMutableStatePointer( + pointerId: string, + resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + watchPointer: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + replacePointer: + | ((value: T) => Promise) + | CultMeshMutableStatePointerReplacer, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshMutableStatePointer { + return new CultMeshMutableStatePointer( + pointerId, + resolvePointer, + watchPointer, + replacePointer, + options, + ); +} + +export function cultMeshProjectionSource( + sourceId: string, + options: { schemaId?: string; description?: string } = {}, +): CultMeshProjectionSource { + requireNonEmpty(sourceId, "sourceId"); + return { + sourceId, + schemaId: options.schemaId, + description: options.description, + }; +} + +export class CultMeshStateRefResolver { + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly resolverId: string, + private readonly resolveRef: (stateRef: string, context: CultMeshQueryContext) => string | undefined, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ) { + requireNonEmpty(resolverId, "resolverId"); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + } + + public resolve(stateRef: string, context: CultMeshQueryContext | string = "local"): string { + if (!stateRef.trim()) { + return ""; + } + return ( + this.resolveRef( + stateRef, + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + ) ?? "" + ); + } + + public tryResolve( + stateRef: string, + context: CultMeshQueryContext | string = "local", + ): { resolved: boolean; value: string } { + const value = this.resolve(stateRef, context); + return { resolved: value.length > 0, value }; + } + + public or(fallback: CultMeshStateRefResolver): CultMeshStateRefResolver { + return new CultMeshStateRefResolver( + `${this.resolverId}|${fallback.resolverId}`, + (stateRef, context) => { + const value = this.resolve(stateRef, context); + return value.length > 0 ? value : fallback.resolve(stateRef, context); + }, + { + sources: [...this.sources, ...fallback.sources], + routeHint: this.routeHint.kind === "automatic" ? fallback.routeHint : this.routeHint, + }, + ); + } + + public asFunction(): (stateRef: string) => string { + return (stateRef) => this.resolve(stateRef); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + return cultMeshQueryContext(context.runtimeId, { routeHint: this.routeHint }); + } +} + +export function cultMeshStateRefResolver( + resolverId: string, + resolveRef: + | ((stateRef: string) => string | undefined) + | ((stateRef: string, context: CultMeshQueryContext) => string | undefined), + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshStateRefResolver { + return new CultMeshStateRefResolver( + resolverId, + (stateRef, context) => + resolveRef.length <= 1 + ? (resolveRef as (stateRef: string) => string | undefined)(stateRef) + : (resolveRef as (stateRef: string, context: CultMeshQueryContext) => string | undefined)( + stateRef, + context, + ), + options, + ); +} + +export function cultMeshDescribeStateRefResolver( + resolver: CultMeshStateRefResolver, +): CultMeshStateRefResolverDiagnostic { + return { + resolverId: resolver.resolverId, + routeHint: resolver.routeHint, + sources: [...resolver.sources], + }; +} + +export function cultMeshStateBinding( + targetProp: string, + pointer: + | { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + } + | string, + options: { + sourceId?: string; + schemaId?: string; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshStateBindingDescriptor { + requireNonEmpty(targetProp, "targetProp"); + if (typeof pointer === "string") { + requireNonEmpty(pointer, "pointerId"); + return { + targetProp, + pointerId: pointer, + sourceId: options.sourceId, + schemaId: options.schemaId, + routeHint: options.routeHint ?? cultMeshRouteHint(), + }; + } + + requireNonEmpty(pointer.pointerId, "pointerId"); + const source = pointer.sources?.[0]; + return { + targetProp, + pointerId: pointer.pointerId, + sourceId: options.sourceId ?? source?.sourceId, + schemaId: options.schemaId ?? source?.schemaId, + routeHint: options.routeHint ?? pointer.routeHint ?? cultMeshRouteHint(), + }; +} + +export function cultMeshStateBindingRecord( + binding?: Partial, +): CultMeshStateBindingRecord; +export function cultMeshStateBindingRecord( + targetProp?: string, + pointerId?: string, + sourceId?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, +): CultMeshStateBindingRecord; +export function cultMeshStateBindingRecord( + bindingOrTargetProp?: Partial | string, + pointerId?: string, + sourceId?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, +): CultMeshStateBindingRecord { + if (typeof bindingOrTargetProp === "object" || bindingOrTargetProp === undefined) { + const binding = bindingOrTargetProp; + const route = cultMeshRouteRecord(binding?.routeHint); + return { + targetProp: binding?.targetProp?.trim() || "value", + pointerId: binding?.pointerId ?? "", + sourceId: binding?.sourceId ?? "", + schemaId: binding?.schemaId ?? "", + routeKind: route.kind, + routeDescription: route.description, + }; + } + + return { + targetProp: bindingOrTargetProp?.trim() || "value", + pointerId: pointerId ?? "", + sourceId: sourceId ?? "", + schemaId: schemaId ?? "", + routeKind: routeKind ?? "", + routeDescription: routeDescription ?? "", + }; +} + +export function cultMeshStateBindingFromRecord( + record: Partial, + options: { + fallbackRouteHint?: CultMeshRouteHint; + fallbackTargetProp?: string; + } = {}, +): CultMeshStateBindingDescriptor { + const targetProp = record.targetProp?.trim() || options.fallbackTargetProp?.trim() || "value"; + return { + targetProp, + pointerId: record.pointerId?.trim() || `${targetProp}.unknown`, + sourceId: record.sourceId ?? "", + schemaId: record.schemaId ?? "", + routeHint: cultMeshRouteFromRecord( + { + kind: record.routeKind, + description: record.routeDescription, + }, + options.fallbackRouteHint, + ), + }; +} + +export function cultMeshOperationBinding( + operation: + | { + readonly operationId: string; + } + | string, + options: { + label?: string; + schemaId?: string; + routeHint?: CultMeshRouteHint; + } = {}, +): CultMeshOperationBindingDescriptor { + const operationId = typeof operation === "string" ? operation : operation.operationId; + requireNonEmpty(operationId, "operationId"); + return { + operationId, + label: options.label ?? "", + schemaId: options.schemaId ?? "", + routeHint: options.routeHint ?? cultMeshRouteHint(), + }; +} + +export function cultMeshOperationBindingRecord( + binding?: Partial, +): CultMeshOperationBindingRecord; +export function cultMeshOperationBindingRecord( + operationId?: string, + label?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, +): CultMeshOperationBindingRecord; +export function cultMeshOperationBindingRecord( + bindingOrOperationId?: Partial | string, + label?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, +): CultMeshOperationBindingRecord { + if (typeof bindingOrOperationId === "object" || bindingOrOperationId === undefined) { + const binding = bindingOrOperationId; + const route = cultMeshRouteRecord(binding?.routeHint); + return { + operationId: binding?.operationId ?? "", + label: binding?.label ?? "", + schemaId: binding?.schemaId ?? "", + routeKind: route.kind, + routeDescription: route.description, + }; + } + + return { + operationId: bindingOrOperationId ?? "", + label: label ?? "", + schemaId: schemaId ?? "", + routeKind: routeKind ?? "", + routeDescription: routeDescription ?? "", + }; +} + +export function cultMeshOperationBindingFromRecord( + record: Partial, + options: { + fallbackRouteHint?: CultMeshRouteHint; + fallbackOperationId?: string; + } = {}, +): CultMeshOperationBindingDescriptor { + return { + operationId: record.operationId?.trim() || options.fallbackOperationId || "", + label: record.label ?? "", + schemaId: record.schemaId ?? "", + routeHint: cultMeshRouteFromRecord( + { + kind: record.routeKind, + description: record.routeDescription, + }, + options.fallbackRouteHint, + ), + }; +} + +export function cultMeshOperationInvocation( + operation: + | { + readonly operationId: string; + readonly schemaId?: string; + readonly routeHint?: CultMeshRouteHint; + } + | string, + options: { + schemaId?: string; + routeHint?: CultMeshRouteHint; + idempotencyKey?: string; + } = {}, +): CultMeshOperationInvocationDescriptor { + const operationId = typeof operation === "string" ? operation : operation.operationId; + requireNonEmpty(operationId, "operationId"); + return { + operationId, + schemaId: options.schemaId ?? (typeof operation === "string" ? "" : operation.schemaId) ?? "", + routeHint: + options.routeHint ?? + (typeof operation === "string" ? undefined : operation.routeHint) ?? + cultMeshRouteHint(), + idempotencyKey: options.idempotencyKey, + }; +} + +export function cultMeshOperationInvocationRecord( + invocation?: CultMeshOperationInvocationDescriptor, + options: { + fallbackOperationId?: string; + fallbackSchemaId?: string; + fallbackRouteHint?: CultMeshRouteHint; + fallbackIdempotencyKey?: string; + } = {}, +): CultMeshOperationInvocationRecord { + const route = cultMeshRouteRecord(invocation?.routeHint ?? options.fallbackRouteHint); + return { + operationId: nonBlankOr(invocation?.operationId, options.fallbackOperationId), + schemaId: nonBlankOr(invocation?.schemaId, options.fallbackSchemaId), + routeKind: route.kind, + routeDescription: route.description, + idempotencyKey: nonBlankOr(invocation?.idempotencyKey, options.fallbackIdempotencyKey), + }; +} + +export function cultMeshOperationInvocationFromRecord( + record: Partial, + options: { + fallbackOperationId?: string; + fallbackSchemaId?: string; + fallbackRouteHint?: CultMeshRouteHint; + fallbackIdempotencyKey?: string; + } = {}, +): CultMeshOperationInvocationDescriptor { + const operationId = nonBlankOr(record.operationId, options.fallbackOperationId); + requireNonEmpty(operationId, "operationId"); + const fallbackRoute = options.fallbackRouteHint ?? cultMeshRouteHint(); + const route = cultMeshRouteFromRecord( + { + kind: record.routeKind, + description: record.routeDescription, + }, + fallbackRoute, + ); + return { + operationId, + schemaId: nonBlankOr(record.schemaId, options.fallbackSchemaId), + routeHint: route, + idempotencyKey: nonBlankOr(record.idempotencyKey, options.fallbackIdempotencyKey) || undefined, + }; +} + +export function cultMeshOperationPayload( + fields: Readonly> = {}, +): CultMeshOperationPayload { + const normalized: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (!key.trim() || value === undefined) { + continue; + } + normalized[key] = String(value); + } + + return { + fields: Object.freeze({ ...normalized }), + getString(key, defaultValue = "") { + return normalized[key] ?? defaultValue; + }, + getInt(key, defaultValue = 0) { + const raw = normalized[key]; + if (raw === undefined || raw.trim() === "") { + return defaultValue; + } + const parsed = Number.parseInt(raw, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; + }, + getDouble(key, defaultValue = 0) { + const raw = normalized[key]; + if (raw === undefined || raw.trim() === "") { + return defaultValue; + } + const parsed = Number.parseFloat(raw); + return Number.isNaN(parsed) ? defaultValue : parsed; + }, + getBoolean(key, defaultValue = false) { + const raw = normalized[key]?.toLowerCase(); + if (raw === "true" || raw === "1" || raw === "yes" || raw === "on") { + return true; + } + if (raw === "false" || raw === "0" || raw === "no" || raw === "off") { + return false; + } + return defaultValue; + }, + with(key, value) { + requireNonEmpty(key, "key"); + return cultMeshOperationPayload({ + ...normalized, + [key]: value, + }); + }, + toRecord() { + return Object.freeze({ ...normalized }); + }, + }; +} + +export function cultMeshProjectionRecipe( + projectionId: string, + sources: readonly CultMeshProjectionSource[], + projectProjection: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + routeHint?: CultMeshRouteHint; + watchProjection?: ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, + ) => CultMeshUnsubscribe; + } = {}, +): CultMeshProjectionRecipe { + return new CultMeshProjectionRecipe( + projectionId, + sources, + projectProjection, + options, + ); +} + +export function cultMeshDescribeProjectionRecipe( + recipe: { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, +): CultMeshProjectionRecipeDiagnostic { + return { + projectionId: recipe.projectionId, + routeHint: recipe.routeHint, + sources: [...recipe.sources], + }; +} + +export function cultMeshDescribeSurface( + surface: + | { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly operationId: string; + } + | { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + } + | CultMeshNativeSliceViewDescriptor, +): CultMeshSurfaceDiagnostic { + if ("queryId" in surface) { + return cultMeshSurfaceDiagnostic("query", surface.queryId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + if ("operationId" in surface) { + return cultMeshSurfaceDiagnostic("operation", surface.operationId); + } + + if ("pointerId" in surface) { + return cultMeshSurfaceDiagnostic("state-pointer", surface.pointerId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + if ("viewId" in surface) { + return cultMeshSurfaceDiagnostic("native-slice-view", surface.viewId, { + routeHint: surface.route, + }); + } + + if ("projectionId" in surface) { + return cultMeshSurfaceDiagnostic("projection-recipe", surface.projectionId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + return cultMeshSurfaceDiagnostic("live-feed", surface.feedId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); +} + +export function cultMeshSurfaceDiagnostic( + kind: CultMeshSurfaceKind, + surfaceId: string, + options: { + routeHint?: CultMeshRouteHint; + sources?: readonly CultMeshProjectionSource[]; + } = {}, +): CultMeshSurfaceDiagnostic { + requireNonEmpty(surfaceId, "surfaceId"); + return { + kind, + surfaceId, + routeHint: options.routeHint ?? cultMeshRouteHint(), + sources: [...(options.sources ?? [])], + }; +} + +export function cultMeshDescribeSurfaceCatalog( + catalogId: string, + surfaces: readonly CultMeshSurfaceDiagnostic[], +): CultMeshSurfaceCatalogDiagnostic { + requireNonEmpty(catalogId, "catalogId"); + return { + catalogId, + surfaces: surfaces.map(surface => cultMeshSurfaceDiagnostic(surface.kind, surface.surfaceId, { + routeHint: surface.routeHint, + sources: surface.sources, + })), + }; +} + +export function cultMeshFindSurface( + catalog: CultMeshSurfaceCatalogDiagnostic, + surfaceId: string, +): CultMeshSurfaceDiagnostic | undefined { + requireNonEmpty(surfaceId, "surfaceId"); + return catalog.surfaces.find(surface => surface.surfaceId === surfaceId); +} + +export function cultMeshSurfacesByKind( + catalog: CultMeshSurfaceCatalogDiagnostic, + kind: CultMeshSurfaceKind, +): CultMeshSurfaceDiagnostic[] { + return catalog.surfaces + .filter(surface => surface.kind === kind) + .map(surface => cultMeshSurfaceDiagnostic(surface.kind, surface.surfaceId, { + routeHint: surface.routeHint, + sources: surface.sources, + })); +} + +export function cultMeshSurfaceCatalogIndex( + catalog: CultMeshSurfaceCatalogDiagnostic, +): CultMeshSurfaceCatalogIndexDiagnostic { + return { + catalogId: catalog.catalogId, + queries: cultMeshSurfacesByKind(catalog, "query"), + projectionRecipes: cultMeshSurfacesByKind(catalog, "projection-recipe"), + liveFeeds: cultMeshSurfacesByKind(catalog, "live-feed"), + operations: cultMeshSurfacesByKind(catalog, "operation"), + statePointers: cultMeshSurfacesByKind(catalog, "state-pointer"), + nativeSliceViews: cultMeshSurfacesByKind(catalog, "native-slice-view"), + }; +} + +export function cultMeshNativeSliceColumn( + name: string, + valueType: string, + elementSizeBytes: number, +): CultMeshNativeSliceColumn { + requireNonEmpty(name, "name"); + requireNonEmpty(valueType, "valueType"); + if (!Number.isInteger(elementSizeBytes) || elementSizeBytes <= 0) { + throw new Error("elementSizeBytes must be a positive integer."); + } + + return { name, valueType, elementSizeBytes }; +} + +export function cultMeshNativeSliceView( + viewId: string, + schemaId: string, + rowCount: number, + columns: readonly CultMeshNativeSliceColumn[], + options: { route?: CultMeshRouteHint; nativeHandle?: string } = {}, +): CultMeshNativeSliceViewDescriptor { + requireNonEmpty(viewId, "viewId"); + requireNonEmpty(schemaId, "schemaId"); + if (!Number.isInteger(rowCount) || rowCount < 0) { + throw new Error("rowCount must be a non-negative integer."); + } + + return { + viewId, + schemaId, + rowCount, + columns: [...columns], + route: options.route ?? cultMeshRouteHint(), + nativeHandle: options.nativeHandle, + }; +} + +export function cultMeshDenseRowStrideBytes( + view: CultMeshNativeSliceViewDescriptor, +): number { + return view.columns.reduce((sum, column) => sum + column.elementSizeBytes, 0); +} + +export function cultMeshFindNativeSliceColumn( + view: CultMeshNativeSliceViewDescriptor, + name: string, +): CultMeshNativeSliceColumn | undefined { + requireNonEmpty(name, "name"); + return view.columns.find((column) => column.name === name); +} + +export function cultMeshDescribeNativeSliceView( + view: CultMeshNativeSliceViewDescriptor, +): CultMeshNativeSliceViewDiagnostic { + return { + viewId: view.viewId, + schemaId: view.schemaId, + rowCount: view.rowCount, + columns: [...view.columns], + route: view.route, + nativeHandle: view.nativeHandle, + denseRowStrideBytes: cultMeshDenseRowStrideBytes(view), + }; +} + export interface CultMeshNodeOptions { documents?: Iterable; bindings?: Iterable; @@ -544,6 +2594,622 @@ export class CultMeshStreamCatalog { } export class CultMesh { + public static vec2(x: number, y: number): CultMeshVec2 { + return cultMeshVec2(x, y); + } + + public static rect(min: CultMeshVec2, max: CultMeshVec2): CultMeshRect { + return cultMeshRect(min, max); + } + + public static rectFromBounds( + minX: number, + minY: number, + maxX: number, + maxY: number, + ): CultMeshRect { + return cultMeshRectFromBounds(minX, minY, maxX, maxY); + } + + public static viewportRequest( + viewport: CultMeshRect, + controlledEntityIndices?: readonly number[], + ): CultMeshViewportRequest { + return cultMeshViewportRequest(viewport, controlledEntityIndices); + } + + public static routeHint( + kind: CultMeshLocalityKind = "automatic", + description?: string, + ): CultMeshRouteHint { + return cultMeshRouteHint(kind, description); + } + + public static routeRecord(routeHint?: CultMeshRouteHint): CultMeshRouteRecord; + public static routeRecord(kind?: string, description?: string): CultMeshRouteRecord; + public static routeRecord( + routeOrKind?: CultMeshRouteHint | string, + description = "", + ): CultMeshRouteRecord { + return typeof routeOrKind === "string" || routeOrKind === undefined + ? cultMeshRouteRecord(routeOrKind, description) + : cultMeshRouteRecord(routeOrKind); + } + + public static routeFromRecord( + record: Partial, + fallback: CultMeshRouteHint = cultMeshRouteHint(), + ): CultMeshRouteHint { + return cultMeshRouteFromRecord(record, fallback); + } + + public static authorityClaim( + role: string, + options: { shardId?: string; leaseId?: string } = {}, + ): CultMeshAuthorityClaim { + return cultMeshAuthorityClaim(role, options); + } + + public static verseContext( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, + ): CultMeshVerseContext { + return cultMeshVerseContext(verseId, runtimeId, options); + } + + public static verse( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, + ): CultMeshVerse { + return cultMeshVerse(verseId, runtimeId, options); + } + + public static async connectVerse( + verseId: string, + runtimeId: string, + options: { + routeHint?: CultMeshRouteHint; + claims?: readonly CultMeshAuthorityClaim[]; + } = {}, + ): Promise { + return cultMeshVerse(verseId, runtimeId, options); + } + + public static operationContextFromVerse( + context: CultMeshVerseContext, + options: { idempotencyKey?: string } = {}, + ): CultMeshOperationContext { + return cultMeshOperationContextFromVerse(context, options); + } + + public static queryContextFromVerse( + context: CultMeshVerseContext, + ): CultMeshQueryContext { + return cultMeshQueryContextFromVerse(context); + } + + public static bindOperation( + verse: CultMeshVerseContext | CultMeshVerse, + operation: CultMeshOperationHandle, + ): CultMeshBoundOperationHandle { + return cultMeshBindOperation(verse, operation); + } + + public static bindQuery( + verse: CultMeshVerseContext | CultMeshVerse, + query: CultMeshQuerySurface, + ): CultMeshBoundQuerySurface { + return cultMeshBindQuery(verse, query); + } + + public static bindLiveFeed( + verse: CultMeshVerseContext | CultMeshVerse, + feed: CultMeshLiveFeed, + ): CultMeshBoundLiveFeed { + return cultMeshBindLiveFeed(verse, feed); + } + + public static operationContext( + runtimeId: string, + options: { + claims?: readonly CultMeshAuthorityClaim[]; + routeHint?: CultMeshRouteHint; + idempotencyKey?: string; + } = {}, + ): CultMeshOperationContext { + return cultMeshOperationContext(runtimeId, options); + } + + public static operationContextFor(runtimeId: string): CultMeshOperationContextBuilder { + return cultMeshOperationContextFor(runtimeId); + } + + public static queryContext( + runtimeId: string, + options: { routeHint?: CultMeshRouteHint } = {}, + ): CultMeshQueryContext { + return cultMeshQueryContext(runtimeId, options); + } + + public static queryContextFor(runtimeId: string): CultMeshQueryContextBuilder { + return cultMeshQueryContextFor(runtimeId); + } + + public static operationReceipt( + operationId: string, + accepted: boolean, + options: { route?: CultMeshRouteHint; diagnostic?: string } = {}, + ): CultMeshOperationReceipt { + return cultMeshOperationReceipt(operationId, accepted, options); + } + + public static operation( + operationId: string, + invokeOperation: ( + request: TRequest, + context: CultMeshOperationContext, + ) => Promise, + ): CultMeshOperationHandle { + return cultMeshOperation(operationId, invokeOperation); + } + + public static query( + queryId: string, + executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchQuery?: CultMeshQueryWatcher; + } = {}, + ): CultMeshQuerySurface { + return cultMeshQuery(queryId, executeQuery, options); + } + + public static describeQuerySurface( + query: { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, + ): CultMeshQuerySurfaceDiagnostic { + return cultMeshDescribeQuerySurface(query); + } + + public static describeOperationHandle( + operation: { + readonly operationId: string; + }, + ): CultMeshOperationHandleDiagnostic { + return cultMeshDescribeOperationHandle(operation); + } + + public static describeStatePointer( + pointer: { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + }, + ): CultMeshStatePointerDiagnostic { + return cultMeshDescribeStatePointer(pointer); + } + + public static liveFeed( + feedId: string, + snapshotFeed: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchFeed?: CultMeshLiveFeedWatcher; + } = {}, + ): CultMeshLiveFeed { + return cultMeshLiveFeed(feedId, snapshotFeed, options); + } + + public static describeLiveFeed( + feed: { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, + ): CultMeshLiveFeedDiagnostic { + return cultMeshDescribeLiveFeed(feed); + } + + public static pollingQueryWatcher( + executeQuery: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: CultMeshPollingWatchOptions = {}, + ): CultMeshQueryWatcher { + return cultMeshPollingQueryWatcher(executeQuery, options); + } + + public static statePointer( + pointerId: string, + resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + watchPointer?: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshStatePointer { + return cultMeshStatePointer(pointerId, resolvePointer, watchPointer, options); + } + + public static mutableStatePointer( + pointerId: string, + resolvePointer: (() => Promise) | CultMeshStatePointerResolver, + watchPointer: + | ((callback: (value: T) => void) => CultMeshUnsubscribe) + | CultMeshStatePointerWatcher, + replacePointer: + | ((value: T) => Promise) + | CultMeshMutableStatePointerReplacer, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshMutableStatePointer { + return cultMeshMutableStatePointer( + pointerId, + resolvePointer, + watchPointer, + replacePointer, + options, + ); + } + + public static bindStatePointer( + verse: CultMeshVerseContext | CultMeshVerse, + pointer: CultMeshStatePointer, + ): CultMeshBoundStatePointer { + return cultMeshBindStatePointer(verse, pointer); + } + + public static bindMutableStatePointer( + verse: CultMeshVerseContext | CultMeshVerse, + pointer: CultMeshMutableStatePointer, + ): CultMeshBoundMutableStatePointer { + return cultMeshBindMutableStatePointer(verse, pointer); + } + + public static projectionSource( + sourceId: string, + options: { schemaId?: string; description?: string } = {}, + ): CultMeshProjectionSource { + return cultMeshProjectionSource(sourceId, options); + } + + public static stateRefResolver( + resolverId: string, + resolveRef: + | ((stateRef: string) => string | undefined) + | ((stateRef: string, context: CultMeshQueryContext) => string | undefined), + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshStateRefResolver { + return cultMeshStateRefResolver(resolverId, resolveRef, options); + } + + public static describeStateRefResolver( + resolver: CultMeshStateRefResolver, + ): CultMeshStateRefResolverDiagnostic { + return cultMeshDescribeStateRefResolver(resolver); + } + + public static stateBinding( + targetProp: string, + pointer: + | { + readonly pointerId: string; + readonly routeHint?: CultMeshRouteHint; + readonly sources?: readonly CultMeshProjectionSource[]; + } + | string, + options: { + sourceId?: string; + schemaId?: string; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshStateBindingDescriptor { + return cultMeshStateBinding(targetProp, pointer, options); + } + + public static stateBindingRecord( + binding?: Partial, + ): CultMeshStateBindingRecord; + public static stateBindingRecord( + targetProp?: string, + pointerId?: string, + sourceId?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + ): CultMeshStateBindingRecord; + public static stateBindingRecord( + bindingOrTargetProp?: Partial | string, + pointerId?: string, + sourceId?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + ): CultMeshStateBindingRecord { + return typeof bindingOrTargetProp === "object" || bindingOrTargetProp === undefined + ? cultMeshStateBindingRecord(bindingOrTargetProp) + : cultMeshStateBindingRecord( + bindingOrTargetProp, + pointerId, + sourceId, + schemaId, + routeKind, + routeDescription, + ); + } + + public static stateBindingFromRecord( + record: Partial, + options: { + fallbackRouteHint?: CultMeshRouteHint; + fallbackTargetProp?: string; + } = {}, + ): CultMeshStateBindingDescriptor { + return cultMeshStateBindingFromRecord(record, options); + } + + public static operationBinding( + operation: + | { + readonly operationId: string; + } + | string, + options: { + label?: string; + schemaId?: string; + routeHint?: CultMeshRouteHint; + } = {}, + ): CultMeshOperationBindingDescriptor { + return cultMeshOperationBinding(operation, options); + } + + public static operationBindingRecord( + binding?: Partial, + ): CultMeshOperationBindingRecord; + public static operationBindingRecord( + operationId?: string, + label?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + ): CultMeshOperationBindingRecord; + public static operationBindingRecord( + bindingOrOperationId?: Partial | string, + label?: string, + schemaId?: string, + routeKind?: string, + routeDescription?: string, + ): CultMeshOperationBindingRecord { + return typeof bindingOrOperationId === "object" || bindingOrOperationId === undefined + ? cultMeshOperationBindingRecord(bindingOrOperationId) + : cultMeshOperationBindingRecord( + bindingOrOperationId, + label, + schemaId, + routeKind, + routeDescription, + ); + } + + public static operationBindingFromRecord( + record: Partial, + options: { + fallbackRouteHint?: CultMeshRouteHint; + fallbackOperationId?: string; + } = {}, + ): CultMeshOperationBindingDescriptor { + return cultMeshOperationBindingFromRecord(record, options); + } + + public static operationInvocation( + operation: + | { + readonly operationId: string; + readonly schemaId?: string; + readonly routeHint?: CultMeshRouteHint; + } + | string, + options: { + schemaId?: string; + routeHint?: CultMeshRouteHint; + idempotencyKey?: string; + } = {}, + ): CultMeshOperationInvocationDescriptor { + return cultMeshOperationInvocation(operation, options); + } + + public static operationInvocationRecord( + invocation?: CultMeshOperationInvocationDescriptor, + options: { + fallbackOperationId?: string; + fallbackSchemaId?: string; + fallbackRouteHint?: CultMeshRouteHint; + fallbackIdempotencyKey?: string; + } = {}, + ): CultMeshOperationInvocationRecord { + return cultMeshOperationInvocationRecord(invocation, options); + } + + public static operationInvocationFromRecord( + record: Partial, + options: { + fallbackOperationId?: string; + fallbackSchemaId?: string; + fallbackRouteHint?: CultMeshRouteHint; + fallbackIdempotencyKey?: string; + } = {}, + ): CultMeshOperationInvocationDescriptor { + return cultMeshOperationInvocationFromRecord(record, options); + } + + public static operationPayload( + fields: Readonly> = {}, + ): CultMeshOperationPayload { + return cultMeshOperationPayload(fields); + } + + public static projectionRecipe( + projectionId: string, + sources: readonly CultMeshProjectionSource[], + projectProjection: ( + parameters: TParameters, + context: CultMeshQueryContext, + ) => Promise, + options: { + routeHint?: CultMeshRouteHint; + watchProjection?: ( + parameters: TParameters, + context: CultMeshQueryContext, + callback: (value: TResult) => void, + ) => CultMeshUnsubscribe; + } = {}, + ): CultMeshProjectionRecipe { + return cultMeshProjectionRecipe( + projectionId, + sources, + projectProjection, + options, + ); + } + + public static describeProjectionRecipe( + recipe: { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + }, + ): CultMeshProjectionRecipeDiagnostic { + return cultMeshDescribeProjectionRecipe(recipe); + } + + public static describeSurface( + surface: + | { + readonly queryId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly operationId: string; + } + | { + readonly projectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly feedId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly pointerId: string; + } + | CultMeshNativeSliceViewDescriptor, + ): CultMeshSurfaceDiagnostic { + return cultMeshDescribeSurface(surface); + } + + public static surfaceDiagnostic( + kind: CultMeshSurfaceKind, + surfaceId: string, + options: { + routeHint?: CultMeshRouteHint; + sources?: readonly CultMeshProjectionSource[]; + } = {}, + ): CultMeshSurfaceDiagnostic { + return cultMeshSurfaceDiagnostic(kind, surfaceId, options); + } + + public static describeSurfaceCatalog( + catalogId: string, + surfaces: readonly CultMeshSurfaceDiagnostic[], + ): CultMeshSurfaceCatalogDiagnostic { + return cultMeshDescribeSurfaceCatalog(catalogId, surfaces); + } + + public static findSurface( + catalog: CultMeshSurfaceCatalogDiagnostic, + surfaceId: string, + ): CultMeshSurfaceDiagnostic | undefined { + return cultMeshFindSurface(catalog, surfaceId); + } + + public static surfacesByKind( + catalog: CultMeshSurfaceCatalogDiagnostic, + kind: CultMeshSurfaceKind, + ): CultMeshSurfaceDiagnostic[] { + return cultMeshSurfacesByKind(catalog, kind); + } + + public static surfaceCatalogIndex( + catalog: CultMeshSurfaceCatalogDiagnostic, + ): CultMeshSurfaceCatalogIndexDiagnostic { + return cultMeshSurfaceCatalogIndex(catalog); + } + + public static nativeSliceColumn( + name: string, + valueType: string, + elementSizeBytes: number, + ): CultMeshNativeSliceColumn { + return cultMeshNativeSliceColumn(name, valueType, elementSizeBytes); + } + + public static nativeSliceView( + viewId: string, + schemaId: string, + rowCount: number, + columns: readonly CultMeshNativeSliceColumn[], + options: { route?: CultMeshRouteHint; nativeHandle?: string } = {}, + ): CultMeshNativeSliceViewDescriptor { + return cultMeshNativeSliceView(viewId, schemaId, rowCount, columns, options); + } + + public static denseRowStrideBytes(view: CultMeshNativeSliceViewDescriptor): number { + return cultMeshDenseRowStrideBytes(view); + } + + public static findNativeSliceColumn( + view: CultMeshNativeSliceViewDescriptor, + name: string, + ): CultMeshNativeSliceColumn | undefined { + return cultMeshFindNativeSliceColumn(view, name); + } + + public static describeNativeSliceView( + view: CultMeshNativeSliceViewDescriptor, + ): CultMeshNativeSliceViewDiagnostic { + return cultMeshDescribeNativeSliceView(view); + } + public static async createNode( cachePath: string, options: CultMeshNodeOptions = {}, @@ -1107,3 +3773,31 @@ function copyBudgetFor( return "opaque-runtime"; } } + +function nonBlankOr(value?: string, fallback = ""): string { + return value && value.trim() ? value : fallback; +} + +function parseCultMeshLocalityKind( + value: string | undefined, + fallback: CultMeshLocalityKind, +): CultMeshLocalityKind { + switch ((value ?? "").trim().toLowerCase()) { + case "automatic": + return "automatic"; + case "inprocess": + case "in-process": + return "in-process"; + case "sharedmemory": + case "shared-memory": + return "shared-memory"; + case "ipc": + return "ipc"; + case "network": + return "network"; + case "wasm": + return "wasm"; + default: + return fallback; + } +} diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index c14d3a47..1fd8ffa3 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -145,6 +145,1129 @@ test("CultMesh TS local Verse catalog exposes sorted views and watch ergonomics" ); }); +test("CultMesh TS exposes shared geometry query primitives", () => { + const viewport = CultMesh.rect(CultMesh.vec2(10, -5), CultMesh.vec2(-2, 7)); + assert.deepEqual(viewport, { + min: { x: -2, y: -5 }, + max: { x: 10, y: 7 }, + }); + + assert.deepEqual(CultMesh.viewportRequest(viewport, [3, 1]), { + minX: -2, + minY: -5, + maxX: 10, + maxY: 7, + controlledEntityIndices: [3, 1], + }); + + assert.deepEqual(CultMesh.rectFromBounds(4, 3, 2, 1), { + min: { x: 2, y: 1 }, + max: { x: 4, y: 3 }, + }); +}); + +test("CultMesh TS exposes typed operation primitives", async () => { + const operation = CultMesh.operation< + { actor: string; direction: { x: number; y: number } }, + { receipt: ReturnType; actor: string } + >("game.test.move", async (request, context) => { + assert.equal(context.runtimeId, "rts-client"); + assert.deepEqual(context.claims, [ + { role: "simulation-authority", shardId: "zone:0", leaseId: "lease:zone:0" }, + ]); + assert.deepEqual(context.routeHint, { + kind: "network", + description: "remote Verse node", + }); + assert.equal(context.idempotencyKey, "move-1"); + return { + receipt: CultMesh.operationReceipt("game.test.move", true, { + route: context.routeHint, + }), + actor: request.actor, + }; + }); + + const response = await operation.invoke( + { actor: "pawn:1", direction: CultMesh.vec2(1, 0) }, + CultMesh.operationContext("rts-client", { + claims: [ + CultMesh.authorityClaim("simulation-authority", { + shardId: "zone:0", + leaseId: "lease:zone:0", + }), + ], + routeHint: CultMesh.routeHint("network", "remote Verse node"), + idempotencyKey: "move-1", + }), + ); + + assert.equal(operation.operationId, "game.test.move"); + assert.equal(response.actor, "pawn:1"); + assert.deepEqual(CultMesh.describeOperationHandle(operation), { + operationId: "game.test.move", + }); + assert.deepEqual(response.receipt, { + operationId: "game.test.move", + accepted: true, + route: { + kind: "network", + description: "remote Verse node", + }, + diagnostic: undefined, + }); +}); + +test("CultMesh TS flattens and parses cross-runtime route records", () => { + const record = CultMesh.routeRecord( + CultMesh.routeHint("shared-memory", "co-located slab"), + ); + const restored = CultMesh.routeFromRecord(record); + const fromCsharp = CultMesh.routeFromRecord( + { + kind: "InProcess", + description: "", + }, + CultMesh.routeHint("network", "fallback route"), + ); + const fallback = CultMesh.routeFromRecord( + { + kind: "not-real", + description: "", + }, + CultMesh.routeHint("ipc", "fallback route"), + ); + + assert.deepEqual(record, { + kind: "shared-memory", + description: "co-located slab", + }); + assert.deepEqual(restored, { + kind: "shared-memory", + description: "co-located slab", + }); + assert.deepEqual(fromCsharp, { + kind: "in-process", + description: "fallback route", + }); + assert.deepEqual(fallback, { + kind: "ipc", + description: "fallback route", + }); +}); + +test("CultMesh TS exposes typed query primitives", async () => { + const watchedCounts: number[] = []; + const visibleObjects = CultMesh.query< + { viewport: ReturnType }, + { queryId: string; count: number } + >("game.test.visible_objects", async (parameters, context) => { + assert.equal(context.runtimeId, "browser-client"); + assert.deepEqual(context.routeHint, { + kind: "shared-memory", + description: "co-located daemon slab", + }); + assert.equal(parameters.viewport.minX, -10); + return { + queryId: "game.test.visible_objects", + count: 2, + }; + }, { + watchQuery: (parameters, context, callback) => { + assert.equal(context.runtimeId, "browser-client"); + assert.equal(parameters.viewport.maxX, 10); + callback({ queryId: "game.test.visible_objects", count: 3 }); + return () => watchedCounts.push(-1); + }, + }); + + const result = await visibleObjects.execute( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -5, 10, 5)) }, + CultMesh.queryContext("browser-client", { + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon slab"), + }), + ); + + assert.deepEqual(result, { + queryId: "game.test.visible_objects", + count: 2, + }); + + const diagnostic = CultMesh.describeQuerySurface(visibleObjects); + assert.equal(diagnostic.queryId, "game.test.visible_objects"); + assert.equal(diagnostic.routeHint.kind, "automatic"); + assert.deepEqual(diagnostic.sources, []); + assert.notEqual(diagnostic.sources, visibleObjects.sources); + + const unsubscribe = visibleObjects.watch( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -5, 10, 5)) }, + "browser-client", + (value) => watchedCounts.push(value.count), + ); + unsubscribe(); + + assert.deepEqual(watchedCounts, [3, -1]); +}); + +test("CultMesh TS polling watcher turns snapshots into a query watch", async () => { + let value = 0; + const observed: number[] = []; + const query = CultMesh.query( + "game.test.counter", + async () => value, + { + watchQuery: CultMesh.pollingQueryWatcher(async () => value, { + intervalMs: 5, + equals: (left, right) => left === right, + }), + }, + ); + + const unsubscribe = query.watch(undefined, "browser-client", next => observed.push(next)); + await delay(15); + value = 1; + await delay(20); + value = 1; + await delay(12); + value = 2; + await delay(20); + unsubscribe(); + value = 3; + await delay(12); + + assert.deepEqual(observed, [0, 1, 2]); +}); + +test("CultMesh TS polling watcher can suppress the initial baseline", async () => { + let value = "baseline"; + const observed: string[] = []; + const watch = CultMesh.pollingQueryWatcher(async () => value, { + intervalMs: 5, + emitInitial: false, + }); + + const unsubscribe = watch(undefined, CultMesh.queryContext("browser-client"), next => observed.push(next)); + await delay(15); + value = "changed"; + await delay(15); + unsubscribe(); + + assert.deepEqual(observed, ["changed"]); +}); + +test("CultMesh TS exposes live feed primitives for reactive client views", async () => { + let frameId = 10; + const observed: number[] = []; + const feed = CultMesh.liveFeed< + { viewport: ReturnType }, + { frameId: number; route: string } + >( + "aetheria.rts.viewport.feed", + async (_parameters, context) => ({ + frameId, + route: context.routeHint.kind, + }), + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1"), + CultMesh.projectionSource("daemon:aetheria.health.latest.v1"), + ], + routeHint: CultMesh.routeHint("shared-memory", "co-located RTS cache"), + watchFeed: CultMesh.pollingQueryWatcher(async (_parameters, context) => ({ + frameId, + route: context.routeHint.kind, + }), { + intervalMs: 5, + equals: (left, right) => left.frameId === right.frameId && left.route === right.route, + }), + }, + ); + + assert.equal(feed.feedId, "aetheria.rts.viewport.feed"); + assert.equal(feed.routeHint.kind, "shared-memory"); + assert.deepEqual(feed.sources.map(source => source.sourceId), [ + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.health.latest.v1", + ]); + + const diagnostic = CultMesh.describeLiveFeed(feed); + assert.equal(diagnostic.feedId, "aetheria.rts.viewport.feed"); + assert.equal(diagnostic.routeHint.description, "co-located RTS cache"); + assert.deepEqual(diagnostic.sources.map(source => source.sourceId), [ + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.health.latest.v1", + ]); + assert.notEqual(diagnostic.sources, feed.sources); + + const snapshot = await feed.snapshot( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-1, -1, 1, 1)) }, + "browser-client", + ); + + assert.deepEqual(snapshot, { + frameId: 10, + route: "shared-memory", + }); + + const unsubscribe = feed.watch( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-1, -1, 1, 1)) }, + "browser-client", + value => observed.push(value.frameId), + ); + await delay(15); + frameId = 11; + await delay(15); + unsubscribe(); + + assert.deepEqual(observed, [10, 11]); +}); + +test("CultMesh TS exposes projection recipes as shared query affordances", async () => { + const watchedRoutes: string[] = []; + const recipe = CultMesh.projectionRecipe< + { viewport: ReturnType }, + { projectionId: string; sourceCount: number; route: string } + >( + "aetheria.zone.objects.visible", + [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + description: "latest daemon frame", + }), + CultMesh.projectionSource("daemon:aetheria.authority.policy.v1", { + schemaId: "gamecult.aetheria.authority_policy.v1", + }), + ], + async (_parameters, context) => ({ + projectionId: "aetheria.zone.objects.visible", + sourceCount: 2, + route: context.routeHint.kind, + }), + { + routeHint: CultMesh.routeHint("shared-memory", "co-located frame slab"), + watchProjection: (_parameters, context, callback) => { + callback({ + projectionId: "aetheria.zone.objects.visible", + sourceCount: 2, + route: context.routeHint.kind, + }); + return () => watchedRoutes.push("unsubscribed"); + }, + }, + ); + + assert.equal(recipe.projectionId, "aetheria.zone.objects.visible"); + assert.equal(recipe.routeHint.kind, "shared-memory"); + assert.deepEqual(recipe.sources.map((source) => source.sourceId), [ + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.authority.policy.v1", + ]); + + const recipeDiagnostic = CultMesh.describeProjectionRecipe(recipe); + assert.equal(recipeDiagnostic.projectionId, "aetheria.zone.objects.visible"); + assert.equal(recipeDiagnostic.routeHint.description, "co-located frame slab"); + assert.deepEqual(recipeDiagnostic.sources.map((source) => source.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + "gamecult.aetheria.authority_policy.v1", + ]); + assert.notEqual(recipeDiagnostic.sources, recipe.sources); + + const projected = await recipe.project( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-10, -5, 10, 5)) }, + CultMesh.queryContextFor("browser-starfire") + .route("wasm", "browser-local projection") + .build(), + ); + + assert.deepEqual(projected, { + projectionId: "aetheria.zone.objects.visible", + sourceCount: 2, + route: "wasm", + }); + + const query = recipe.asQuerySurface(); + assert.equal(query.routeHint.kind, "shared-memory"); + assert.deepEqual(query.sources.map((source) => source.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + "gamecult.aetheria.authority_policy.v1", + ]); + + const queried = await query.execute( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-1, -1, 1, 1)) }, + "unity-raven", + ); + + assert.equal(query.queryId, recipe.projectionId); + assert.equal(queried.route, "shared-memory"); + + const queryDiagnostic = CultMesh.describeQuerySurface(query); + assert.equal(queryDiagnostic.queryId, "aetheria.zone.objects.visible"); + assert.equal(queryDiagnostic.routeHint.kind, "shared-memory"); + assert.deepEqual(queryDiagnostic.sources.map((source) => source.sourceId), [ + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.authority.policy.v1", + ]); + assert.notEqual(queryDiagnostic.sources, query.sources); + + const unsubscribe = query.watch( + { viewport: CultMesh.viewportRequest(CultMesh.rectFromBounds(-1, -1, 1, 1)) }, + "unity-raven", + (value) => watchedRoutes.push(value.route), + ); + unsubscribe(); + + assert.deepEqual(watchedRoutes, ["shared-memory", "unsubscribed"]); +}); + +test("CultMesh TS describes typed surface catalogs for tools and generated bindings", () => { + const source = CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + }); + const routeHint = CultMesh.routeHint("shared-memory", "co-located frame slab"); + const query = CultMesh.query( + "aetheria.zone.objects.visible", + async () => "objects", + { + sources: [source], + routeHint, + }, + ); + const feed = CultMesh.liveFeed( + "aetheria.rts.viewport.feed", + async () => "frame", + { + sources: [source], + routeHint, + }, + ); + const operation = CultMesh.operation( + "aetheria.pilot.set_move_vector", + async () => "accepted", + ); + const pointer = CultMesh.statePointer( + "aetheria.selection.current", + async () => "entity:ship:1", + undefined, + { + sources: [source], + routeHint, + }, + ); + const nativeView = CultMesh.nativeSliceView( + "aetheria.zone.render", + "gamecult.aetheria.render_body.v1", + 128, + [CultMesh.nativeSliceColumn("position", "CultVec2", 8)], + { route: routeHint }, + ); + + const querySurface = CultMesh.describeSurface(query); + const feedSurface = CultMesh.describeSurface(feed); + const operationSurface = CultMesh.describeSurface(operation); + const pointerSurface = CultMesh.describeSurface(pointer); + const nativeViewSurface = CultMesh.describeSurface(nativeView); + const catalog = CultMesh.describeSurfaceCatalog("gamecult.aetheria.rts.surfaces.v1", [ + querySurface, + feedSurface, + operationSurface, + pointerSurface, + nativeViewSurface, + ]); + + assert.equal(querySurface.kind, "query"); + assert.equal(feedSurface.kind, "live-feed"); + assert.equal(pointerSurface.kind, "state-pointer"); + assert.equal(pointerSurface.routeHint.kind, "shared-memory"); + assert.deepEqual(pointerSurface.sources.map(next => next.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + ]); + assert.equal(nativeViewSurface.kind, "native-slice-view"); + assert.equal(catalog.catalogId, "gamecult.aetheria.rts.surfaces.v1"); + assert.deepEqual(catalog.surfaces.map(surface => surface.surfaceId), [ + "aetheria.zone.objects.visible", + "aetheria.rts.viewport.feed", + "aetheria.pilot.set_move_vector", + "aetheria.selection.current", + "aetheria.zone.render", + ]); + assert.equal(catalog.surfaces[0].routeHint.kind, "shared-memory"); + assert.deepEqual(catalog.surfaces[0].sources.map(next => next.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + ]); + assert.notEqual(catalog.surfaces[0], querySurface); + assert.notEqual(catalog.surfaces[0].sources, querySurface.sources); + assert.equal(catalog.surfaces[4].routeHint.kind, "shared-memory"); + + assert.equal( + CultMesh.findSurface(catalog, "aetheria.selection.current")?.kind, + "state-pointer", + ); + assert.equal(CultMesh.findSurface(catalog, "missing"), undefined); + + const operations = CultMesh.surfacesByKind(catalog, "operation"); + assert.deepEqual(operations.map(surface => surface.surfaceId), [ + "aetheria.pilot.set_move_vector", + ]); + assert.notEqual(operations, catalog.surfaces); + assert.notEqual(operations[0], operationSurface); + + const index = CultMesh.surfaceCatalogIndex(catalog); + assert.equal(index.catalogId, "gamecult.aetheria.rts.surfaces.v1"); + assert.deepEqual(index.queries.map(surface => surface.surfaceId), [ + "aetheria.zone.objects.visible", + ]); + assert.deepEqual(index.liveFeeds.map(surface => surface.surfaceId), [ + "aetheria.rts.viewport.feed", + ]); + assert.deepEqual(index.operations.map(surface => surface.surfaceId), [ + "aetheria.pilot.set_move_vector", + ]); + assert.deepEqual(index.statePointers.map(surface => surface.surfaceId), [ + "aetheria.selection.current", + ]); + assert.deepEqual(index.nativeSliceViews.map(surface => surface.surfaceId), [ + "aetheria.zone.render", + ]); + assert.deepEqual(index.projectionRecipes, []); + assert.notEqual(index.operations, operations); + assert.notEqual(index.operations[0], operations[0]); +}); + +test("CultMesh TS exposes fluent context builders for typed handles", async () => { + const operationContext = CultMesh.operationContextFor("unity-client") + .claim("pilot-authority", { shardId: "zone:raven" }) + .route("shared-memory", "co-located Verse") + .idempotency("move:raven:1") + .build(); + + assert.deepEqual(operationContext, { + runtimeId: "unity-client", + claims: [{ role: "pilot-authority", shardId: "zone:raven", leaseId: undefined }], + routeHint: { kind: "shared-memory", description: "co-located Verse" }, + idempotencyKey: "move:raven:1", + }); + + const queryContext = CultMesh.queryContextFor("browser-client") + .route("wasm", "browser-local projection") + .build(); + + assert.deepEqual(queryContext, { + runtimeId: "browser-client", + routeHint: { kind: "wasm", description: "browser-local projection" }, + }); +}); + +test("CultMesh TS Verse context lets generated domain sugar use shared typed contexts", async () => { + const verse = await CultMesh.connectVerse("starbridge", "browser-starfire", { + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + claims: [ + CultMesh.authorityClaim("commander-control", { + shardId: "zone:frontier", + leaseId: "lease:starfire", + }), + ], + }); + const queryVerse = verse.withRoute("shared-memory", "local projection slab"); + const commandVerse = verse.withRoute("network", "remote command route"); + + const aetheria = queryVerse.use((queryContext) => { + const moveOperation = commandVerse.bindOperation(CultMesh.operation< + { entityId: number; direction: { x: number; y: number } }, + ReturnType + >("aetheria.entity.pilot.move", async (request, operationContext) => { + assert.equal(operationContext.runtimeId, "browser-starfire"); + assert.equal(operationContext.claims[0]?.role, "commander-control"); + assert.equal(operationContext.idempotencyKey, "move:starfire:1"); + return CultMesh.operationReceipt("aetheria.entity.pilot.move", request.entityId === 7, { + route: operationContext.routeHint, + }); + })); + const objectsVisible = CultMesh.bindQuery(queryContext, CultMesh.query< + { viewport: ReturnType }, + { runtimeId: string; zoneId: string; route: string; minX: number } + >("aetheria.zone.objects.visible", async (parameters, context) => ({ + runtimeId: context.runtimeId, + zoneId: "zone:frontier", + route: context.routeHint.kind, + minX: parameters.viewport.minX, + }))); + + return { + entity: (entityId: number) => ({ + pilot: { + move: (direction: { x: number; y: number }, idempotencyKey: string) => + moveOperation.invoke({ entityId, direction }, { idempotencyKey }), + }, + }), + zone: (zoneId: string) => ({ + objects: { + visibleWithin: async (viewport: ReturnType) => ({ + ...await objectsVisible.execute({ viewport }), + zoneId, + }), + }, + }), + }; + }); + + const receipt = await aetheria + .entity(7) + .pilot + .move(CultMesh.vec2(1, 0), "move:starfire:1"); + const viewport = await aetheria + .zone("zone:frontier") + .objects + .visibleWithin(CultMesh.viewportRequest(CultMesh.rectFromBounds(-16, -8, 16, 8))); + + assert.equal(verse.verseId, "starbridge"); + assert.equal(verse.runtimeId, "browser-starfire"); + assert.deepEqual(verse.operationContext({ idempotencyKey: "move:starfire:2" }), { + runtimeId: "browser-starfire", + claims: [{ role: "commander-control", shardId: "zone:frontier", leaseId: "lease:starfire" }], + routeHint: { kind: "network", description: "remote Verse peer" }, + idempotencyKey: "move:starfire:2", + }); + assert.deepEqual(queryVerse.queryContext(), { + runtimeId: "browser-starfire", + routeHint: { kind: "shared-memory", description: "local projection slab" }, + }); + assert.equal(commandVerse.bindOperation(CultMesh.operation("noop", async (_request, context) => + CultMesh.operationReceipt("noop", true, { route: context.routeHint }))).operationId, "noop"); + assert.deepEqual(receipt, { + operationId: "aetheria.entity.pilot.move", + accepted: true, + route: { + kind: "network", + description: "remote command route", + }, + diagnostic: undefined, + }); + assert.deepEqual(viewport, { + runtimeId: "browser-starfire", + zoneId: "zone:frontier", + route: "shared-memory", + minX: -16, + }); +}); + +test("CultMesh TS exposes typed state pointers for UI and tools", async () => { + const updates: string[] = []; + let value = "initial"; + const pointer = CultMesh.statePointer( + "aetheria.selection.current", + async () => value, + (callback: (value: string) => void) => { + callback(value); + return () => updates.push("unsubscribed"); + }, + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.selection.current.v1", { + schemaId: "gamecult.aetheria.selection.v1", + }), + ], + routeHint: CultMesh.routeHint("shared-memory", "co-located selection cache"), + }, + ); + + assert.equal(pointer.pointerId, "aetheria.selection.current"); + assert.equal(await pointer.resolve(), "initial"); + assert.deepEqual(CultMesh.describeStatePointer(pointer), { + pointerId: "aetheria.selection.current", + routeHint: { + kind: "shared-memory", + description: "co-located selection cache", + }, + sources: [ + { + sourceId: "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1", + description: undefined, + }, + ], + }); + + value = "selected:ship:1"; + const unsubscribe = pointer.watch((next) => updates.push(next)); + unsubscribe(); + + assert.deepEqual(updates, ["selected:ship:1", "unsubscribed"]); + + const contextualUpdates: string[] = []; + const contextualPointer = CultMesh.statePointer( + "aetheria.daemon.frame.latest", + async (context) => `${context.runtimeId}:${context.routeHint.kind}`, + (context, callback) => { + callback(`${context.runtimeId}:frame:12:${context.routeHint.kind}`); + return () => contextualUpdates.push("contextual-unsubscribed"); + }, + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + }), + ], + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon frame"), + }, + ); + const verse = CultMesh.verse("aetheria.local", "bifrost-tool") + .withRoute("ipc", "tool bridge"); + const bound = CultMesh.bindStatePointer(verse, contextualPointer); + + assert.equal(await bound.resolve(), "bifrost-tool:ipc"); + const contextualUnsubscribe = bound.watch((next) => contextualUpdates.push(next)); + contextualUnsubscribe(); + + assert.equal(bound.pointerId, "aetheria.daemon.frame.latest"); + assert.deepEqual(bound.sources.map(source => source.schemaId), [ + "gamecult.aetheria.daemon_frame.v1", + ]); + assert.deepEqual(contextualUpdates, [ + "bifrost-tool:frame:12:ipc", + "contextual-unsubscribed", + ]); +}); + +test("CultMesh TS mutable state pointers read watch and replace through a Verse", async () => { + let stored = "frame:0"; + let watcher: ((value: string) => void) | undefined; + const contexts: string[] = []; + const pointer = CultMesh.mutableStatePointer( + "aetheria.daemon.frame.latest", + async (context) => { + contexts.push(`read:${context.runtimeId}:${context.routeHint.kind}`); + return stored; + }, + (context, callback) => { + contexts.push(`watch:${context.runtimeId}:${context.routeHint.kind}`); + watcher = (value) => callback(`${context.runtimeId}:${value}:${context.routeHint.kind}`); + return () => { + watcher = undefined; + }; + }, + async (context, value) => { + contexts.push(`replace:${context.runtimeId}:${context.routeHint.kind}`); + stored = value; + watcher?.(value); + }, + { + routeHint: CultMesh.routeHint("shared-memory", "co-located daemon frame"), + sources: [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + }), + ], + }, + ); + + const verse = CultMesh.verse("aetheria.local", "unity-raven", { + routeHint: CultMesh.routeHint("ipc", "tool bridge"), + }); + const bound = verse.bindMutableStatePointer(pointer); + + let observed = ""; + const unsubscribe = bound.watch((value) => { + observed = value; + }); + assert.equal(await bound.read(), "frame:0"); + await bound.replace("frame:1"); + unsubscribe(); + + assert.equal(stored, "frame:1"); + assert.equal(observed, "unity-raven:frame:1:ipc"); + assert.deepEqual(contexts, [ + "watch:unity-raven:ipc", + "read:unity-raven:ipc", + "replace:unity-raven:ipc", + ]); + assert.deepEqual(CultMesh.describeStatePointer(pointer), { + pointerId: "aetheria.daemon.frame.latest", + routeHint: { + kind: "shared-memory", + description: "co-located daemon frame", + }, + sources: [ + { + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + description: undefined, + }, + ], + }); + assert.deepEqual(CultMesh.stateBinding("frame", pointer), { + targetProp: "frame", + pointerId: "aetheria.daemon.frame.latest", + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + routeHint: { + kind: "shared-memory", + description: "co-located daemon frame", + }, + }); +}); + +test("CultMesh TS describes UI state bindings from typed state pointers", async () => { + const pointer = CultMesh.statePointer( + "aetheria.selection.current", + async () => "entity:station:0", + undefined, + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.selection.current.v1", { + schemaId: "gamecult.aetheria.selection.v1", + }), + ], + routeHint: CultMesh.routeHint("shared-memory", "co-located selection cache"), + }, + ); + + const binding = CultMesh.stateBinding("value", pointer); + const explicitBinding = CultMesh.stateBinding( + "label", + "aetheria.selection.label", + { + sourceId: "daemon:aetheria.selection.label.v1", + schemaId: "gamecult.aetheria.selection_label.v1", + routeHint: CultMesh.routeHint("ipc", "tool bridge"), + }, + ); + + assert.deepEqual(binding, { + targetProp: "value", + pointerId: "aetheria.selection.current", + sourceId: "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1", + routeHint: { + kind: "shared-memory", + description: "co-located selection cache", + }, + }); + assert.deepEqual(explicitBinding, { + targetProp: "label", + pointerId: "aetheria.selection.label", + sourceId: "daemon:aetheria.selection.label.v1", + schemaId: "gamecult.aetheria.selection_label.v1", + routeHint: { + kind: "ipc", + description: "tool bridge", + }, + }); +}); + +test("CultMesh TS flattens and rehydrates UI state binding records", () => { + const binding = CultMesh.stateBinding( + "status", + "aetheria.current.status", + { + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + routeHint: CultMesh.routeHint("shared-memory", "co-located frame slab"), + }, + ); + + assert.deepEqual(CultMesh.stateBindingRecord(binding), { + targetProp: "status", + pointerId: "aetheria.current.status", + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + routeKind: "shared-memory", + routeDescription: "co-located frame slab", + }); + + assert.deepEqual(CultMesh.stateBindingFromRecord({ + targetProp: "value", + pointerId: "aetheria.selection.current", + sourceId: "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1", + routeKind: "InProcess", + routeDescription: "embedded tool host", + }), { + targetProp: "value", + pointerId: "aetheria.selection.current", + sourceId: "daemon:aetheria.selection.current.v1", + schemaId: "gamecult.aetheria.selection.v1", + routeHint: { + kind: "in-process", + description: "embedded tool host", + }, + }); +}); + +test("CultMesh TS describes UI command bindings from typed operations", async () => { + const operation = CultMesh.operation< + { actor: string }, + ReturnType + >("aetheria.entity.pilot.move", async (_request, context) => + CultMesh.operationReceipt("aetheria.entity.pilot.move", true, { + route: context.routeHint, + })); + + const binding = CultMesh.operationBinding(operation, { + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + }); + const explicitBinding = CultMesh.operationBinding("aetheria.surface.refresh", { + label: "Refresh", + }); + + assert.deepEqual(binding, { + operationId: "aetheria.entity.pilot.move", + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: { + kind: "network", + description: "remote Verse peer", + }, + }); + assert.deepEqual(explicitBinding, { + operationId: "aetheria.surface.refresh", + label: "Refresh", + schemaId: "", + routeHint: { + kind: "automatic", + }, + }); +}); + +test("CultMesh TS flattens and rehydrates UI operation binding records", () => { + const binding = CultMesh.operationBinding("aetheria.entity.pilot.move", { + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + }); + + assert.deepEqual(CultMesh.operationBindingRecord(binding), { + operationId: "aetheria.entity.pilot.move", + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeKind: "network", + routeDescription: "remote Verse peer", + }); + + assert.deepEqual(CultMesh.operationBindingFromRecord({ + operationId: "aetheria.surface.refresh", + label: "Refresh", + schemaId: "gamecult.aetheria.refresh.v1", + routeKind: "SharedMemory", + routeDescription: "co-located command boundary", + }), { + operationId: "aetheria.surface.refresh", + label: "Refresh", + schemaId: "gamecult.aetheria.refresh.v1", + routeHint: { + kind: "shared-memory", + description: "co-located command boundary", + }, + }); +}); + +test("CultMesh TS carries UI command invocations as typed operation descriptors", () => { + const binding = CultMesh.operationBinding("aetheria.entity.pilot.move", { + label: "Move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: CultMesh.routeHint("ipc", "local Verse node"), + }); + + const invocation = CultMesh.operationInvocation(binding, { + idempotencyKey: "move:42", + }); + const explicitInvocation = CultMesh.operationInvocation( + "aetheria.surface.refresh", + { + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + }, + ); + + assert.deepEqual(invocation, { + operationId: "aetheria.entity.pilot.move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: { + kind: "ipc", + description: "local Verse node", + }, + idempotencyKey: "move:42", + }); + assert.deepEqual(explicitInvocation, { + operationId: "aetheria.surface.refresh", + schemaId: "", + routeHint: { + kind: "network", + description: "remote Verse peer", + }, + idempotencyKey: undefined, + }); +}); + +test("CultMesh TS reads common operation payload scalar fields", () => { + const payload = CultMesh.operationPayload({ + value: "42.5", + tierIndex: 3, + enabled: "on", + name: "Starfire", + }); + const updated = payload.with("enabled", false); + + assert.equal(payload.getString("name"), "Starfire"); + assert.equal(payload.getString("missing", "fallback"), "fallback"); + assert.equal(payload.getInt("tierIndex", -1), 3); + assert.equal(payload.getInt("missing", -1), -1); + assert.equal(payload.getDouble("value", -1), 42.5); + assert.equal(payload.getBoolean("enabled"), true); + assert.equal(payload.getBoolean("missing", true), true); + assert.equal(updated.getBoolean("enabled", true), false); + assert.equal(updated.getString("name"), "Starfire"); + assert.deepEqual(payload.fields, { + value: "42.5", + tierIndex: "3", + enabled: "on", + name: "Starfire", + }); + assert.deepEqual(payload.toRecord(), payload.fields); +}); + +test("CultMesh TS flattens and rehydrates operation invocation records", () => { + const invocation = CultMesh.operationInvocation("aetheria.entity.pilot.move", { + schemaId: "gamecult.aetheria.pilot_move.v1", + routeHint: CultMesh.routeHint("ipc", "local Verse node"), + idempotencyKey: "move:42", + }); + + const record = CultMesh.operationInvocationRecord(invocation); + const restored = CultMesh.operationInvocationFromRecord(record, { + fallbackOperationId: "fallback.operation", + fallbackSchemaId: "fallback.schema", + fallbackRouteHint: CultMesh.routeHint("network", "fallback route"), + }); + const csharpRecord = CultMesh.operationInvocationFromRecord( + { + operationId: "", + schemaId: "", + routeKind: "InProcess", + routeDescription: "", + idempotencyKey: "", + }, + { + fallbackOperationId: "fallback.operation", + fallbackSchemaId: "fallback.schema", + fallbackRouteHint: CultMesh.routeHint("network", "fallback route"), + fallbackIdempotencyKey: "fallback-key", + }, + ); + + assert.deepEqual(record, { + operationId: "aetheria.entity.pilot.move", + schemaId: "gamecult.aetheria.pilot_move.v1", + routeKind: "ipc", + routeDescription: "local Verse node", + idempotencyKey: "move:42", + }); + assert.deepEqual(restored, invocation); + assert.deepEqual(csharpRecord, { + operationId: "fallback.operation", + schemaId: "fallback.schema", + routeHint: { + kind: "in-process", + description: "fallback route", + }, + idempotencyKey: "fallback-key", + }); +}); + +test("CultMesh TS composes named state ref resolvers for surfaces", () => { + const daemon = CultMesh.stateRefResolver( + "aetheria.daemon.refs", + (stateRef: string, context) => + stateRef === "aetheria.daemon/frame/id" + ? `${context.runtimeId}:${context.routeHint.kind}:42` + : "", + { + sources: [ + CultMesh.projectionSource("daemon:aetheria.frame.latest.v1", { + schemaId: "gamecult.aetheria.daemon_frame.v1", + }), + ], + routeHint: CultMesh.routeHint("in-process", "embedded renderer"), + }, + ); + const itemStats = CultMesh.stateRefResolver( + "aetheria.item_stats.refs", + (stateRef: string) => + stateRef === "aetheria.item_stats/laser/damage" ? "12.5" : "", + ); + const resolver = daemon.or(itemStats); + + assert.equal( + resolver.resolve( + "aetheria.daemon/frame/id", + CultMesh.queryContext("unity-raven", { + routeHint: CultMesh.routeHint("network", "remote peer"), + }), + ), + "unity-raven:network:42", + ); + assert.equal(resolver.resolve("aetheria.item_stats/laser/damage"), "12.5"); + assert.deepEqual(resolver.tryResolve("missing"), { + resolved: false, + value: "", + }); + assert.equal(resolver.asFunction()("aetheria.item_stats/laser/damage"), "12.5"); + + assert.deepEqual(CultMesh.describeStateRefResolver(resolver), { + resolverId: "aetheria.daemon.refs|aetheria.item_stats.refs", + routeHint: { + kind: "in-process", + description: "embedded renderer", + }, + sources: [ + { + sourceId: "daemon:aetheria.frame.latest.v1", + schemaId: "gamecult.aetheria.daemon_frame.v1", + description: undefined, + }, + ], + }); +}); + +test("CultMesh TS exposes native slice descriptors", () => { + const view = CultMesh.nativeSliceView( + "aetheria.zone.render", + "gamecult.aetheria.zone_render.v1", + 12, + [ + CultMesh.nativeSliceColumn("position", "CultVec3", 12), + CultMesh.nativeSliceColumn("rotationRadians", "float32", 4), + CultMesh.nativeSliceColumn("renderGroupId", "uint32", 4), + ], + { + route: CultMesh.routeHint("shared-memory", "CultCache slab"), + nativeHandle: "aetheria-zone-render-001", + }, + ); + + assert.equal(CultMesh.denseRowStrideBytes(view), 20); + assert.deepEqual(CultMesh.findNativeSliceColumn(view, "position"), { + name: "position", + valueType: "CultVec3", + elementSizeBytes: 12, + }); + assert.equal(CultMesh.findNativeSliceColumn(view, "missing"), undefined); + + const diagnostic = CultMesh.describeNativeSliceView(view); + assert.equal(diagnostic.viewId, "aetheria.zone.render"); + assert.equal(diagnostic.schemaId, "gamecult.aetheria.zone_render.v1"); + assert.equal(diagnostic.rowCount, 12); + assert.equal(diagnostic.route.kind, "shared-memory"); + assert.equal(diagnostic.nativeHandle, "aetheria-zone-render-001"); + assert.equal(diagnostic.denseRowStrideBytes, 20); + assert.deepEqual(diagnostic.columns.map(column => column.name), [ + "position", + "rotationRadians", + "renderGroupId", + ]); + assert.notEqual(diagnostic.columns, view.columns); +}); + test("CultMesh TS branded facade exposes schema and shard catalogs", () => { const schemaCatalog = CultMesh.createSchemaCatalog(); const schemaUpdates: string[] = []; @@ -615,3 +1738,7 @@ test("CultMesh TS negotiates streaming frame body transports explicitly", () => assert.deepEqual(streamUpdates, ["mimir:kiyo-pro"]); assert.deepEqual(frameUpdates, [42n]); }); + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} diff --git a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md new file mode 100644 index 00000000..93fb3f3c --- /dev/null +++ b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md @@ -0,0 +1,298 @@ +# CultMesh Cross-Runtime Primitives Roadmap + +CultMesh should feel like a cozy typed runtime, not a transport kit with good +manners. Application code should read as native state and native operations in +every runtime, while CultMesh chooses the fastest valid path: in-process, +shared memory, IPC, network, or WASM. + +In this roadmap, "primitive" does not mean "tiny DTO" or local convenience +wrapper. It means a reusable cross-runtime developer affordance promoted into +the shared library layer: the abstraction, metadata, generated bindings, +runtime routing, diagnostics, and fast path that make an obvious state operation +feel native everywhere. A `Rect` value is a primitive; so is a typed viewport +query surface that can execute against an in-process daemon, a shared CultCache +slab, a remote Verse peer, or a browser/WASM host without changing the caller's +API. + +This roadmap is the shared-layer backlog for abstractions that Aetheria, +Brokkr, Odin, Eve, Bifrost, Unity, Electron, Rust, and browser clients should +all be able to use without cloning local facade code. + +## Design Rule + +If two runtimes need the same semantic shape, hoist it into CultMesh or the +adjacent CultLib foundation layer. Do not leave it as an Aetheria-only wrapper, +an Electron preload convention, a Unity bridge helper, or an MCP-specific +adapter. + +CultMesh should be expansive enough to absorb repeated developer-experience +patterns, not just repeated serialization shapes. When Aetheria, Brokkr, Odin, +Bifrost, Eve, Ymir, VoidBot, Unity, TS, Rust, or browser clients all want the +same kind of state pointer, operation, query, watch, authority claim, native +view, or surface binding, that is evidence of a missing shared primitive. The +domain package keeps the nouns and policy; CultMesh owns the managed, cozy +machinery that makes those nouns feel magical and run very fast. + +The application layer may name domain operations: + +```ts +await verse.aetheria.zone(zoneId).pilot(entityId).move(CultMesh.vec2(1, 0)); +``` + +The shared layer must own the reusable primitives that make that sugar possible: +typed state pointers, operation handles, query surfaces, authority claims, +projection recipes, native slab descriptors, watches, and generated binding +metadata. + +The test is whether a downstream runtime can say the domain thing directly. If +the caller has to remember record keys, schema ids, MessagePack slots, endpoint +paths, IPC channel names, local publication files, cache filenames, queue names, +or whether the target is local/remote/WASM, the shared primitive is not cozy +enough yet. + +## Managed Magic Contract + +Every promoted primitive should carry both a high-level developer promise and a +low-level performance promise: + +| Promise | Requirement | +| --- | --- | +| Native-feeling API | Public calls look like typed domain state, operations, queries, or views rather than transport instructions. | +| One semantic surface | C#, TypeScript, Rust, browser/WASM, Unity, Eve/CultUI, and tool runtimes use the same conceptual API even when adapters differ. | +| Fastest valid route | The runtime can choose in-process calls, shared slabs, IPC, RUDP/network, copied snapshots, or WASM views without creating a second application API. | +| Typed safety | State, operation payloads, query parameters, results, authority claims, receipts, and view columns are generated or strongly typed. | +| Reactive by default | Values that can change expose watch/subscription semantics with explicit lifetime and backpressure behavior. | +| Inspectable | Diagnostics can explain the chosen route, authority decision, schema version, cache source, native handle, and copy/fallback cost. | +| Portable fallback | Remote or browser clients keep the same semantic surface when zero-copy locality is unavailable. | + +## Primitive Families + +| Primitive family | Shared responsibility | First consumers | +| --- | --- | --- | +| Typed state pointer | A stable typed reference to Verse state that can resolve, watch, and be embedded in Eve/CultUI surfaces. | Eve, Bifrost MCP, Unity inspectors, RTS panels | +| State binding descriptor | Surface metadata that binds a UI/tool prop to a typed CultMesh state pointer with source schema and route hints. | Eve/CultUI, Bifrost MCP, Unity inspectors, RTS status panels | +| Typed operation handle | Method-shaped invocation with typed request/response, authority claims, idempotency, route hints, and receipts. | Aetheria pilot/commander verbs, Brokkr editor tools | +| Operation binding descriptor | Surface metadata that binds a UI/tool command to a typed CultMesh operation id with request schema and route hints. | Eve/CultUI, Bifrost MCP, Unity controls, RTS command panels | +| Operation invocation descriptor | Concrete UI/tool invocation metadata that preserves operation id, request schema, route hints, and idempotency across renderer boundaries. | Eve/CultUI lowerers, Unity UI Toolkit, RTS panels, Bifrost tools | +| Operation payload | Shared invocation field bag with typed scalar readers, copy-on-write updates, and legacy string-field ingestion while generated request schemas take over. | Eve/CultUI lowerers, Unity UI Toolkit, RTS panels, Bifrost tools | +| Typed query surface | Derived-state query with typed parameters and result, callable locally or remotely without exposing transport. | Aetheria objects/gravity/selection/inventory viewports | +| Projection recipe | Declarative composition of typed documents into a named local projection, cacheable and watchable by clients. | Unity render shell, Electron RTS map, browser CultMesh-only clients | +| Authority scope | Runtime claim, lease, witness, or delegated simulation ownership expressed as typed policy state. | Trusted co-op, future quorum/witness modes | +| Native slice view | Typed SoA/slab view descriptors that map to Unity `NativeArray`, Rust slices, C# spans, or browser typed arrays. | Aetheria render state, Ymir physics state | +| Schema-generated binding | Cross-runtime generated metadata for document slots, enum ids, schema ids, and ergonomic method surfaces. | C#, TypeScript, Rust, WASM | +| Reactive watch | Subscription primitive over typed documents, pointers, queries, and native views with disposal/backpressure semantics. | CultUI, Unity presentation caches, RTS status panels | +| Managed Verse context | Shared `verse.use(schema)` binding context carrying runtime id, route hints, and authority claims into generated domain operations and queries. | Unity client, RTS client, browser-only CultMesh clients, Rust rebuild | +| Verse-bound surface | Operation, query, and live-feed handles bound once to a Verse context so generated facades call domain methods without repeatedly materializing transport contexts. | Aetheria RTS facade, Unity facade generation, Rust/WASM bindings | +| Schema-stamped cache recovery | Directory-backed CultCache records may recover missing hot-manifest catalog entries from schema-version metadata embedded in the cold payload, then resolve by shared schema name. | Aetheria persisted Verse state, long-lived daemon stores, editor/runtime co-deployment | + +## Required Sugar Shape + +The shared primitives should let domain packages expose APIs that look like +state, not transport. + +TypeScript target: + +```ts +const verse = await CultMesh.connectVerse("starbridge"); +const aetheria = verse.use(Aetheria); + +const viewport = await aetheria.zone(zoneId) + .objects() + .visibleTo(controlledUnits) + .within(CultMesh.rectFromBounds(-400, -250, 400, 250)) + .query(); + +await aetheria.entity(ravenId) + .pilot() + .move(CultMesh.vec2(1, 0), { claim: "pilot-control" }); +``` + +C# target: + +```csharp +var verse = await CultMesh.ConnectVerseAsync("starbridge"); +var aetheria = verse.Use(AetheriaRuntime.Schema); + +var viewport = await aetheria.Zone(zoneId) + .Objects() + .VisibleTo(controlledUnits) + .Within(CultRect.FromBounds(-400, -250, 400, 250)) + .QueryAsync(); + +await aetheria.Entity(ravenId) + .Pilot() + .MoveAsync(CultVec2.Right, claim: "pilot-control"); +``` + +Current seed: + +```csharp +var verse = await CultMesh.ConnectVerseAsync( + "starbridge", + "unity-raven", + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located Verse"), + new[] { new CultMeshAuthorityClaim("pilot-control", shardId: "zone:raven") }); + +var aetheria = verse.Use(context => new AetheriaGeneratedFacade(context)); +await aetheria.Entity(ravenId).Pilot.MoveAsync(CultVec2.Right); +``` + +```ts +const verse = await CultMesh.connectVerse("starbridge", "browser-starfire", { + routeHint: CultMesh.routeHint("network", "remote Verse peer"), + claims: [CultMesh.authorityClaim("commander-control", { shardId: "zone:frontier" })], +}); + +const aetheria = verse.use((context) => new AetheriaGeneratedFacade(context)); +await aetheria.entity(ravenId).pilot.move(CultMesh.vec2(1, 0)); +``` + +Generated facades should split Verse views by locality and bind surfaces once: + +```ts +const queryVerse = verse.withRoute("shared-memory", statePath); +const commandVerse = verse + .withRoute("network", endpoint) + .withClaim("commander-control", { shardId: "aetheria.local" }); + +const objectsViewport = CultMesh.bindQuery(queryVerse, queries.objectsViewport); +const setMoveVector = CultMesh.bindOperation(commandVerse, operations.setMoveVector); + +await objectsViewport.execute(viewport); +await setMoveVector.invoke({ actorEntityKey, directionX: 1, directionY: 0 }); +``` + +That seed is intentionally context-only. Discovery, authority negotiation, +transport selection, native slabs, and WASM adapters can grow behind the same +Verse handle without forcing Unity, Electron, browser, or Rust clients to learn +a second public API. + +Rust/WASM target: + +```rust +let verse = CultMesh::connect_verse("starbridge").await?; +let aetheria = verse.use_schema(aetheria::schema()); + +let viewport = aetheria + .zone(zone_id) + .objects() + .visible_to(&controlled_units) + .within(CultRect::from_bounds(-400.0, -250.0, 400.0, 250.0)) + .query() + .await?; +``` + +These examples are intentionally aspirational. They are the shape the primitive +families must support; Aetheria should not keep bespoke client facades once the +shared layer can express the same semantics. + +## Staged Build + +1. Stabilize handle descriptors. + `CultMeshStatePointer`, `CultMeshOperationHandle`, + `CultMeshQuerySurface`, + `CultMeshProjectionRecipe`, `CultMeshRouteHint`, and + `CultMeshNativeSliceViewDescriptor` are the current seed. Keep them small, + serializable where useful, and mirrored across C# and TypeScript. Bound + operation, query, and live-feed wrappers are the first managed layer above + those descriptors; generated facades should prefer them over ad hoc + `OperationContext` / `QueryContext` plumbing. + +2. Add generated binding descriptors. + Domain packages should generate schema ids, slots, enum ids, state pointer + factories, query handles, and operation handles from the same source + contract. Hand-entered MessagePack slot maps are compatibility debt. + +3. Grow projection recipes. + The seed now names source state, route hints, projection execution, and + query-surface conversion. Next it should add generated source handles, local + cache keys, invalidation/watch behavior, and native-view output where + applicable. This turns Aetheria `ObjectsViewport`, `GravityViewport`, + selection, inventory, stats, station stock, and authority status into + portable CultMesh surfaces. + +4. Add transparent state refs for UI runtimes. + Eve/CultUI surfaces may carry a typed `CultMeshStatePointer` instead of a + JSON blob or string state ref. The UI runtime resolves and watches it using + the local Verse node, with Bifrost/Odin using the same pointer semantics for + MCP tools. State pointers now carry route hints and typed source metadata in + both C# and TypeScript, so a surface catalog can advertise where a pointer + resolves and which schema-backed document/source it depends on instead of + leaking daemon-specific resolver conventions. They also bind to a Verse like + operations, queries, and live feeds: `CultMesh.StatePointer(...)` / + `CultMesh.statePointer(...)` create pointers, and + `CultMesh.bindStatePointer(...)` / `verse.BindStatePointer(...)` produce a + bound pointer that resolves and watches through the Verse query context + instead of requiring UI or tool hosts to rebuild runtime-id and route + plumbing. Mutable document-style handles use the same shared shape: + `CultMeshMutableStatePointer` / `CultMesh.mutableStatePointer(...)` + add typed replacement to the read/watch state pointer contract so tools, + editor surfaces, and authorized clients do not grow private + `Document` wrappers. `CultMeshStateBindingDescriptor` / `CultMesh.stateBinding(...)` + now hoists the repeatable "component prop -> state pointer" shape into the + shared layer, including pointer id, source id, schema id, and route hint. + Eve documents, Unity inspectors, browser panels, and Bifrost tools should + carry this descriptor instead of inventing local `stateRef` string props. + `CultMeshOperationBindingDescriptor` / `CultMesh.operationBinding(...)` + applies the same rule to controls: a UI command advertises a typed operation + id, optional request schema, label, and route hint instead of making + `command` and `transport` strings the canonical API. + +5. Add native slab adapters. + `CultMeshNativeSliceViewDescriptor` must map to Unity `NativeArray`, C# + `ReadOnlySpan`, Rust slices, and TS typed arrays where locality permits. + The descriptor names the column and value type; runtime adapters own handles, + fences, and unavoidable copy accounting. + +6. Add authority-aware routing. + Operation/query invocation should accept authority claims and route hints, + then choose local, shared-memory, IPC, network, or WASM execution without + exposing a second public API for each route. + +7. Add cross-runtime conformance tests. + The same primitive examples must pass in C# and TypeScript first, then Rust. + Aetheria client tests should assert against these shared primitives rather + than against private Unity/Electron bridges. + +8. Harden durable cache ergonomics. + Directory-backed CultCache stores should behave like managed state, not + fragile manifest bookkeeping. A missing catalog entry in the hot manifest + must not strand a readable cold record when the payload carries a schema + version. The C# directory store now infers a minimal catalog entry from a + schema-stamped payload and resolves by schema name, with regression coverage + in `GameCult.Caching.Tests`. The TypeScript and Rust single-file MessagePack + stores now perform the same schema-stamped recovery at the snapshot boundary, + and both cache runtimes normalize recovered schema-name envelopes back to the + registered public document type before hydration. Python now mirrors the + same managed recovery path for stores and raw CultNet replication helpers, + and the TypeScript inspector reports recovered schema names instead of + leaking stale manifest ids. Keep this parity intact before relying on + long-lived cross-runtime Verse stores. + +## Aetheria Pressure Tests + +Aetheria should exercise the roadmap in this order: + +1. Sector map and Starbridge session summary as typed state pointers. +2. Objects/gravity viewport split as typed query surfaces. +3. Selected object, inventory, station stock, and current stats as projection + recipes. +4. Pilot movement, targeting, equipment activation, docking, repair, cooling, + salvage, and commander verbs as typed operation handles. +5. Zone render and Ymir physics views as native slice descriptors. +6. Eve/CultUI panels that embed state pointers and operation handles instead of + local payload maps. +7. Unity and RTS clients consuming the same generated domain sugar. + +## Stop Lines + +- Do not add new public Aetheria command facades unless they can be expressed as + typed CultMesh operation handles. +- Do not add new viewport APIs unless they can be expressed as typed CultMesh + query surfaces or projection recipes. +- Do not add new UI state-ref strings when a typed state pointer would express + the same thing. +- Do not add native memory sharing as a Unity-only bridge; describe the slab in + CultMesh and let Unity be one adapter. +- Do not accept runtime parity by convention. C#, TypeScript, and Rust need + shared generated contracts or explicit conformance tests. From 81b01ec41e78f2b21d8b850f7b276b7b941aafc8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 14:35:54 +0100 Subject: [PATCH 013/498] Harden CultNet snapshot fetches --- .../CultNetShardReplication.cs | 40 ++++++++++++++++++- src/GameCult.Networking/CultNetTransport.cs | 15 +++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Networking/CultNetShardReplication.cs b/src/GameCult.Networking/CultNetShardReplication.cs index 88eaac7a..5bc452f2 100644 --- a/src/GameCult.Networking/CultNetShardReplication.cs +++ b/src/GameCult.Networking/CultNetShardReplication.cs @@ -516,6 +516,16 @@ namespace GameCult.Networking /// public TimeSpan ResponseTimeout { get; set; } = TimeSpan.FromSeconds(5); + /// + /// Gets or sets schema ids to request. Null or empty requests every schema allowed by the shard. + /// + public IReadOnlyList? SchemaIds { get; set; } + + /// + /// Gets or sets record keys to request. Null or empty requests every record allowed by the shard/schema filter. + /// + public IReadOnlyList? RecordKeys { get; set; } + /// /// Gets or sets a callback used to customize each ephemeral fetch client. /// @@ -569,7 +579,8 @@ namespace GameCult.Networking client.SendCultNet(new CultNetSnapshotRequestMessage { MessageId = messageId, - SchemaIds = shard.SchemaIds.Count == 0 ? null : shard.SchemaIds.ToArray(), + SchemaIds = ResolveRequestedSchemaIds(shard), + RecordKeys = ResolveRequestedRecordKeys(), ShardId = shard.ShardId, ShardEpoch = shard.Epoch }); @@ -583,6 +594,33 @@ namespace GameCult.Networking ?? CultNetSchemaClients.CreateForEndpoint(endpoint, _options.Security, _options.ConfigureClient); } + private string[]? ResolveRequestedSchemaIds(CultNetShardDescriptor shard) + { + if (_options.SchemaIds is { Count: > 0 }) + { + return _options.SchemaIds + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + return shard.SchemaIds.Count == 0 ? null : shard.SchemaIds.ToArray(); + } + + private string[]? ResolveRequestedRecordKeys() + { + if (_options.RecordKeys is not { Count: > 0 }) + { + return null; + } + + var recordKeys = _options.RecordKeys + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return recordKeys.Length == 0 ? null : recordKeys; + } + private async Task WaitForConnectionAsync(ICultNetSchemaClient client, string endpoint) { var deadline = DateTimeOffset.UtcNow + _options.ConnectTimeout; diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index b4517204..98da8562 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -1837,6 +1837,14 @@ namespace GameCult.Networking { return null; } + catch (SocketException error) when ( + error.SocketErrorCode == SocketError.ConnectionReset || + error.SocketErrorCode == SocketError.ConnectionAborted || + error.SocketErrorCode == SocketError.Shutdown) + { + DisconnectReason = Array.Empty(); + return null; + } _stats.BytesReceived += received; if (_remoteEndPoint == null) @@ -2163,6 +2171,13 @@ namespace GameCult.Networking { return null; } + catch (SocketException error) when ( + error.SocketErrorCode == SocketError.ConnectionReset || + error.SocketErrorCode == SocketError.ConnectionAborted || + error.SocketErrorCode == SocketError.Shutdown) + { + return null; + } _stats.BytesReceived += received; var wire = new byte[received]; From 60a3fa9f8272953d27a13c3ea71ef73aa3f4d129 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 14:43:00 +0100 Subject: [PATCH 014/498] Add cache-backed CultMesh document handles --- src/GameCult.Mesh/CultMesh.cs | 246 ++++++++++++++++++ src/GameCult.Mesh/CultMeshPrimitives.cs | 47 +++- src/GameCult.Mesh/docs/public-api.md | 27 +- .../CultMeshStreamingTests.cs | 108 ++++++++ 4 files changed, 417 insertions(+), 11 deletions(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index b4e93588..cfde04af 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Networking; +using R3; namespace GameCult.Mesh { @@ -412,6 +413,19 @@ namespace GameCult.Mesh return new CultMeshDocumentHandle(BindLiveFeed(context, feed)); } + /// + /// Binds a mutable typed document live feed to a Verse context. + /// + public static CultMeshDocumentHandle BindDocument( + CultMeshVerseContext context, + CultMeshLiveFeed feed, + Func replace) + where TDocument : class + { + if (replace == null) throw new ArgumentNullException(nameof(replace)); + return new CultMeshDocumentHandle(BindLiveFeed(context, feed), replace); + } + /// /// Binds a typed document live feed to a Verse. /// @@ -424,6 +438,19 @@ namespace GameCult.Mesh return BindDocument(verse.Context, feed); } + /// + /// Binds a mutable typed document live feed to a Verse. + /// + public static CultMeshDocumentHandle BindDocument( + CultMeshVerse verse, + CultMeshLiveFeed feed, + Func replace) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return BindDocument(verse.Context, feed, replace); + } + /// /// Binds a typed state pointer to a Verse context. /// @@ -884,6 +911,30 @@ namespace GameCult.Mesh return BindDocument(context, feed); } + /// + /// Creates a mutable typed document handle from snapshot/watch/replace delegates and binds it to a Verse context. + /// + public static CultMeshDocumentHandle Document( + string documentId, + CultMeshVerseContext context, + Func> latest, + Func> watch, + Func replace, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (replace == null) throw new ArgumentNullException(nameof(replace)); + + var feed = LiveFeed( + documentId, + (_parameters, queryContext) => latest(queryContext), + (_parameters, queryContext) => watch(queryContext), + sources, + routeHint); + return BindDocument(context, feed, replace); + } + /// /// Creates a typed document handle from snapshot/watch delegates and binds it to a Verse. /// @@ -900,6 +951,175 @@ namespace GameCult.Mesh return Document(documentId, verse.Context, latest, watch, sources, routeHint); } + /// + /// Creates a mutable typed document handle from snapshot/watch/replace delegates and binds it to a Verse. + /// + public static CultMeshDocumentHandle Document( + string documentId, + CultMeshVerse verse, + Func> latest, + Func> watch, + Func replace, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Document(documentId, verse.Context, latest, watch, replace, sources, routeHint); + } + + /// + /// Creates a typed document handle directly over one CultCache record. + /// + public static CultMeshDocumentHandle Document( + CultCache cache, + CultRecordKey key, + CultMeshVerseContext context, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var descriptor = cache.Registry.GetRequired(); + var sourceList = sources?.ToArray() + ?? new[] { ProjectionSource(key.Value, descriptor.SchemaId, "CultCache record") }; + var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache document"); + return Document( + ResolveDocumentId(documentId, key), + context, + _ => Task.FromResult(ReadRequired(cache, key)), + _ => cache.WatchRecord(key) + .Where(change => change.Document != null) + .Select(change => change.Document!), + async value => + { + await cache.UpsertAsync(value, new CultRecordHandle(key)).ConfigureAwait(false); + }, + sourceList, + route); + } + + /// + /// Creates a typed document handle directly over one CultCache record. + /// + public static CultMeshDocumentHandle Document( + CultCache cache, + CultRecordKey key, + CultMeshVerse verse, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Document(cache, key, verse.Context, documentId, sources, routeHint); + } + + /// + /// Creates a typed document handle directly over one CultCache record with a local Verse context. + /// + public static CultMeshDocumentHandle Document( + CultCache cache, + CultRecordKey key, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + return Document( + cache, + key, + Verse("local", "local", routeHint).Context, + documentId, + sources, + routeHint); + } + + /// + /// Creates a typed document handle directly over one distributed CultNet database record. + /// + public static CultMeshDocumentHandle Document( + CultNetDatabase database, + CultRecordKey key, + CultMeshVerseContext context, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (database == null) throw new ArgumentNullException(nameof(database)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var sourceList = sources?.ToArray() + ?? new[] { ProjectionSource(key.Value, descriptor.SchemaId, "CultNet database record") }; + var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Automatic, "CultNet database document"); + return Document( + ResolveDocumentId(documentId, key), + context, + async _ => await ReadRequiredAsync(database, key).ConfigureAwait(false), + _ => database.WatchRecord(key) + .Where(change => change.Document != null) + .Select(change => change.Document!), + async value => + { + await database.PutAsync(key, value).ConfigureAwait(false); + }, + sourceList, + route); + } + + /// + /// Creates a typed document handle directly over one distributed CultNet database record. + /// + public static CultMeshDocumentHandle Document( + CultNetDatabase database, + CultRecordKey key, + CultMeshVerse verse, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Document(database, key, verse.Context, documentId, sources, routeHint); + } + + /// + /// Creates a typed document handle directly over one CultMesh node database record. + /// + public static CultMeshDocumentHandle Document( + CultMeshNode node, + CultRecordKey key, + CultMeshVerseContext context, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + return Document(node.Database, key, context, documentId, sources, routeHint); + } + + /// + /// Creates a typed document handle directly over one CultMesh node database record. + /// + public static CultMeshDocumentHandle Document( + CultMeshNode node, + CultRecordKey key, + CultMeshVerse verse, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Document(node, key, verse.Context, documentId, sources, routeHint); + } + /// /// Describes a typed live feed surface for tooling, UI, and diagnostics. /// @@ -1395,5 +1615,31 @@ namespace GameCult.Mesh return Dns.GetHostAddresses(host).FirstOrDefault() ?? throw new InvalidOperationException($"Could not resolve RUDP host {host}."); } + + private static string ResolveDocumentId(string? documentId, CultRecordKey key) + { + return string.IsNullOrWhiteSpace(documentId) + ? key.Value + : documentId!; + } + + private static TDocument ReadRequired(CultCache cache, CultRecordKey key) + where TDocument : class + { + return cache.Get(key) + ?? throw new KeyNotFoundException( + $"CultMesh document '{key.Value}' was not found as {typeof(TDocument).FullName}."); + } + + private static async Task ReadRequiredAsync( + CultNetDatabase database, + CultRecordKey key) + where TDocument : class + { + var document = await database.GetAsync(key).ConfigureAwait(false); + return document + ?? throw new KeyNotFoundException( + $"CultMesh document '{key.Value}' was not found as {typeof(TDocument).FullName}."); + } } } diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 49906d1c..70c451ea 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -897,6 +897,9 @@ namespace GameCult.Mesh /// Gets typed state sources this document handle depends on, when known. IReadOnlyList Sources { get; } + /// Gets whether this handle can replace the underlying document value. + bool CanReplace { get; } + /// Creates a same-schema alias presentation for another CLR document type. CultMeshDocumentHandle AsSchemaAlias() where TAlias : class; } @@ -911,11 +914,15 @@ namespace GameCult.Mesh CultDocumentRegistry.Shared.GetRequired(); private readonly CultMeshBoundLiveFeed _feed; + private readonly Func? _replace; /// Creates a document handle from a Verse-bound live feed. - public CultMeshDocumentHandle(CultMeshBoundLiveFeed feed) + public CultMeshDocumentHandle( + CultMeshBoundLiveFeed feed, + Func? replace = null) { _feed = feed ?? throw new ArgumentNullException(nameof(feed)); + _replace = replace; } /// Gets the semantic document id. @@ -945,6 +952,9 @@ namespace GameCult.Mesh /// Gets the underlying live feed used by this document handle. public CultMeshLiveFeed Feed => _feed.Feed; + /// Gets whether this handle can replace the underlying document value. + public bool CanReplace => _replace != null; + /// Reads one coherent document snapshot. public Task LatestAsync() { @@ -964,6 +974,18 @@ namespace GameCult.Mesh return Watch().Subscribe(onNext); } + /// Replaces the underlying document when this handle is backed by mutable state. + public Task ReplaceAsync(TDocument value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (_replace == null) + { + throw new NotSupportedException($"Document handle '{DocumentId}' is read-only."); + } + + return _replace(value); + } + /// Creates a same-schema alias presentation for another CLR document type. public CultMeshDocumentHandle AsSchemaAlias() where TAlias : class { @@ -979,22 +1001,33 @@ namespace GameCult.Mesh var aliasFeed = new CultMeshLiveFeed( DocumentId, - async (parameters, context) => ConvertDocument( + async (parameters, context) => ConvertDocument( await Feed.SnapshotAsync(parameters, context).ConfigureAwait(false)), (parameters, context) => Feed .Watch(parameters, context) - .Select(ConvertDocument), + .Select(ConvertDocument), Sources, RouteHint); - return new CultMeshDocumentHandle(aliasFeed.Bind(Context)); + Func? replace = _replace == null + ? null + : value => _replace(ConvertDocument(value)); + + return new CultMeshDocumentHandle(aliasFeed.Bind(Context), replace); } - private static TAlias ConvertDocument(TDocument document) where TAlias : class + private static TTarget ConvertDocument(TSource document) + where TSource : class + where TTarget : class { if (document == null) throw new ArgumentNullException(nameof(document)); - var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)); - return (TAlias)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TAlias), payload); + if (document is TTarget alreadyTyped) + { + return alreadyTyped; + } + + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TSource)); + return (TTarget)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TTarget), payload); } } diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 30044d6e..507b82d7 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -162,11 +162,14 @@ portable machinery that makes it feel native. `CultMeshVerse.BindLiveFeed(...)`. - `CultMeshDocumentHandle` is the typed reactive document edge for CultCache/CultNet state. It exposes document metadata, one coherent - `LatestAsync()` read, and a `Watch()` stream without forcing callers through - transport or projection layers. Same-schema CLR aliases can be requested with - `AsSchemaAlias()`; CultMesh verifies the shared + `LatestAsync()` read, a `Watch()` stream, and `ReplaceAsync(...)` when the + handle is backed by mutable state. Callers can bind a projected live feed, a + local `CultCache` record, a distributed `CultNetDatabase` record, or a + `CultMeshNode` record through the same surface instead of walking transport + and projection layers themselves. Same-schema CLR aliases can be requested + with `AsSchemaAlias()`; CultMesh verifies the shared `[CultDocument(schemaName, schemaVersion)]` identity and uses the shared - CultCache MessagePack codec for conversion. + CultCache MessagePack codec for read/watch/replace conversion. - `CultMeshProjectionRecipe` names a reusable projection from typed source state into derived state. It records source handles, route hints, and projection execution, and can be exposed as a typed query @@ -216,6 +219,22 @@ await movePilot.InvokeAsync(moveRequest, idempotencyKey); A single typed document can be surfaced the same way: +```csharp +var stationStock = CultMesh.Document( + node, + new CultRecordKey("station:starbridge:stock"), + verse); + +var stock = await stationStock.LatestAsync(); +using var watch = stationStock.Watch(RenderStock); + +var uiAlias = stationStock.AsSchemaAlias(); +await uiAlias.ReplaceAsync(updatedStockFromUi); +``` + +Projected or derived documents can use the same handle shape with explicit +snapshot/watch delegates: + ```csharp var cockpit = CultMesh.Document( "aetheria.ship.cockpit.current", diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 7985144a..1324561c 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using FluentAssertions; using GameCult.Caching; +using GameCult.Networking; using MessagePack; using NUnit.Framework; using R3; @@ -697,6 +698,92 @@ public sealed class CultMeshStreamingTests .WithMessage("*tests.mesh_other*tests.mesh_note*"); } + [Test] + public async Task DocumentHandle_ReadsWatchesAndReplacesCultCacheRecords() + { + var cache = new CultCache(); + var key = new CultRecordKey("mesh-note:cache"); + await cache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "cache-initial", + Revision = 1 + }, new CultRecordHandle(key)); + + var handle = CultMesh.Document( + cache, + key, + CultMesh.Verse("starbridge", "unity-pilot")); + var alias = handle.AsSchemaAlias(); + + handle.CanReplace.Should().BeTrue(); + alias.CanReplace.Should().BeTrue(); + handle.DocumentId.Should().Be(key.Value); + handle.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(handle.SchemaId); + + var snapshot = await alias.LatestAsync(); + snapshot.Text.Should().Be("cache-initial"); + + MeshNoteAliasDocument observed = null!; + using var subscription = alias.Watch(value => observed = value); + await alias.ReplaceAsync(new MeshNoteAliasDocument + { + Schema = "tests.mesh_note.v1", + Text = "cache-replaced", + Revision = 2 + }); + + cache.Get(key)!.Text.Should().Be("cache-replaced"); + observed.Text.Should().Be("cache-replaced"); + observed.Revision.Should().Be(2); + } + + [Test] + public async Task DocumentHandle_ReplacesThroughCultMeshNodeDatabase() + { + using var directory = new TemporaryDirectory(); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(directory.Path, "mesh-node.cc"), + new CultMeshNodeOptions + { + DatabaseOptions = new CultNetDatabaseOptions + { + RuntimeId = "node-runtime", + Shards = new[] + { + new CultNetShardDescriptor( + "mesh", + "node-runtime", + epoch: 1, + isPrimary: true, + schemaIds: new[] { CultDocumentRegistry.Shared.GetRequired().SchemaId }) + } + }, + StartServer = false + }); + var key = new CultRecordKey("mesh-note:node"); + + var handle = CultMesh.Document( + node, + key, + CultMesh.Verse("starbridge", "unity-pilot")); + var alias = handle.AsSchemaAlias(); + + MeshNoteDocument observed = null!; + using var subscription = handle.Watch(value => observed = value); + await alias.ReplaceAsync(new MeshNoteAliasDocument + { + Schema = "tests.mesh_note.v1", + Text = "node-replaced", + Revision = 3 + }); + + var snapshot = await handle.LatestAsync(); + snapshot.Text.Should().Be("node-replaced"); + observed.Text.Should().Be("node-replaced"); + observed.Revision.Should().Be(3); + } + [Test] public void SurfaceCatalog_DescribesTypedRuntimeSurfaces() { @@ -1039,6 +1126,27 @@ public sealed class CultMeshStreamingTests return catalog; } + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "cultmesh-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } + [CultDocument("tests.mesh_managed_player", "tests.mesh_managed_player.v1")] private sealed class MeshManagedPlayer { From df079f2cab5e9e5ff31fe16b46560ba41f65b637 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 15:01:23 +0100 Subject: [PATCH 015/498] Add CultMesh document catalogs --- src/GameCult.Mesh/CultMesh.cs | 16 +++ src/GameCult.Mesh/CultMeshPrimitives.cs | 124 ++++++++++++++++++ src/GameCult.Mesh/docs/public-api.md | 19 +++ .../CultMeshStreamingTests.cs | 33 +++++ 4 files changed, 192 insertions(+) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index cfde04af..7742b8ac 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1120,6 +1120,22 @@ namespace GameCult.Mesh return Document(node, key, verse.Context, documentId, sources, routeHint); } + /// + /// Creates a schema-aware catalog over typed document handles. + /// + public static CultMeshDocumentCatalog Documents(IEnumerable documents) + { + return new CultMeshDocumentCatalog(documents); + } + + /// + /// Creates a schema-aware catalog over typed document handles. + /// + public static CultMeshDocumentCatalog Documents(params ICultMeshDocumentHandle[] documents) + { + return new CultMeshDocumentCatalog(documents); + } + /// /// Describes a typed live feed surface for tooling, UI, and diagnostics. /// diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 70c451ea..22624d86 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1031,6 +1031,130 @@ namespace GameCult.Mesh } } + /// + /// Schema-aware lookup table for typed CultMesh document handles. + /// + public sealed class CultMeshDocumentCatalog + { + private readonly IReadOnlyList _documents; + private readonly IReadOnlyDictionary _documentsByType; + private readonly IReadOnlyDictionary _documentsBySchema; + + /// Creates a document catalog from known handles. + public CultMeshDocumentCatalog(IEnumerable documents) + { + if (documents == null) throw new ArgumentNullException(nameof(documents)); + + var documentList = documents + .Where(document => document != null) + .ToArray(); + var byType = new Dictionary(); + var bySchema = new Dictionary(StringComparer.Ordinal); + foreach (var document in documentList) + { + byType[document.DocumentType] = document; + if (!string.IsNullOrWhiteSpace(document.SchemaVersion)) + bySchema[document.SchemaVersion] = document; + if (!string.IsNullOrWhiteSpace(document.SchemaName)) + bySchema[document.SchemaName] = document; + } + + _documents = documentList; + _documentsByType = byType; + _documentsBySchema = bySchema; + } + + /// Gets the handles in this catalog. + public IReadOnlyList Documents => _documents; + + /// Looks up a document handle by shared schema name or schema version. + public bool TryGetDocumentBySchema( + string schema, + out ICultMeshDocumentHandle document) + { + if (!string.IsNullOrWhiteSpace(schema) && + _documentsBySchema.TryGetValue(schema, out document!)) + { + return true; + } + + document = null!; + return false; + } + + /// Looks up a document handle by shared schema name or schema version. + public ICultMeshDocumentHandle DocumentBySchema(string schema) + { + if (TryGetDocumentBySchema(schema, out var document)) + return document; + + throw new NotSupportedException( + $"CultMesh document catalog does not expose a document for schema '{schema}'."); + } + + /// Looks up a typed document handle by CLR type or same-schema alias. + public bool TryGetDocument( + out CultMeshDocumentHandle document) + where TDocument : class + { + if (_documentsByType.TryGetValue(typeof(TDocument), out var untypedDocument) && + untypedDocument is CultMeshDocumentHandle typedDocument) + { + document = typedDocument; + return true; + } + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + if (_documentsBySchema.TryGetValue(descriptor.SchemaVersion, out var schemaDocument) || + _documentsBySchema.TryGetValue(descriptor.SchemaName, out schemaDocument!)) + { + if (schemaDocument is CultMeshDocumentHandle schemaTypedDocument) + { + document = schemaTypedDocument; + return true; + } + + document = schemaDocument.AsSchemaAlias(); + return true; + } + + document = null!; + return false; + } + + /// Looks up a typed document handle by CLR type or same-schema alias. + public CultMeshDocumentHandle Document() + where TDocument : class + { + if (TryGetDocument(out var document)) + return document; + + throw new NotSupportedException( + $"CultMesh document catalog does not expose a document for {typeof(TDocument).FullName}."); + } + + /// Reads one typed document snapshot by CLR type or same-schema alias. + public Task LatestAsync() + where TDocument : class + { + return Document().LatestAsync(); + } + + /// Watches one typed document by CLR type or same-schema alias. + public Observable Watch() + where TDocument : class + { + return Document().Watch(); + } + + /// Subscribes to one typed document by CLR type or same-schema alias. + public IDisposable Watch(Action onNext) + where TDocument : class + { + return Document().Watch(onNext); + } + } + /// /// Inspectable metadata for a typed live feed surface. /// diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 507b82d7..e789f8d6 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -170,6 +170,12 @@ portable machinery that makes it feel native. with `AsSchemaAlias()`; CultMesh verifies the shared `[CultDocument(schemaName, schemaVersion)]` identity and uses the shared CultCache MessagePack codec for read/watch/replace conversion. +- `CultMeshDocumentCatalog` is the schema-aware lookup edge for a set of + document handles. It indexes handles by CLR document type, schema name, and + schema version, and resolves same-schema CLR aliases by delegating to + `CultMeshDocumentHandle.AsSchemaAlias()`. Domain facades + can expose `Document()`, `DocumentBySchema(...)`, `LatestAsync()`, and + `Watch()` without owning schema dictionaries or alias serializers. - `CultMeshProjectionRecipe` names a reusable projection from typed source state into derived state. It records source handles, route hints, and projection execution, and can be exposed as a typed query @@ -254,6 +260,19 @@ using var subscription = cockpit.Watch(next => RenderCockpit(next)); var daemonAlias = cockpit.AsSchemaAlias(); ``` +A domain facade can collect handles into one schema-aware catalog: + +```csharp +var documents = CultMesh.Documents( + currentDocking, + stationRefit, + zoneContacts); + +var refit = await documents.LatestAsync(); +var uiDocking = documents.Document(); +var bySchema = documents.DocumentBySchema("gamecult.aetheria.station_refit.v1"); +``` + ```ts const queryVerse = verse.withRoute("shared-memory", statePath); const commandVerse = verse diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 1324561c..eb902889 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -698,6 +698,39 @@ public sealed class CultMeshStreamingTests .WithMessage("*tests.mesh_other*tests.mesh_note*"); } + [Test] + public async Task DocumentCatalog_IndexesHandlesByTypeAndSchemaAlias() + { + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "catalog-primary", + Revision = 3 + }; + var handle = CultMesh.Document( + "mesh.note.current", + CultMesh.Verse("starbridge", "unity-pilot"), + _ => Task.FromResult(current), + _ => new Subject()); + var catalog = CultMesh.Documents(handle); + + catalog.Documents.Should().ContainSingle().Which.Should().BeSameAs(handle); + catalog.TryGetDocument(out var exact).Should().BeTrue(); + exact.Should().BeSameAs(handle); + catalog.TryGetDocumentBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue(); + byVersion.Should().BeSameAs(handle); + catalog.DocumentBySchema("tests.mesh_note").Should().BeSameAs(handle); + + catalog.TryGetDocument(out var alias).Should().BeTrue(); + alias.DocumentId.Should().Be(handle.DocumentId); + alias.SchemaName.Should().Be(handle.SchemaName); + alias.SchemaVersion.Should().Be(handle.SchemaVersion); + + var aliasSnapshot = await catalog.LatestAsync(); + aliasSnapshot.Text.Should().Be("catalog-primary"); + aliasSnapshot.Revision.Should().Be(3); + } + [Test] public async Task DocumentHandle_ReadsWatchesAndReplacesCultCacheRecords() { From eb52b560880121cddd104f4550cd7785320ca441 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 15:09:36 +0100 Subject: [PATCH 016/498] Add CultMesh collection handles --- src/GameCult.Mesh/CultMesh.cs | 289 ++++++++++++++++++ src/GameCult.Mesh/CultMeshPrimitives.cs | 176 +++++++++++ src/GameCult.Mesh/docs/public-api.md | 21 ++ src/GameCult.Networking/CultNetDatabase.cs | 27 ++ .../CultMeshStreamingTests.cs | 80 +++++ 5 files changed, 593 insertions(+) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 7742b8ac..37b8b1bd 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1136,6 +1136,215 @@ namespace GameCult.Mesh return new CultMeshDocumentCatalog(documents); } + /// + /// Creates a typed collection handle over all local CultCache records assignable to the document type. + /// + public static CultMeshCollectionHandle Collection( + CultCache cache, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + + var descriptor = cache.Registry.GetRequired(); + var sourceList = sources?.ToArray() + ?? new[] { ProjectionSource(CollectionId(collectionId, descriptor), descriptor.SchemaId, "CultCache collection") }; + var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache collection"); + return new CultMeshCollectionHandle( + CollectionId(collectionId, descriptor), + () => Task.FromResult>(cache.GetAll().ToArray()), + () => cache.Watch().Select(change => ToCollectionChange(change, descriptor.SchemaId)), + sourceList, + route); + } + + /// + /// Creates a typed collection handle over the local CultCache record with the supplied CultName. + /// + public static CultMeshCollectionHandle CollectionByName( + CultCache cache, + string name, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name)); + + var descriptor = cache.Registry.GetRequired(); + var resolvedId = collectionId ?? $"{descriptor.SchemaId}:name:{name}"; + var sourceList = sources?.ToArray() + ?? new[] { ProjectionSource(resolvedId, descriptor.SchemaId, "CultCache named collection") }; + var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache named collection"); + return new CultMeshCollectionHandle( + resolvedId, + () => Task.FromResult>(Optional(cache.GetByName(name))), + () => cache.Watch() + .Where(change => MatchesName(cache, name, change)) + .Select(change => ToCollectionChange(change, descriptor.SchemaId)), + sourceList, + route); + } + + /// + /// Creates a typed collection handle over local CultCache records matched by an indexed value. + /// + public static CultMeshCollectionHandle CollectionByIndex( + CultCache cache, + string alias, + string value, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value must be non-empty.", nameof(alias)); + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value must be non-empty.", nameof(value)); + + var descriptor = cache.Registry.GetRequired(); + var resolvedId = collectionId ?? $"{descriptor.SchemaId}:index:{alias}:{value}"; + var sourceList = sources?.ToArray() + ?? new[] { ProjectionSource(resolvedId, descriptor.SchemaId, "CultCache indexed collection") }; + var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache indexed collection"); + return new CultMeshCollectionHandle( + resolvedId, + () => Task.FromResult>(Optional(cache.GetByIndex(alias, value))), + () => cache.Watch() + .Where(change => MatchesIndex(cache, alias, value, change)) + .Select(change => ToCollectionChange(change, descriptor.SchemaId)), + sourceList, + route); + } + + /// + /// Creates a typed collection handle over all distributed CultNet database records assignable to the document type. + /// + public static CultMeshCollectionHandle Collection( + CultNetDatabase database, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (database == null) throw new ArgumentNullException(nameof(database)); + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var sourceList = sources?.ToArray() + ?? new[] { ProjectionSource(CollectionId(collectionId, descriptor), descriptor.SchemaId, "CultNet database collection") }; + var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Automatic, "CultNet database collection"); + return new CultMeshCollectionHandle( + CollectionId(collectionId, descriptor), + () => Task.FromResult>(database.GetAll().ToArray()), + () => database.Watch().Select(ToCollectionChange), + sourceList, + route); + } + + /// + /// Creates a typed collection handle over the distributed CultNet database record with the supplied CultName. + /// + public static CultMeshCollectionHandle CollectionByName( + CultNetDatabase database, + string name, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (database == null) throw new ArgumentNullException(nameof(database)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name)); + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var resolvedId = collectionId ?? $"{descriptor.SchemaId}:name:{name}"; + var sourceList = sources?.ToArray() + ?? new[] { ProjectionSource(resolvedId, descriptor.SchemaId, "CultNet database named collection") }; + var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Automatic, "CultNet database named collection"); + return new CultMeshCollectionHandle( + resolvedId, + () => Task.FromResult>(Optional(database.GetByName(name))), + () => database.WatchByName(name).Select(ToCollectionChange), + sourceList, + route); + } + + /// + /// Creates a typed collection handle over distributed CultNet database records matched by an indexed value. + /// + public static CultMeshCollectionHandle CollectionByIndex( + CultNetDatabase database, + string alias, + string value, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (database == null) throw new ArgumentNullException(nameof(database)); + if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value must be non-empty.", nameof(alias)); + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value must be non-empty.", nameof(value)); + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var resolvedId = collectionId ?? $"{descriptor.SchemaId}:index:{alias}:{value}"; + var sourceList = sources?.ToArray() + ?? new[] { ProjectionSource(resolvedId, descriptor.SchemaId, "CultNet database indexed collection") }; + var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Automatic, "CultNet database indexed collection"); + return new CultMeshCollectionHandle( + resolvedId, + () => Task.FromResult>(Optional(database.GetByIndex(alias, value))), + () => database.WatchByIndex(alias, value).Select(ToCollectionChange), + sourceList, + route); + } + + /// + /// Creates a typed collection handle over all CultMesh node database records assignable to the document type. + /// + public static CultMeshCollectionHandle Collection( + CultMeshNode node, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + return Collection(node.Database, collectionId, sources, routeHint); + } + + /// + /// Creates a typed collection handle over the CultMesh node database record with the supplied CultName. + /// + public static CultMeshCollectionHandle CollectionByName( + CultMeshNode node, + string name, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + return CollectionByName(node.Database, name, collectionId, sources, routeHint); + } + + /// + /// Creates a typed collection handle over CultMesh node database records matched by an indexed value. + /// + public static CultMeshCollectionHandle CollectionByIndex( + CultMeshNode node, + string alias, + string value, + string? collectionId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + return CollectionByIndex(node.Database, alias, value, collectionId, sources, routeHint); + } + /// /// Describes a typed live feed surface for tooling, UI, and diagnostics. /// @@ -1639,6 +1848,86 @@ namespace GameCult.Mesh : documentId!; } + private static string CollectionId(string? collectionId, CultDocumentDescriptor descriptor) + { + return string.IsNullOrWhiteSpace(collectionId) + ? descriptor.SchemaId + : collectionId!; + } + + private static IReadOnlyList Optional(TDocument? document) + where TDocument : class + { + return document == null + ? Array.Empty() + : new[] { document }; + } + + private static bool MatchesName( + CultCache cache, + string name, + CultCacheDocumentChange change) + where TDocument : class + { + var current = cache.GetByName(name); + return (change.Document != null && ReferenceEquals(current, change.Document)) || + (change.PreviousDocument != null && ReferenceEquals(current, change.PreviousDocument)); + } + + private static bool MatchesIndex( + CultCache cache, + string alias, + string value, + CultCacheDocumentChange change) + where TDocument : class + { + var current = cache.GetByIndex(alias, value); + return (change.Document != null && ReferenceEquals(current, change.Document)) || + (change.PreviousDocument != null && ReferenceEquals(current, change.PreviousDocument)); + } + + private static CultMeshCollectionChange ToCollectionChange( + CultCacheDocumentChange change, + string schemaId) + where TDocument : class + { + return new CultMeshCollectionChange( + change.Kind switch + { + CultCacheDocumentChangeKind.Added => CultMeshCollectionChangeKind.Added, + CultCacheDocumentChangeKind.Updated => CultMeshCollectionChangeKind.Updated, + CultCacheDocumentChangeKind.Removed => CultMeshCollectionChangeKind.Removed, + _ => CultMeshCollectionChangeKind.Updated + }, + change.Key, + schemaId, + change.Document, + change.PreviousDocument); + } + + private static CultMeshCollectionChange ToCollectionChange( + CultNetDatabaseChange change) + where TDocument : class + { + return new CultMeshCollectionChange( + change.Kind switch + { + CultNetDatabaseChangeKind.Added => CultMeshCollectionChangeKind.Added, + CultNetDatabaseChangeKind.Updated => CultMeshCollectionChangeKind.Updated, + CultNetDatabaseChangeKind.Removed => CultMeshCollectionChangeKind.Removed, + CultNetDatabaseChangeKind.Predicted => CultMeshCollectionChangeKind.Predicted, + CultNetDatabaseChangeKind.Reconciled => CultMeshCollectionChangeKind.Reconciled, + CultNetDatabaseChangeKind.SchemaMigrated => CultMeshCollectionChangeKind.SchemaMigrated, + CultNetDatabaseChangeKind.Rejected => CultMeshCollectionChangeKind.Rejected, + _ => CultMeshCollectionChangeKind.Updated + }, + change.Key, + change.SchemaId, + change.Document, + change.PreviousDocument, + change.Message); + } + private static TDocument ReadRequired(CultCache cache, CultRecordKey key) where TDocument : class { diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 22624d86..5152e738 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1155,6 +1155,182 @@ namespace GameCult.Mesh } } + /// + /// Describes how a CultMesh collection document changed. + /// + public enum CultMeshCollectionChangeKind + { + /// A document was added. + Added, + /// A document was updated. + Updated, + /// A document was removed. + Removed, + /// A local prediction was published before authority accepted it. + Predicted, + /// A predicted document was reconciled with authority. + Reconciled, + /// A document was accepted through schema migration. + SchemaMigrated, + /// A document change was rejected. + Rejected + } + + /// + /// One typed document change observed through a CultMesh collection handle. + /// + public sealed class CultMeshCollectionChange + where TDocument : class + { + /// Creates a collection change. + public CultMeshCollectionChange( + CultMeshCollectionChangeKind kind, + CultRecordKey key, + string schemaId, + TDocument? document, + TDocument? previousDocument, + string? message = null) + { + Kind = kind; + Key = key; + SchemaId = schemaId ?? ""; + Document = document; + PreviousDocument = previousDocument; + Message = message; + } + + /// Gets the change kind. + public CultMeshCollectionChangeKind Kind { get; } + + /// Gets the changed record key. + public CultRecordKey Key { get; } + + /// Gets the schema id of the changed document. + public string SchemaId { get; } + + /// Gets the current document, when present. + public TDocument? Document { get; } + + /// Gets the previous document, when present. + public TDocument? PreviousDocument { get; } + + /// Gets an optional rejection or diagnostic message. + public string? Message { get; } + } + + /// + /// Typed reactive collection handle with schema-aware alias conversion. + /// + public sealed class CultMeshCollectionHandle + where TDocument : class + { + private static readonly CultDocumentDescriptor Descriptor = + CultDocumentRegistry.Shared.GetRequired(); + + private readonly Func>> _latest; + private readonly Func>> _watchChanges; + + /// Creates a collection handle. + public CultMeshCollectionHandle( + string collectionId, + Func>> latest, + Func>> watchChanges, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + { + CollectionId = string.IsNullOrWhiteSpace(collectionId) + ? throw new ArgumentException("Value must be non-empty.", nameof(collectionId)) + : collectionId; + _latest = latest ?? throw new ArgumentNullException(nameof(latest)); + _watchChanges = watchChanges ?? throw new ArgumentNullException(nameof(watchChanges)); + Sources = sources?.ToArray() ?? Array.Empty(); + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; + } + + /// Gets the semantic collection id. + public string CollectionId { get; } + + /// Gets the CLR document type presented by this collection. + public Type DocumentType => typeof(TDocument); + + /// Gets the stable CultCache schema name. + public string SchemaName => Descriptor.SchemaName; + + /// Gets the stable CultCache schema version. + public string SchemaVersion => Descriptor.SchemaVersion; + + /// Gets the content-derived schema identifier. + public string SchemaId => Descriptor.SchemaId; + + /// Gets the preferred or observed route for collection access. + public CultMeshRouteHint RouteHint { get; } + + /// Gets typed state sources this collection handle depends on, when known. + public IReadOnlyList Sources { get; } + + /// Reads one coherent collection snapshot. + public Task> LatestAsync() + { + return _latest(); + } + + /// Watches typed collection changes. + public Observable> WatchChanges() + { + return _watchChanges(); + } + + /// Subscribes to typed collection changes. + public IDisposable WatchChanges(Action> onNext) + { + if (onNext == null) throw new ArgumentNullException(nameof(onNext)); + return WatchChanges().Subscribe(onNext); + } + + /// Creates a same-schema alias presentation for another CLR document type. + public CultMeshCollectionHandle AsSchemaAlias() where TAlias : class + { + var aliasDescriptor = CultDocumentRegistry.Shared.GetRequired(); + if (!string.Equals(Descriptor.SchemaName, aliasDescriptor.SchemaName, StringComparison.Ordinal) || + !string.Equals(Descriptor.SchemaVersion, aliasDescriptor.SchemaVersion, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Document type {typeof(TAlias).FullName} uses schema '{aliasDescriptor.SchemaName}' " + + $"version '{aliasDescriptor.SchemaVersion}', but collection '{CollectionId}' exposes " + + $"schema '{SchemaName}' version '{SchemaVersion}'."); + } + + return new CultMeshCollectionHandle( + CollectionId, + async () => (await LatestAsync().ConfigureAwait(false)) + .Select(ConvertDocument) + .ToArray(), + () => WatchChanges().Select(change => new CultMeshCollectionChange( + change.Kind, + change.Key, + change.SchemaId, + change.Document == null ? null : ConvertDocument(change.Document), + change.PreviousDocument == null ? null : ConvertDocument(change.PreviousDocument), + change.Message)), + Sources, + RouteHint); + } + + private static TTarget ConvertDocument(TSource document) + where TSource : class + where TTarget : class + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (document is TTarget alreadyTyped) + { + return alreadyTyped; + } + + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TSource)); + return (TTarget)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TTarget), payload); + } + } + /// /// Inspectable metadata for a typed live feed surface. /// diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index e789f8d6..3a35d7f8 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -176,6 +176,13 @@ portable machinery that makes it feel native. `CultMeshDocumentHandle.AsSchemaAlias()`. Domain facades can expose `Document()`, `DocumentBySchema(...)`, `LatestAsync()`, and `Watch()` without owning schema dictionaries or alias serializers. +- `CultMeshCollectionHandle` is the typed reactive edge for + multi-record state. It exposes one `LatestAsync()` collection snapshot and a + `WatchChanges()` stream while hiding whether the backing source is an + in-process `CultCache`, a distributed `CultNetDatabase`, or a `CultMeshNode`. + Collection handles can represent all records of a document type, a named + document view, or an indexed value view, and they support the same + same-schema CLR alias conversion as document handles. - `CultMeshProjectionRecipe` names a reusable projection from typed source state into derived state. It records source handles, route hints, and projection execution, and can be exposed as a typed query @@ -273,6 +280,20 @@ var uiDocking = documents.Document(); var bySchema = documents.DocumentBySchema("gamecult.aetheria.station_refit.v1"); ``` +Typed collections use the same one-call style: + +```csharp +var allies = CultMesh.CollectionByIndex( + node, + "Faction", + "au"); + +var currentAllies = await allies.LatestAsync(); +using var changes = allies.WatchChanges(change => UpdateRoster(change)); + +var uiAllies = allies.AsSchemaAlias(); +``` + ```ts const queryVerse = verse.withRoute("shared-memory", statePath); const commandVerse = verse diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 83c34b82..560f80ab 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -686,6 +686,33 @@ namespace GameCult.Networking return Task.FromResult(_cache.Get(key)); } + /// + /// Gets all documents assignable to the requested type. + /// + public IEnumerable GetAll() where T : class + { + ThrowIfDisposed(); + return _cache.GetAll(); + } + + /// + /// Gets a typed document by its CultName value. + /// + public T? GetByName(string name) where T : class + { + ThrowIfDisposed(); + return _cache.GetByName(name); + } + + /// + /// Gets a typed document by an indexed value. + /// + public T? GetByIndex(string alias, string value) where T : class + { + ThrowIfDisposed(); + return _cache.GetByIndex(alias, value); + } + /// /// Opens a reactive POCO presentation for one distributed CultCache document. /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index eb902889..d3229bdb 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -771,6 +771,71 @@ public sealed class CultMeshStreamingTests observed.Revision.Should().Be(2); } + [Test] + public async Task CollectionHandle_ReadsAndWatchesDatabaseCollectionsByIndex() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + var teamId = Guid.NewGuid().ToString("D"); + var collection = CultMesh.CollectionByIndex( + database, + "TeamId", + teamId); + var changes = new List>(); + using var subscription = collection.WatchChanges(change => changes.Add(change)); + + await database.PutAsync( + new CultRecordKey("player:one"), + new MeshIndexedPlayer + { + Name = "One", + TeamId = teamId, + Score = 10 + }); + await database.PutAsync( + new CultRecordKey("player:two"), + new MeshIndexedPlayer + { + Name = "Two", + TeamId = Guid.NewGuid().ToString("D"), + Score = 20 + }); + + collection.CollectionId.Should().Contain(teamId); + collection.SchemaName.Should().Be("tests.mesh_indexed_player"); + collection.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic); + collection.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(collection.SchemaId); + + var snapshot = await collection.LatestAsync(); + snapshot.Should().ContainSingle().Which.Name.Should().Be("One"); + changes.Should().ContainSingle(); + changes[0].Kind.Should().Be(CultMeshCollectionChangeKind.Added); + changes[0].Document!.Name.Should().Be("One"); + } + + [Test] + public async Task CollectionHandle_SupportsSameSchemaAliases() + { + var cache = new CultCache(); + await cache.UpsertAsync( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "collection-alias", + Revision = 4 + }, + new CultRecordHandle(new CultRecordKey("mesh-note:collection"))); + + var collection = CultMesh.Collection(cache); + var alias = collection.AsSchemaAlias(); + + alias.CollectionId.Should().Be(collection.CollectionId); + alias.SchemaName.Should().Be(collection.SchemaName); + + var snapshot = await alias.LatestAsync(); + snapshot.Should().ContainSingle().Which.Text.Should().Be("collection-alias"); + } + [Test] public async Task DocumentHandle_ReplacesThroughCultMeshNodeDatabase() { @@ -1193,6 +1258,21 @@ public sealed class CultMeshStreamingTests public int Health; } + [CultDocument("tests.mesh_indexed_player", "tests.mesh_indexed_player.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class MeshIndexedPlayer + { + [Key(0)] + public string Name { get; set; } = string.Empty; + + [Key(1)] + [CultIndex] + public string TeamId { get; set; } = string.Empty; + + [Key(2)] + public int Score { get; set; } + } + [CultDocument("tests.mesh_note", "tests.mesh_note.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class MeshNoteDocument From a17e10d198c686b9f31d7ec377489e5ea6c89889 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 15:20:09 +0100 Subject: [PATCH 017/498] Add CultMesh TS document handles --- packages/cultmesh-ts/src/index.ts | 780 +++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 93 +++ src/GameCult.Mesh/docs/public-api.md | 32 + 3 files changed, 905 insertions(+) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index b87167d8..f2505358 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -99,6 +99,38 @@ export interface CultMeshQueryContext { export type CultMeshUnsubscribe = () => void; +export type CultMeshDocumentWatcher = ( + context: CultMeshQueryContext, + callback: (value: TDocument) => void, +) => CultMeshUnsubscribe; + +export type CultMeshDocumentReplacer = ( + context: CultMeshQueryContext, + value: TDocument, +) => Promise; + +export type CultMeshCollectionSnapshot = readonly TDocument[]; + +export type CultMeshCollectionChangeKind = "added" | "updated" | "removed" | "reset"; + +export interface CultMeshCollectionChange { + readonly kind: CultMeshCollectionChangeKind; + readonly key?: string; + readonly value?: TDocument; +} + +export type CultMeshCollectionWatcher = ( + context: CultMeshQueryContext, + callback: (change: CultMeshCollectionChange) => void, +) => CultMeshUnsubscribe; + +export interface CultMeshDocumentSchemaDescriptor { + readonly type?: string; + readonly schemaId?: string; + readonly schemaName?: string; + readonly schemaVersion?: string; +} + export type CultMeshStatePointerResolver = ( context: CultMeshQueryContext, ) => Promise; @@ -406,6 +438,18 @@ export class CultMeshVerse { return cultMeshBindLiveFeed(this.context, feed); } + public bindDocument( + document: CultMeshDocumentHandle, + ): CultMeshBoundDocumentHandle { + return cultMeshBindDocument(this.context, document); + } + + public bindCollection( + collection: CultMeshCollectionHandle, + ): CultMeshBoundCollectionHandle { + return cultMeshBindCollection(this.context, collection); + } + public bindStatePointer( pointer: CultMeshStatePointer, ): CultMeshBoundStatePointer { @@ -660,6 +704,401 @@ export class CultMeshBoundLiveFeed { } } +export class CultMeshDocumentHandle { + public readonly schema: CultMeshDocumentSchemaDescriptor; + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly documentId: string, + schema: CultMeshDocumentSchemaDescriptor, + private readonly snapshotDocument: (context: CultMeshQueryContext) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchDocument?: CultMeshDocumentWatcher; + replaceDocument?: CultMeshDocumentReplacer; + } = {}, + ) { + requireNonEmpty(documentId, "documentId"); + this.schema = normalizeCultMeshDocumentSchema(schema); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchDocument = options.watchDocument; + this.replaceDocument = options.replaceDocument; + } + + private readonly watchDocument: CultMeshDocumentWatcher | undefined; + private readonly replaceDocument: CultMeshDocumentReplacer | undefined; + + public get canReplace(): boolean { + return this.replaceDocument !== undefined; + } + + public latest(context: CultMeshQueryContext | string = "local"): Promise { + return this.snapshotDocument( + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + ); + } + + public read(context: CultMeshQueryContext | string = "local"): Promise { + return this.latest(context); + } + + public watch(callback: (value: TDocument) => void): CultMeshUnsubscribe; + public watch(context: CultMeshQueryContext | string, callback: (value: TDocument) => void): CultMeshUnsubscribe; + public watch( + contextOrCallback: CultMeshQueryContext | string | ((value: TDocument) => void), + maybeCallback?: (value: TDocument) => void, + ): CultMeshUnsubscribe { + if (!this.watchDocument) { + throw new Error(`Document '${this.documentId}' does not support watches.`); + } + + const callback = + typeof contextOrCallback === "function" ? contextOrCallback : maybeCallback; + if (!callback) { + throw new Error(`Document '${this.documentId}' requires a watch callback.`); + } + + const context = + typeof contextOrCallback === "function" + ? cultMeshQueryContext("local") + : typeof contextOrCallback === "string" + ? cultMeshQueryContext(contextOrCallback) + : contextOrCallback; + + return this.watchDocument(this.resolveContext(context), callback); + } + + public replace(value: TDocument): Promise; + public replace(context: CultMeshQueryContext | string, value: TDocument): Promise; + public replace( + contextOrValue: CultMeshQueryContext | string | TDocument, + maybeValue?: TDocument, + ): Promise { + if (!this.replaceDocument) { + throw new Error(`Document '${this.documentId}' does not support replacement.`); + } + + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? typeof contextOrValue === "string" + ? cultMeshQueryContext(contextOrValue) + : contextOrValue as CultMeshQueryContext + : cultMeshQueryContext("local"); + const value = hasContext ? maybeValue : contextOrValue as TDocument; + if (value === undefined) { + throw new Error(`Document '${this.documentId}' requires a replacement value.`); + } + + return this.replaceDocument(this.resolveContext(context), value); + } + + public asSchemaAlias( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TAlias } = {}, + ): CultMeshDocumentHandle { + const aliasSchema = normalizeCultMeshDocumentSchema(schema); + if (!cultMeshSchemasAreCompatible(this.schema, aliasSchema)) { + throw new Error( + `Document '${this.documentId}' schema ${cultMeshSchemaLabel(this.schema)} is not compatible with alias ${cultMeshSchemaLabel(aliasSchema)}.`, + ); + } + + const parse = options.parse ?? ((value: unknown) => value as TAlias); + return new CultMeshDocumentHandle( + this.documentId, + aliasSchema, + async (context) => parse(await this.latest(context)), + { + sources: this.sources, + routeHint: this.routeHint, + watchDocument: this.watchDocument + ? (context, callback) => this.watch(context, value => callback(parse(value))) + : undefined, + replaceDocument: this.replaceDocument + ? (context, value) => this.replace(context, value as unknown as TDocument) + : undefined, + }, + ); + } + + public bind(verse: CultMeshVerseContext | CultMeshVerse): CultMeshBoundDocumentHandle { + return cultMeshBindDocument(verse, this); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + + return cultMeshQueryContext(context.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshBoundDocumentHandle { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly document: CultMeshDocumentHandle, + ) {} + + public get documentId(): string { + return this.document.documentId; + } + + public get schema(): CultMeshDocumentSchemaDescriptor { + return this.document.schema; + } + + public get canReplace(): boolean { + return this.document.canReplace; + } + + public latest(): Promise { + return this.document.latest(cultMeshQueryContextFromVerse(this.verse)); + } + + public read(): Promise { + return this.latest(); + } + + public watch(callback: (value: TDocument) => void): CultMeshUnsubscribe { + return this.document.watch(cultMeshQueryContextFromVerse(this.verse), callback); + } + + public replace(value: TDocument): Promise { + return this.document.replace(cultMeshQueryContextFromVerse(this.verse), value); + } + + public asSchemaAlias( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TAlias } = {}, + ): CultMeshBoundDocumentHandle { + return cultMeshBindDocument(this.verse, this.document.asSchemaAlias(schema, options)); + } +} + +export class CultMeshDocumentCatalog { + readonly #byDocumentId = new Map>(); + readonly #byType = new Map>(); + readonly #bySchemaId = new Map>(); + readonly #bySchemaNameVersion = new Map>(); + + public constructor(documents: Iterable>) { + for (const document of documents) { + this.add(document); + } + } + + public get documents(): readonly CultMeshDocumentHandle[] { + return [...this.#byDocumentId.values()]; + } + + public add(document: CultMeshDocumentHandle): this { + this.#byDocumentId.set(document.documentId, document as CultMeshDocumentHandle); + if (document.schema.type) { + this.#byType.set(document.schema.type, document as CultMeshDocumentHandle); + } + if (document.schema.schemaId) { + this.#bySchemaId.set(document.schema.schemaId, document as CultMeshDocumentHandle); + } + const key = cultMeshSchemaNameVersionKey(document.schema); + if (key) { + this.#bySchemaNameVersion.set(key, document as CultMeshDocumentHandle); + } + return this; + } + + public tryDocument( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument } = {}, + ): CultMeshDocumentHandle | undefined { + const descriptor = normalizeCultMeshDocumentSchema(schema); + const exact = + (descriptor.type ? this.#byType.get(descriptor.type) : undefined) ?? + (descriptor.schemaId ? this.#bySchemaId.get(descriptor.schemaId) : undefined) ?? + (() => { + const key = cultMeshSchemaNameVersionKey(descriptor); + return key ? this.#bySchemaNameVersion.get(key) : undefined; + })(); + + if (!exact) { + return undefined; + } + + return cultMeshSchemasAreCompatible(exact.schema, descriptor) + ? exact.asSchemaAlias(descriptor, options) + : undefined; + } + + public document( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument } = {}, + ): CultMeshDocumentHandle { + const document = this.tryDocument(schema, options); + if (!document) { + throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`); + } + return document; + } + + public latest( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string = "local", + options: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + return this.document(schema, options).latest(context); + } + + public watch( + schema: CultMeshDocumentSchemaDescriptor, + callback: (value: TDocument) => void, + options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, + ): CultMeshUnsubscribe { + return this.document(schema, options).watch(options.context ?? "local", callback); + } +} + +export class CultMeshCollectionHandle { + public readonly schema: CultMeshDocumentSchemaDescriptor; + public readonly sources: readonly CultMeshProjectionSource[]; + public readonly routeHint: CultMeshRouteHint; + + public constructor( + public readonly collectionId: string, + schema: CultMeshDocumentSchemaDescriptor, + private readonly snapshotCollection: (context: CultMeshQueryContext) => Promise>, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchCollection?: CultMeshCollectionWatcher; + } = {}, + ) { + requireNonEmpty(collectionId, "collectionId"); + this.schema = normalizeCultMeshDocumentSchema(schema); + this.sources = [...(options.sources ?? [])]; + this.routeHint = options.routeHint ?? cultMeshRouteHint(); + this.watchCollection = options.watchCollection; + } + + private readonly watchCollection: CultMeshCollectionWatcher | undefined; + + public latest(context: CultMeshQueryContext | string = "local"): Promise> { + return this.snapshotCollection( + this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), + ); + } + + public watchChanges(callback: (change: CultMeshCollectionChange) => void): CultMeshUnsubscribe; + public watchChanges( + context: CultMeshQueryContext | string, + callback: (change: CultMeshCollectionChange) => void, + ): CultMeshUnsubscribe; + public watchChanges( + contextOrCallback: + | CultMeshQueryContext + | string + | ((change: CultMeshCollectionChange) => void), + maybeCallback?: (change: CultMeshCollectionChange) => void, + ): CultMeshUnsubscribe { + if (!this.watchCollection) { + throw new Error(`Collection '${this.collectionId}' does not support watches.`); + } + + const callback = + typeof contextOrCallback === "function" ? contextOrCallback : maybeCallback; + if (!callback) { + throw new Error(`Collection '${this.collectionId}' requires a watch callback.`); + } + + const context = + typeof contextOrCallback === "function" + ? cultMeshQueryContext("local") + : typeof contextOrCallback === "string" + ? cultMeshQueryContext(contextOrCallback) + : contextOrCallback; + + return this.watchCollection(this.resolveContext(context), callback); + } + + public asSchemaAlias( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TAlias } = {}, + ): CultMeshCollectionHandle { + const aliasSchema = normalizeCultMeshDocumentSchema(schema); + if (!cultMeshSchemasAreCompatible(this.schema, aliasSchema)) { + throw new Error( + `Collection '${this.collectionId}' schema ${cultMeshSchemaLabel(this.schema)} is not compatible with alias ${cultMeshSchemaLabel(aliasSchema)}.`, + ); + } + + const parse = options.parse ?? ((value: unknown) => value as TAlias); + return new CultMeshCollectionHandle( + this.collectionId, + aliasSchema, + async (context) => (await this.latest(context)).map(value => parse(value)), + { + sources: this.sources, + routeHint: this.routeHint, + watchCollection: this.watchCollection + ? (context, callback) => this.watchChanges(context, change => callback({ + ...change, + value: change.value === undefined ? undefined : parse(change.value), + })) + : undefined, + }, + ); + } + + public bind(verse: CultMeshVerseContext | CultMeshVerse): CultMeshBoundCollectionHandle { + return cultMeshBindCollection(verse, this); + } + + private resolveContext(context: CultMeshQueryContext): CultMeshQueryContext { + if (context.routeHint.kind !== "automatic" || this.routeHint.kind === "automatic") { + return context; + } + + return cultMeshQueryContext(context.runtimeId, { + routeHint: this.routeHint, + }); + } +} + +export class CultMeshBoundCollectionHandle { + public constructor( + public readonly verse: CultMeshVerseContext, + public readonly collection: CultMeshCollectionHandle, + ) {} + + public get collectionId(): string { + return this.collection.collectionId; + } + + public get schema(): CultMeshDocumentSchemaDescriptor { + return this.collection.schema; + } + + public latest(): Promise> { + return this.collection.latest(cultMeshQueryContextFromVerse(this.verse)); + } + + public watchChanges(callback: (change: CultMeshCollectionChange) => void): CultMeshUnsubscribe { + return this.collection.watchChanges(cultMeshQueryContextFromVerse(this.verse), callback); + } + + public asSchemaAlias( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TAlias } = {}, + ): CultMeshBoundCollectionHandle { + return cultMeshBindCollection(this.verse, this.collection.asSchemaAlias(schema, options)); + } +} + export class CultMeshStatePointer { public readonly sources: readonly CultMeshProjectionSource[]; public readonly routeHint: CultMeshRouteHint; @@ -1191,6 +1630,20 @@ export function cultMeshBindMutableStatePointer( return new CultMeshBoundMutableStatePointer(resolveCultMeshVerseContext(verse), pointer); } +export function cultMeshBindDocument( + verse: CultMeshVerseContext | CultMeshVerse, + document: CultMeshDocumentHandle, +): CultMeshBoundDocumentHandle { + return new CultMeshBoundDocumentHandle(resolveCultMeshVerseContext(verse), document); +} + +export function cultMeshBindCollection( + verse: CultMeshVerseContext | CultMeshVerse, + collection: CultMeshCollectionHandle, +): CultMeshBoundCollectionHandle { + return new CultMeshBoundCollectionHandle(resolveCultMeshVerseContext(verse), collection); +} + function resolveCultMeshVerseContext(verse: CultMeshVerseContext | CultMeshVerse): CultMeshVerseContext { return verse instanceof CultMeshVerse ? verse.context : verse; } @@ -1326,6 +1779,125 @@ export function cultMeshLiveFeed( return new CultMeshLiveFeed(feedId, snapshotFeed, options); } +export function cultMeshDocument( + documentId: string, + schema: CultMeshDocumentSchemaDescriptor, + snapshotDocument: (context: CultMeshQueryContext) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchDocument?: CultMeshDocumentWatcher; + replaceDocument?: CultMeshDocumentReplacer; + } = {}, +): CultMeshDocumentHandle { + return new CultMeshDocumentHandle(documentId, schema, snapshotDocument, options); +} + +export function cultMeshDocumentFromCache( + cache: CultCache, + definition: TDefinition, + key: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, +): CultMeshDocumentHandle> { + requireNonEmpty(key, "key"); + const documentId = options.documentId ?? `${definition.type}:${key}`; + const schema = cultMeshSchemaFromDefinition(definition); + return cultMeshDocument( + documentId, + schema, + async () => cache.getRequired(definition, key), + { + routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache"), + sources: [cultMeshProjectionSource(documentId, { schemaId: schema.schemaId })], + watchDocument: cultMeshPollingDocumentWatcher( + async () => cache.getRequired(definition, key), + { intervalMs: options.pollMs ?? 50 }, + ), + replaceDocument: async (_context, value) => { + await cache.put(definition, key, value); + }, + }, + ); +} + +export function cultMeshGlobalDocumentFromCache( + cache: CultCache, + definition: TDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, +): CultMeshDocumentHandle> { + const documentId = options.documentId ?? `${definition.type}:global`; + const schema = cultMeshSchemaFromDefinition(definition); + return cultMeshDocument( + documentId, + schema, + async () => cache.getRequiredGlobal(definition), + { + routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache"), + sources: [cultMeshProjectionSource(documentId, { schemaId: schema.schemaId })], + watchDocument: cultMeshPollingDocumentWatcher( + async () => cache.getRequiredGlobal(definition), + { intervalMs: options.pollMs ?? 50 }, + ), + replaceDocument: async (_context, value) => { + await cache.putGlobal(definition, value); + }, + }, + ); +} + +export function cultMeshDocuments( + ...documents: readonly CultMeshDocumentHandle[] +): CultMeshDocumentCatalog { + return new CultMeshDocumentCatalog(documents); +} + +export function cultMeshCollection( + collectionId: string, + schema: CultMeshDocumentSchemaDescriptor, + snapshotCollection: (context: CultMeshQueryContext) => Promise>, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchCollection?: CultMeshCollectionWatcher; + } = {}, +): CultMeshCollectionHandle { + return new CultMeshCollectionHandle(collectionId, schema, snapshotCollection, options); +} + +export function cultMeshCollectionFromCache( + cache: CultCache, + definition: TDefinition, + options: { + collectionId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, +): CultMeshCollectionHandle> { + const collectionId = options.collectionId ?? definition.type; + const schema = cultMeshSchemaFromDefinition(definition); + return cultMeshCollection( + collectionId, + schema, + async () => cache.getAll(definition), + { + routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache"), + sources: [cultMeshProjectionSource(collectionId, { schemaId: schema.schemaId })], + watchCollection: cultMeshPollingCollectionWatcher( + async () => cache.getAll(definition), + { intervalMs: options.pollMs ?? 50 }, + ), + }, + ); +} + export function cultMeshDescribeLiveFeed( feed: { readonly feedId: string; @@ -1390,6 +1962,33 @@ export function cultMeshPollingQueryWatcher( }; } +export function cultMeshPollingDocumentWatcher( + readDocument: (context: CultMeshQueryContext) => Promise, + options: CultMeshPollingWatchOptions = {}, +): CultMeshDocumentWatcher { + const watch = cultMeshPollingQueryWatcher( + async (_parameters, context) => readDocument(context), + options, + ); + return (context, callback) => watch(undefined, context, callback); +} + +export function cultMeshPollingCollectionWatcher( + readCollection: (context: CultMeshQueryContext) => Promise>, + options: CultMeshPollingWatchOptions> = {}, +): CultMeshCollectionWatcher { + const watch = cultMeshPollingQueryWatcher>( + async (_parameters, context) => readCollection(context), + { + ...options, + equals: options.equals ?? ((left, right) => JSON.stringify(left) === JSON.stringify(right)), + }, + ); + return (context, callback) => watch(undefined, context, () => callback({ + kind: "reset", + })); +} + export function cultMeshStatePointer( pointerId: string, resolvePointer: (() => Promise) | CultMeshStatePointerResolver, @@ -2122,6 +2721,40 @@ export class CultMeshNode { return this.cache.delete(definition, key); } + public document( + definition: TDefinition, + key: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle> { + return cultMeshDocumentFromCache(this.cache, definition, key, options); + } + + public globalDocument( + definition: TDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle> { + return cultMeshGlobalDocumentFromCache(this.cache, definition, options); + } + + public collection( + definition: TDefinition, + options: { + collectionId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshCollectionHandle> { + return cultMeshCollectionFromCache(this.cache, definition, options); + } + public async flush(soft = false): Promise { await this.store.pushAll?.(this.cache.snapshot(), { soft }); } @@ -2819,6 +3452,90 @@ export class CultMesh { return cultMeshLiveFeed(feedId, snapshotFeed, options); } + public static document( + documentId: string, + schema: CultMeshDocumentSchemaDescriptor, + snapshotDocument: (context: CultMeshQueryContext) => Promise, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchDocument?: CultMeshDocumentWatcher; + replaceDocument?: CultMeshDocumentReplacer; + } = {}, + ): CultMeshDocumentHandle { + return cultMeshDocument(documentId, schema, snapshotDocument, options); + } + + public static documentFromCache( + cache: CultCache, + definition: TDefinition, + key: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle> { + return cultMeshDocumentFromCache(cache, definition, key, options); + } + + public static globalDocumentFromCache( + cache: CultCache, + definition: TDefinition, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle> { + return cultMeshGlobalDocumentFromCache(cache, definition, options); + } + + public static documents( + ...documents: readonly CultMeshDocumentHandle[] + ): CultMeshDocumentCatalog { + return cultMeshDocuments(...documents); + } + + public static bindDocument( + verse: CultMeshVerseContext | CultMeshVerse, + document: CultMeshDocumentHandle, + ): CultMeshBoundDocumentHandle { + return cultMeshBindDocument(verse, document); + } + + public static collection( + collectionId: string, + schema: CultMeshDocumentSchemaDescriptor, + snapshotCollection: (context: CultMeshQueryContext) => Promise>, + options: { + sources?: readonly CultMeshProjectionSource[]; + routeHint?: CultMeshRouteHint; + watchCollection?: CultMeshCollectionWatcher; + } = {}, + ): CultMeshCollectionHandle { + return cultMeshCollection(collectionId, schema, snapshotCollection, options); + } + + public static collectionFromCache( + cache: CultCache, + definition: TDefinition, + options: { + collectionId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshCollectionHandle> { + return cultMeshCollectionFromCache(cache, definition, options); + } + + public static bindCollection( + verse: CultMeshVerseContext | CultMeshVerse, + collection: CultMeshCollectionHandle, + ): CultMeshBoundCollectionHandle { + return cultMeshBindCollection(verse, collection); + } + public static describeLiveFeed( feed: { readonly feedId: string; @@ -3660,6 +4377,69 @@ export class CultMesh { } } +function cultMeshSchemaFromDefinition( + definition: AnyCultCacheDocumentDefinition, +): CultMeshDocumentSchemaDescriptor { + return { + type: definition.type, + schemaId: definition.schemaId ?? definition.type, + schemaName: definition.schemaName, + schemaVersion: definition.schemaVersion, + }; +} + +function normalizeCultMeshDocumentSchema( + schema: CultMeshDocumentSchemaDescriptor, +): CultMeshDocumentSchemaDescriptor { + const normalized = { + type: schema.type?.trim() || undefined, + schemaId: schema.schemaId?.trim() || undefined, + schemaName: schema.schemaName?.trim() || undefined, + schemaVersion: schema.schemaVersion?.trim() || undefined, + }; + + if (!normalized.type && !normalized.schemaId && !normalized.schemaName) { + throw new Error("Document schema must include type, schemaId, or schemaName."); + } + + return normalized; +} + +function cultMeshSchemaNameVersionKey( + schema: CultMeshDocumentSchemaDescriptor, +): string | undefined { + return schema.schemaName + ? `${schema.schemaName}@${schema.schemaVersion ?? ""}` + : undefined; +} + +function cultMeshSchemasAreCompatible( + left: CultMeshDocumentSchemaDescriptor, + right: CultMeshDocumentSchemaDescriptor, +): boolean { + if (left.schemaId && right.schemaId && left.schemaId === right.schemaId) { + return true; + } + + if ( + left.schemaName && + right.schemaName && + left.schemaName === right.schemaName && + (left.schemaVersion ?? "") === (right.schemaVersion ?? "") + ) { + return true; + } + + return Boolean(left.type && right.type && left.type === right.type); +} + +function cultMeshSchemaLabel(schema: CultMeshDocumentSchemaDescriptor): string { + return schema.schemaId ?? + cultMeshSchemaNameVersionKey(schema) ?? + schema.type ?? + ""; +} + function requireNonEmpty(value: string, name: string): void { if (!value || value.trim().length === 0) { throw new Error(`${name} must be non-empty.`); diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 1fd8ffa3..2660595d 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -24,6 +24,16 @@ const noteDocument = defineDocumentType({ name: "noteId", }); +const noteAliasDocument = defineDocumentType({ + type: "cultmesh.note.ui", + schemaId: "cultmesh.note.v0", + schema: z.object({ + noteId: z.string(), + body: z.string(), + }), + name: "noteId", +}); + async function waitFor(predicate: () => boolean, description: string): Promise { const startedAt = Date.now(); while (!predicate()) { @@ -58,6 +68,89 @@ test("CultMesh TS opens a durable local node and registers document bindings", a assert.ok(reopened.documents.getBySchemaId("cultmesh.note.v0")); }); +test("CultMesh TS document handles hide local cache plumbing behind typed reactive reads", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-doc-")), "node.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:1", { + noteId: "note:1", + body: "initial", + }); + + const document = node.document(noteDocument, "note:1", { pollMs: 5 }); + const verse = CultMesh.verse("local", "browser-client", { + routeHint: CultMesh.routeHint("shared-memory", "local cache"), + }); + const bound = verse.bindDocument(document); + const observed: string[] = []; + const unsubscribe = bound.watch(note => observed.push(note.body)); + + assert.equal(document.documentId, "cultmesh.note:note:1"); + assert.equal(document.canReplace, true); + assert.equal((await bound.latest()).body, "initial"); + + await bound.replace({ + noteId: "note:1", + body: "updated", + }); + await waitFor(() => observed.includes("updated"), "document handle update"); + unsubscribe(); + + const alias = bound.asSchemaAlias(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }); + assert.equal((await alias.latest()).body, "updated"); + + const catalog = CultMesh.documents(document); + assert.equal( + (await catalog.latest(noteAliasDocument, "browser-client", { + parse: value => noteAliasDocument.schema.parse(value), + })).body, + "updated", + ); + assert.throws( + () => document.asSchemaAlias({ schemaId: "cultmesh.other.v0" }), + /not compatible/, + ); +}); + +test("CultMesh TS collection handles expose typed snapshots and reset watches", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-coll-")), "node.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:a", { + noteId: "note:a", + body: "alpha", + }); + + const collection = node.collection(noteDocument, { pollMs: 5 }); + const bound = CultMesh.bindCollection( + CultMesh.verse("local", "rts-client").withRoute("shared-memory", "local cache"), + collection, + ); + const changes: string[] = []; + const unsubscribe = bound.watchChanges(change => changes.push(change.kind)); + + assert.deepEqual((await bound.latest()).map(note => note.body), ["alpha"]); + + await node.put(noteDocument, "note:b", { + noteId: "note:b", + body: "bravo", + }); + await waitFor(() => changes.length >= 2, "collection reset after update"); + unsubscribe(); + + assert.deepEqual( + (await collection.asSchemaAlias(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).latest()).map(note => note.body).sort(), + ["alpha", "bravo"], + ); + assert.ok(changes.every(kind => kind === "reset")); +}); + test("CultMesh TS local authority leases do not trust peer cards by contact alone", () => { const peers = CultMesh.createPeerCatalog(); const leases = CultMesh.createAuthorityLeaseCatalog(); diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 3a35d7f8..e34d8263 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -307,6 +307,38 @@ const objects = await visibleObjects.execute(viewportRequest); await movePilot.invoke({ actorEntityKey, directionX: 1, directionY: 0 }); ``` +TypeScript runtimes expose the same document and collection handle shape: + +```ts +const station = await CultMesh.startNode(statePath, { + documents: [stationStockDocument, currentDockingDocument], +}); + +const stock = station.document(stationStockDocument, "station:starbridge:stock"); +const docking = station.document(currentDockingDocument, "player:raven:docking"); +const catalog = CultMesh.documents(stock, docking); + +const stockUi = catalog.document(stationStockUiDocument, { + parse: value => stationStockUiDocument.schema.parse(value), +}); + +const currentStock = await stockUi.latest(); +const unsubscribe = stockUi.watch(next => renderStock(next)); +await stockUi.replace(updatedStock); +unsubscribe(); + +const pilots = station.collection(playerShipDocument); +const currentPilots = await pilots.latest(); +const stopRoster = pilots.watchChanges(change => refreshRoster(change)); +``` + +Projected, remote, shared-memory, IPC, WASM, and local-cache documents all use +the same TS handle contract. The caller names the typed document or collection +it wants; the handle owns route defaults, snapshot reads, reactive watches, +schema alias validation, and replacement when the backing source is mutable. +Generated Aetheria and Ymir facades should bind those handles to a Verse once +and keep transport, quorum, and cache mechanics behind CultMesh. + Native view descriptors can name unmanaged columns without hand-entered byte sizes: From 8bb85d7c474c677b5766d48c259883a83943134e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 15:35:12 +0100 Subject: [PATCH 018/498] Add document surface diagnostics --- packages/cultmesh-ts/src/index.ts | 40 +++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 38 +++++++++++++++++- src/GameCult.Mesh/CultMesh.cs | 29 ++++++++++++++ src/GameCult.Mesh/CultMeshPrimitives.cs | 14 +++++++ src/GameCult.Mesh/docs/public-api.md | 17 ++++---- .../CultMeshStreamingTests.cs | 21 +++++++++- 6 files changed, 149 insertions(+), 10 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index f2505358..545a0e14 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -280,6 +280,8 @@ export type CultMeshSurfaceKind = | "projection-recipe" | "live-feed" | "operation" + | "document" + | "collection" | "state-pointer" | "native-slice-view"; @@ -301,6 +303,8 @@ export interface CultMeshSurfaceCatalogIndexDiagnostic { readonly projectionRecipes: readonly CultMeshSurfaceDiagnostic[]; readonly liveFeeds: readonly CultMeshSurfaceDiagnostic[]; readonly operations: readonly CultMeshSurfaceDiagnostic[]; + readonly documents: readonly CultMeshSurfaceDiagnostic[]; + readonly collections: readonly CultMeshSurfaceDiagnostic[]; readonly statePointers: readonly CultMeshSurfaceDiagnostic[]; readonly nativeSliceViews: readonly CultMeshSurfaceDiagnostic[]; } @@ -2504,6 +2508,16 @@ export function cultMeshDescribeSurface( readonly routeHint: CultMeshRouteHint; readonly sources: readonly CultMeshProjectionSource[]; } + | { + readonly documentId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly collectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } | { readonly pointerId: string; readonly routeHint?: CultMeshRouteHint; @@ -2529,6 +2543,20 @@ export function cultMeshDescribeSurface( }); } + if ("documentId" in surface) { + return cultMeshSurfaceDiagnostic("document", surface.documentId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + + if ("collectionId" in surface) { + return cultMeshSurfaceDiagnostic("collection", surface.collectionId, { + routeHint: surface.routeHint, + sources: surface.sources, + }); + } + if ("viewId" in surface) { return cultMeshSurfaceDiagnostic("native-slice-view", surface.viewId, { routeHint: surface.route, @@ -2608,6 +2636,8 @@ export function cultMeshSurfaceCatalogIndex( projectionRecipes: cultMeshSurfacesByKind(catalog, "projection-recipe"), liveFeeds: cultMeshSurfacesByKind(catalog, "live-feed"), operations: cultMeshSurfacesByKind(catalog, "operation"), + documents: cultMeshSurfacesByKind(catalog, "document"), + collections: cultMeshSurfacesByKind(catalog, "collection"), statePointers: cultMeshSurfacesByKind(catalog, "state-pointer"), nativeSliceViews: cultMeshSurfacesByKind(catalog, "native-slice-view"), }; @@ -3849,6 +3879,16 @@ export class CultMesh { | { readonly pointerId: string; } + | { + readonly documentId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } + | { + readonly collectionId: string; + readonly routeHint: CultMeshRouteHint; + readonly sources: readonly CultMeshProjectionSource[]; + } | CultMeshNativeSliceViewDescriptor, ): CultMeshSurfaceDiagnostic { return cultMeshDescribeSurface(surface); diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 2660595d..7c79bae7 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -646,6 +646,24 @@ test("CultMesh TS describes typed surface catalogs for tools and generated bindi routeHint, }, ); + const document = CultMesh.document( + "daemon:aetheria.frame.latest.v1", + { schemaId: "gamecult.aetheria.daemon_frame.v1" }, + async () => ({ frameId: 1 }), + { + sources: [source], + routeHint, + }, + ); + const collection = CultMesh.collection( + "daemon:aetheria.contacts.v1", + { schemaId: "gamecult.aetheria.contact.v1" }, + async () => [], + { + sources: [source], + routeHint, + }, + ); const nativeView = CultMesh.nativeSliceView( "aetheria.zone.render", "gamecult.aetheria.render_body.v1", @@ -658,17 +676,23 @@ test("CultMesh TS describes typed surface catalogs for tools and generated bindi const feedSurface = CultMesh.describeSurface(feed); const operationSurface = CultMesh.describeSurface(operation); const pointerSurface = CultMesh.describeSurface(pointer); + const documentSurface = CultMesh.describeSurface(document); + const collectionSurface = CultMesh.describeSurface(collection); const nativeViewSurface = CultMesh.describeSurface(nativeView); const catalog = CultMesh.describeSurfaceCatalog("gamecult.aetheria.rts.surfaces.v1", [ querySurface, feedSurface, operationSurface, + documentSurface, + collectionSurface, pointerSurface, nativeViewSurface, ]); assert.equal(querySurface.kind, "query"); assert.equal(feedSurface.kind, "live-feed"); + assert.equal(documentSurface.kind, "document"); + assert.equal(collectionSurface.kind, "collection"); assert.equal(pointerSurface.kind, "state-pointer"); assert.equal(pointerSurface.routeHint.kind, "shared-memory"); assert.deepEqual(pointerSurface.sources.map(next => next.schemaId), [ @@ -680,6 +704,8 @@ test("CultMesh TS describes typed surface catalogs for tools and generated bindi "aetheria.zone.objects.visible", "aetheria.rts.viewport.feed", "aetheria.pilot.set_move_vector", + "daemon:aetheria.frame.latest.v1", + "daemon:aetheria.contacts.v1", "aetheria.selection.current", "aetheria.zone.render", ]); @@ -689,12 +715,16 @@ test("CultMesh TS describes typed surface catalogs for tools and generated bindi ]); assert.notEqual(catalog.surfaces[0], querySurface); assert.notEqual(catalog.surfaces[0].sources, querySurface.sources); - assert.equal(catalog.surfaces[4].routeHint.kind, "shared-memory"); + assert.equal(catalog.surfaces[6].routeHint.kind, "shared-memory"); assert.equal( CultMesh.findSurface(catalog, "aetheria.selection.current")?.kind, "state-pointer", ); + assert.equal( + CultMesh.findSurface(catalog, "daemon:aetheria.frame.latest.v1")?.kind, + "document", + ); assert.equal(CultMesh.findSurface(catalog, "missing"), undefined); const operations = CultMesh.surfacesByKind(catalog, "operation"); @@ -715,6 +745,12 @@ test("CultMesh TS describes typed surface catalogs for tools and generated bindi assert.deepEqual(index.operations.map(surface => surface.surfaceId), [ "aetheria.pilot.set_move_vector", ]); + assert.deepEqual(index.documents.map(surface => surface.surfaceId), [ + "daemon:aetheria.frame.latest.v1", + ]); + assert.deepEqual(index.collections.map(surface => surface.surfaceId), [ + "daemon:aetheria.contacts.v1", + ]); assert.deepEqual(index.statePointers.map(surface => surface.surfaceId), [ "aetheria.selection.current", ]); diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 37b8b1bd..74afbe51 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1454,6 +1454,35 @@ namespace GameCult.Mesh return new CultMeshSurfaceDiagnostic(CultMeshSurfaceKind.LiveFeed, feed.FeedId, feed.RouteHint, feed.Sources); } + /// + /// Describes a typed document handle as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + ICultMeshDocumentHandle document) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + return new CultMeshSurfaceDiagnostic( + CultMeshSurfaceKind.Document, + document.DocumentId, + document.RouteHint, + document.Sources); + } + + /// + /// Describes a typed collection handle as one entry in a surface catalog. + /// + public static CultMeshSurfaceDiagnostic DescribeSurface( + CultMeshCollectionHandle collection) + where TDocument : class + { + if (collection == null) throw new ArgumentNullException(nameof(collection)); + return new CultMeshSurfaceDiagnostic( + CultMeshSurfaceKind.Collection, + collection.CollectionId, + collection.RouteHint, + collection.Sources); + } + /// /// Describes a typed state pointer as one entry in a surface catalog. /// diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 5152e738..52f212cc 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1502,6 +1502,12 @@ namespace GameCult.Mesh /// A typed operation handle. Operation, + /// A typed reactive document handle. + Document, + + /// A typed reactive collection handle. + Collection, + /// A typed pointer to Verse state. StatePointer, @@ -1608,6 +1614,8 @@ namespace GameCult.Mesh ProjectionRecipes = catalog.FindByKind(CultMeshSurfaceKind.ProjectionRecipe); LiveFeeds = catalog.FindByKind(CultMeshSurfaceKind.LiveFeed); Operations = catalog.FindByKind(CultMeshSurfaceKind.Operation); + Documents = catalog.FindByKind(CultMeshSurfaceKind.Document); + Collections = catalog.FindByKind(CultMeshSurfaceKind.Collection); StatePointers = catalog.FindByKind(CultMeshSurfaceKind.StatePointer); NativeSliceViews = catalog.FindByKind(CultMeshSurfaceKind.NativeSliceView); } @@ -1627,6 +1635,12 @@ namespace GameCult.Mesh /// Gets typed operation handles. public IReadOnlyList Operations { get; } + /// Gets typed reactive document handles. + public IReadOnlyList Documents { get; } + + /// Gets typed reactive collection handles. + public IReadOnlyList Collections { get; } + /// Gets typed pointers to Verse state. public IReadOnlyList StatePointers { get; } diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index e34d8263..b18f1dd5 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -388,19 +388,20 @@ diagnostic shape for generated clients, Eve/CultUI inspectors, MCP tools, and surface catalogs. Tooling should ask CultMesh for these descriptors instead of copying source lists and route hints by convention. `CultMesh.DescribeSurface(...)` and `CultMesh.DescribeSurfaceCatalog(...)` -collect query surfaces, projection recipes, live feeds, operations, state -pointers, and native views into one inspectable catalog for a runtime or -generated binding package. State pointers are advertised by pointer id; native -slice views are advertised by view id and route while the full column layout -stays in `CultMeshNativeSliceViewDescriptor`. +collect query surfaces, projection recipes, live feeds, operations, document +handles, collection handles, state pointers, and native views into one +inspectable catalog for a runtime or generated binding package. Documents are +advertised by document id, collections by collection id, state pointers by +pointer id, and native slice views by view id and route while the full column +layout stays in `CultMeshNativeSliceViewDescriptor`. Catalog diagnostics support exact id lookup and kind filtering so tools can discover, for example, every operation or native view without local scan conventions. `CultMesh.DescribeSurfaceCatalogIndex(...)` / TS `CultMesh.surfaceCatalogIndex(...)` promotes that grouping into a shared diagnostic shape with `queries`, `projectionRecipes`, `liveFeeds`, -`operations`, `statePointers`, and `nativeSliceViews`, so generated bindings -and tools can ask for the semantic bucket they need without copying catalog -scan logic. +`operations`, `documents`, `collections`, `statePointers`, and +`nativeSliceViews`, so generated bindings and tools can ask for the semantic +bucket they need without copying catalog scan logic. Live feeds compose several query surfaces into one client-facing snapshot without making the UI own refresh loops: diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index d3229bdb..a3e080ea 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -911,6 +911,19 @@ public sealed class CultMeshStreamingTests () => new Subject(), route, sources); + var document = CultMesh.Document( + "daemon:aetheria.frame.latest.v1", + CultMesh.Verse("aetheria.local", "unity-raven").Context, + _context => Task.FromResult(new MeshNoteDocument { Schema = "frame", Text = "current", Revision = 1 }), + _context => new Subject(), + sources, + route); + var collection = new CultMeshCollectionHandle( + "daemon:aetheria.contacts.v1", + () => Task.FromResult>(Array.Empty()), + () => new Subject>(), + sources, + route); var nativeView = new CultMeshNativeSliceViewDescriptor( "aetheria.zone.render", "gamecult.aetheria.render_body.v1", @@ -925,18 +938,22 @@ public sealed class CultMeshStreamingTests CultMesh.DescribeSurface(query), CultMesh.DescribeSurface(feed), CultMesh.DescribeSurface(operation), + CultMesh.DescribeSurface(document), + CultMesh.DescribeSurface(collection), CultMesh.DescribeSurface(pointer), CultMesh.DescribeSurface(nativeView) }); catalog.CatalogId.Should().Be("gamecult.aetheria.rts.surfaces.v1"); - catalog.Surfaces.Should().HaveCount(5); + catalog.Surfaces.Should().HaveCount(7); catalog.Surfaces[0].Kind.Should().Be(CultMeshSurfaceKind.Query); catalog.Surfaces[0].SurfaceId.Should().Be("aetheria.zone.objects.visible"); catalog.Surfaces[0].RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); catalog.Surfaces[0].Sources.Should().HaveCount(1); catalog.Find("aetheria.rts.viewport.feed")!.Kind.Should().Be(CultMeshSurfaceKind.LiveFeed); catalog.Find("aetheria.entity.pilot.move")!.Kind.Should().Be(CultMeshSurfaceKind.Operation); + catalog.Find("daemon:aetheria.frame.latest.v1")!.Kind.Should().Be(CultMeshSurfaceKind.Document); + catalog.Find("daemon:aetheria.contacts.v1")!.Kind.Should().Be(CultMeshSurfaceKind.Collection); catalog.Find("aetheria.selection.current")!.Kind.Should().Be(CultMeshSurfaceKind.StatePointer); catalog.Find("aetheria.selection.current")!.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); catalog.Find("aetheria.selection.current")!.Sources.Should().ContainSingle(); @@ -957,6 +974,8 @@ public sealed class CultMeshStreamingTests index.Queries.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.zone.objects.visible"); index.LiveFeeds.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.rts.viewport.feed"); index.Operations.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.entity.pilot.move"); + index.Documents.Should().ContainSingle().Which.SurfaceId.Should().Be("daemon:aetheria.frame.latest.v1"); + index.Collections.Should().ContainSingle().Which.SurfaceId.Should().Be("daemon:aetheria.contacts.v1"); index.StatePointers.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.selection.current"); index.NativeSliceViews.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.zone.render"); index.ProjectionRecipes.Should().BeEmpty(); From 8758a65e8e7c43849150e234095b31e805dad7cd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 15:46:25 +0100 Subject: [PATCH 019/498] Add single-file document handles --- .../single-file-messagepack-backing-store.ts | 28 ++++++- packages/cultmesh-ts/src/index.ts | 82 +++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 43 ++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 58dbd48d..4d2bb4f9 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -166,7 +166,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { try { await writeFile(tempPath, encode(encodeSnapshot(entries))); - await rename(tempPath, this.filePath); + await renameWithRetry(tempPath, this.filePath); } catch (error) { await rm(tempPath, { force: true }).catch(() => undefined); throw error; @@ -174,6 +174,32 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } +async function renameWithRetry(source: string, destination: string): Promise { + let delayMs = 10; + let lastError: unknown; + for (let attempt = 0; attempt < 10; attempt++) { + try { + await rename(source, destination); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") { + throw error; + } + + lastError = error; + await delay(delayMs); + delayMs *= 2; + } + } + + throw lastError; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + type PersistedRecord = { key: string; schemaId: string; diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 545a0e14..5ed8de31 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -1828,6 +1828,62 @@ export function cultMeshDocumentFromCache { + requireNonEmpty(schemaId, "schemaId"); + const documentId = options.documentId ?? schemaId; + const read = async () => { + const records = await store.pullAll(); + const record = records.find(candidate => candidate.schemaId === schemaId); + if (!record) { + throw new Error(`CultMesh store document '${documentId}' did not contain schema ${schemaId}.`); + } + return decode(record.payload); + }; + + return cultMeshDocument( + documentId, + { schemaId }, + async () => read(), + { + routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache store"), + sources: [cultMeshProjectionSource(options.sourceId ?? documentId, { schemaId })], + watchDocument: cultMeshPollingDocumentWatcher(read, { + intervalMs: options.pollMs ?? 50, + }), + }, + ); +} + +export function cultMeshDocumentFromSingleFile( + path: string, + schemaId: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + } = {}, +): CultMeshDocumentHandle { + requireNonEmpty(path, "path"); + return cultMeshDocumentFromStore( + new SingleFileMessagePackBackingStore(path), + schemaId, + { + ...options, + routeHint: options.routeHint ?? cultMeshRouteHint("shared-memory", path), + }, + ); +} + export function cultMeshGlobalDocumentFromCache( cache: CultCache, definition: TDefinition, @@ -3509,6 +3565,32 @@ export class CultMesh { return cultMeshDocumentFromCache(cache, definition, key, options); } + public static documentFromStore( + store: CacheBackingStore, + schemaId: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle { + return cultMeshDocumentFromStore(store, schemaId, options); + } + + public static documentFromSingleFile( + path: string, + schemaId: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + } = {}, + ): CultMeshDocumentHandle { + return cultMeshDocumentFromSingleFile(path, schemaId, options); + } + public static globalDocumentFromCache( cache: CultCache, definition: TDefinition, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 7c79bae7..dbb1e1d2 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -115,6 +115,49 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti ); }); +test("CultMesh TS document handles read schema publications from single-file stores", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-")), "publication.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:publication", { + noteId: "note:publication", + body: "published", + }); + await node.flush(); + + const document = CultMesh.documentFromSingleFile(filePath, "cultmesh.note.v0", { + documentId: "daemon:cultmesh.note.latest", + sourceId: "daemon:cultmesh.note.latest.v0", + pollMs: 50, + }); + const observed: string[] = []; + const unsubscribe = document.watch(value => { + observed.push(noteDocument.schema.parse(value).body); + }); + + try { + assert.equal(noteDocument.schema.parse(await document.latest()).body, "published"); + + const republisher = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await republisher.put(noteDocument, "note:publication", { + noteId: "note:publication", + body: "republished", + }); + await waitFor(() => observed.includes("republished"), "single-file document watch update"); + } finally { + unsubscribe(); + } + + assert.equal(document.documentId, "daemon:cultmesh.note.latest"); + assert.equal(document.routeHint.kind, "shared-memory"); + assert.deepEqual(document.sources.map(source => source.schemaId), [ + "cultmesh.note.v0", + ]); +}); + test("CultMesh TS collection handles expose typed snapshots and reset watches", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-coll-")), "node.ccmp"); const node = await CultMesh.startNode(filePath, { From 1753dfbb92afba56a5f1275f5eaeed6b1d5ace7d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 15:56:45 +0100 Subject: [PATCH 020/498] Add CultMesh peer snapshot document handles --- packages/cultmesh-ts/README.md | 20 +++ packages/cultmesh-ts/src/index.ts | 167 +++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 81 ++++++++++ src/GameCult.Mesh/docs/public-api.md | 16 ++ 4 files changed, 284 insertions(+) diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index 3772ef92..9a210f33 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -265,6 +265,18 @@ const client = await CultMesh.createRudpPeerForAuthorizedPeer( ); const schemas = CultMesh.createSchemaCatalog(); await client.syncSchemaCatalog(schemas, { kinds: ["document_payload"] }); + +const health = CultMesh.documentFromPeerSnapshot( + () => client, + "gamecult.aetheria.daemon_health.v1", + "daemon:aetheria.health.v1", + { + routeHint: CultMesh.routeHint("network", endpoint.uri), + timeoutMs: 5_000, + }, +); + +const latestHealth = await health.latest(); ``` `CultMesh.createRudpPeerForPeer(...)` remains available for already trusted @@ -275,6 +287,14 @@ Discovery-first schema/catalog paths should prefer `CultMeshPeerCatalog.firstAuthorized(...)` with the authority lease catalog before using the peer-card endpoint as a dial target. +`CultMesh.documentFromPeerSnapshot(...)` is the document-level path for RUDP +snapshots. It hides the snapshot request message id, response listener, raw +payload binary normalization, and MessagePack decode behind the same +`CultMeshDocumentHandle` shape as local documents. The helper prefers an exact +schema id match and falls back to the requested record key, so runtimes that +alias the same logical document through different generated schema names can +still read the publication with one CultMesh call. + ## Streaming Mode CultMesh streaming mode is for audio, video, tensor, and opaque byte frames that diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 5ed8de31..31cb0e07 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -21,11 +21,13 @@ import { encodeCultNetMessageForWire, encodeRudpPacket, parseCultNetMessage, + type CultNetErrorMessage, type CultNetDocumentBinding, type CultNetMessage, type CultNetRawDocumentRecord, type CultNetReconnectPolicy, type CultNetSchemaCatalogOptions, + type CultNetSnapshotResponseRawMessage, type CultNetWireContract, } from "cultnet-ts"; @@ -1884,6 +1886,49 @@ export function cultMeshDocumentFromSingleFile( ); } +export function cultMeshDocumentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + schemaId: string, + recordKey: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, +): CultMeshDocumentHandle { + requireNonEmpty(schemaId, "schemaId"); + requireNonEmpty(recordKey, "recordKey"); + const documentId = options.documentId ?? recordKey; + const read = async () => + decodeCultNetRawDocumentPayload( + await requestCultNetRawSnapshotDocument( + peer, + schemaId, + recordKey, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix ?? documentId, + }, + ), + ); + + return cultMeshDocument( + documentId, + { schemaId }, + async () => read(), + { + routeHint: options.routeHint ?? cultMeshRouteHint("network", "CultNet snapshot"), + sources: [cultMeshProjectionSource(options.sourceId ?? documentId, { schemaId })], + watchDocument: cultMeshPollingDocumentWatcher(read, { + intervalMs: options.pollMs ?? 250, + }), + }, + ); +} + export function cultMeshGlobalDocumentFromCache( cache: CultCache, definition: TDefinition, @@ -3591,6 +3636,22 @@ export class CultMesh { return cultMeshDocumentFromSingleFile(path, schemaId, options); } + public static documentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + schemaId: string, + recordKey: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): CultMeshDocumentHandle { + return cultMeshDocumentFromPeerSnapshot(peer, schemaId, recordKey, options); + } + public static globalDocumentFromCache( cache: CultCache, definition: TDefinition, @@ -4595,6 +4656,112 @@ function normalizeRudpDocumentPut( }; } +async function requestCultNetRawSnapshotDocument( + peerOrProvider: CultNetPeer | (() => CultNetPeer | Promise), + schemaId: string, + recordKey: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, +): Promise { + const peer = await resolveCultNetPeer(peerOrProvider); + const messageId = `${options.messageIdPrefix ?? "cultmesh-snapshot"}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const response = await requestCultNetRawSnapshot(peer, [recordKey], { + messageId, + timeoutMs: options.timeoutMs, + }); + const candidates = response.documents.filter(candidate => candidate.recordKey === recordKey); + const document = candidates.find(candidate => candidate.schemaId === schemaId) ?? candidates[0]; + if (!document) { + throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${recordKey}.`); + } + return document; +} + +async function requestCultNetRawSnapshot( + peer: CultNetPeer, + recordKeys: readonly string[], + options: { + messageId: string; + timeoutMs?: number; + }, +): Promise { + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timer); + peer.off("message", onMessage); + peer.off("invalidMessage", onInvalidMessage); + peer.off("error", onError); + peer.off("close", onClose); + }; + const rejectWith = (error: Error): void => { + cleanup(); + reject(error); + }; + const onMessage = (message: CultNetMessage): void => { + if (isCultNetErrorMessage(message)) { + cleanup(); + reject(new Error(message.error)); + return; + } + if (message.schemaVersion !== "cultnet.snapshot_response_raw.v0" || + message.messageId !== options.messageId) { + return; + } + + cleanup(); + resolve(message); + }; + const onInvalidMessage = (error: Error): void => rejectWith(error); + const onError = (error: Error): void => rejectWith(error); + const onClose = (): void => rejectWith(new Error("CultNet peer closed before snapshot response.")); + const timer = setTimeout( + () => rejectWith(new Error(`Timed out waiting for CultNet snapshot ${options.messageId}.`)), + options.timeoutMs ?? 4_000, + ); + + peer.on("message", onMessage); + peer.on("invalidMessage", onInvalidMessage); + peer.on("error", onError); + peer.on("close", onClose); + peer.sendSnapshotRequest({ + schemaVersion: "cultnet.snapshot_request.v0", + messageId: options.messageId, + recordKeys: [...recordKeys], + }); + }); +} + +async function resolveCultNetPeer( + peerOrProvider: CultNetPeer | (() => CultNetPeer | Promise), +): Promise { + return typeof peerOrProvider === "function" + ? await peerOrProvider() + : peerOrProvider; +} + +function decodeCultNetRawDocumentPayload(document: CultNetRawDocumentRecord): unknown { + if (document.payloadEncoding !== "messagepack") { + throw new Error(`Unsupported CultNet raw document payload encoding ${document.payloadEncoding}.`); + } + return decode(toUint8Array(document.payload)); +} + +function isCultNetErrorMessage(message: CultNetMessage): message is CultNetErrorMessage { + return message.schemaVersion === "cultnet.error.v0"; +} + +function toUint8Array(value: unknown): Uint8Array { + if (value instanceof Uint8Array) { + return value; + } + if (Array.isArray(value)) { + return Uint8Array.from(value); + } + throw new Error("CultNet raw document payload was not binary."); +} + async function bindRudpSocket(options: CultMeshRudpSocketOptions): Promise { const socket = createSocket("udp4"); const host = options.bindHost ?? "127.0.0.1"; diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index dbb1e1d2..48fd7dd7 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -1832,6 +1832,87 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () } }); +test("CultMesh TS reads remote RUDP snapshots through document handles", async () => { + const connectionId = 0x10203048; + const node = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-snapshot-")), "node.ccmp"), + { + documents: [noteDocument], + }, + ); + await node.put(noteDocument, "note:remote", { + noteId: "note:remote", + body: "remote handles feel local", + }); + + const server = CultMesh.createRudpDocumentServer( + "cultmesh-ts-rudp-snapshot-server", + connectionId, + { + documents: new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: noteDocument }), + ]), + getCache: () => node.cache, + bindHost: "127.0.0.1", + bindPort: 0, + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + }, + ); + + let peer: CultNetPeer | undefined; + try { + await server.start(); + peer = await CultMesh.createRudpPeer( + "cultmesh-ts-rudp-snapshot-client", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + }, + ); + + const document = CultMesh.documentFromPeerSnapshot( + peer, + "cultmesh.note.v0", + "note:remote", + { + documentId: "note:remote", + timeoutMs: 1_000, + }, + ); + + assert.deepEqual(await document.latest(), { + noteId: "note:remote", + body: "remote handles feel local", + }); + + const alias = CultMesh.documentFromPeerSnapshot( + peer, + "cultmesh.note.alias.v1", + "note:remote", + { + documentId: "note:remote.alias", + timeoutMs: 1_000, + }, + ); + + assert.deepEqual(await alias.latest(), { + noteId: "note:remote", + body: "remote handles feel local", + }); + } finally { + peer?.close(); + server.close(); + } +}); + test("CultMesh TS negotiates streaming frame body transports explicitly", () => { const streams = CultMesh.createStreamCatalog(); const streamUpdates: string[] = []; diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index b18f1dd5..3f46eef7 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -330,6 +330,13 @@ unsubscribe(); const pilots = station.collection(playerShipDocument); const currentPilots = await pilots.latest(); const stopRoster = pilots.watchChanges(change => refreshRoster(change)); + +const remoteHealth = CultMesh.documentFromPeerSnapshot( + () => daemonPeer, + "gamecult.aetheria.daemon_health.v1", + "daemon:aetheria.health.v1", +); +const health = await remoteHealth.latest(); ``` Projected, remote, shared-memory, IPC, WASM, and local-cache documents all use @@ -458,6 +465,15 @@ helpers parse endpoint contact hints and choose authorized peers; `GameCult.Networking` still owns the RUDP packet codec, session state, resend, fragmentation, and channel semantics. +TypeScript runtimes can also wrap a CultNet peer snapshot as a normal document +handle with `CultMesh.documentFromPeerSnapshot(...)`. The caller supplies the +peer or peer factory, schema id, and record key; CultMesh sends the snapshot +request, waits for the matching response, decodes the raw MessagePack document, +and returns the same `latest()`/`watch()` document surface used by local cache +and single-file documents. Exact schema ids are preferred, but record-key +publications remain readable when two runtimes alias the same logical document +through different schema names. + ### Shard Authority Each shard has one primary writer for now. Non-primary writes are rejected or From b6d4ebc92016c04106c6ba272ae84828dd637c1f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 16:03:10 +0100 Subject: [PATCH 021/498] Add synchronous CultMesh document reads --- src/GameCult.Mesh/CultMeshPrimitives.cs | 13 +++++++++++++ tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs | 3 +++ 2 files changed, 16 insertions(+) diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 52f212cc..67ce480f 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -961,6 +961,12 @@ namespace GameCult.Mesh return _feed.SnapshotAsync(CultMeshDocumentQueryParameters.Empty); } + /// Reads the latest document snapshot synchronously for host APIs that cannot be async. + public TDocument Latest() + { + return LatestAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + /// Watches coherent document snapshots. public Observable Watch() { @@ -1140,6 +1146,13 @@ namespace GameCult.Mesh return Document().LatestAsync(); } + /// Reads one typed document synchronously by CLR type or same-schema alias. + public TDocument Latest() + where TDocument : class + { + return Document().Latest(); + } + /// Watches one typed document by CLR type or same-schema alias. public Observable Watch() where TDocument : class diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index a3e080ea..8c73327d 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -655,9 +655,11 @@ public sealed class CultMeshStreamingTests var snapshot = await handle.LatestAsync(); snapshot.Text.Should().Be("primary"); + handle.Latest().Text.Should().Be("primary"); var alias = handle.AsSchemaAlias(); var aliasSnapshot = await alias.LatestAsync(); + alias.Latest().Text.Should().Be("primary"); alias.DocumentId.Should().Be(handle.DocumentId); alias.SchemaName.Should().Be(handle.SchemaName); alias.SchemaVersion.Should().Be(handle.SchemaVersion); @@ -729,6 +731,7 @@ public sealed class CultMeshStreamingTests var aliasSnapshot = await catalog.LatestAsync(); aliasSnapshot.Text.Should().Be("catalog-primary"); aliasSnapshot.Revision.Should().Be(3); + catalog.Latest().Text.Should().Be("catalog-primary"); } [Test] From a08a66a66e1716eaa8041742bf961b171c7bbad3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 16:20:19 +0100 Subject: [PATCH 022/498] Add CultMesh document prediction submissions --- src/GameCult.Mesh/CultMesh.cs | 77 +++++++++++++++++++ src/GameCult.Mesh/CultMeshPrimitives.cs | 28 ++++++- src/GameCult.Mesh/README.md | 9 ++- src/GameCult.Mesh/docs/public-api.md | 15 ++-- .../CultMeshStreamingTests.cs | 62 +++++++++++++++ 5 files changed, 180 insertions(+), 11 deletions(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 74afbe51..08ecfe36 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -426,6 +426,21 @@ namespace GameCult.Mesh return new CultMeshDocumentHandle(BindLiveFeed(context, feed), replace); } + /// + /// Binds a mutable predicted typed document live feed to a Verse context. + /// + public static CultMeshDocumentHandle BindDocument( + CultMeshVerseContext context, + CultMeshLiveFeed feed, + Func replace, + Func submitPrediction) + where TDocument : class + { + if (replace == null) throw new ArgumentNullException(nameof(replace)); + if (submitPrediction == null) throw new ArgumentNullException(nameof(submitPrediction)); + return new CultMeshDocumentHandle(BindLiveFeed(context, feed), replace, submitPrediction); + } + /// /// Binds a typed document live feed to a Verse. /// @@ -451,6 +466,20 @@ namespace GameCult.Mesh return BindDocument(verse.Context, feed, replace); } + /// + /// Binds a mutable predicted typed document live feed to a Verse. + /// + public static CultMeshDocumentHandle BindDocument( + CultMeshVerse verse, + CultMeshLiveFeed feed, + Func replace, + Func submitPrediction) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return BindDocument(verse.Context, feed, replace, submitPrediction); + } + /// /// Binds a typed state pointer to a Verse context. /// @@ -935,6 +964,32 @@ namespace GameCult.Mesh return BindDocument(context, feed, replace); } + /// + /// Creates a mutable predicted typed document handle from snapshot/watch/replace/prediction delegates and binds it to a Verse context. + /// + public static CultMeshDocumentHandle Document( + string documentId, + CultMeshVerseContext context, + Func> latest, + Func> watch, + Func replace, + Func submitPrediction, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (replace == null) throw new ArgumentNullException(nameof(replace)); + if (submitPrediction == null) throw new ArgumentNullException(nameof(submitPrediction)); + + var feed = LiveFeed( + documentId, + (_parameters, queryContext) => latest(queryContext), + (_parameters, queryContext) => watch(queryContext), + sources, + routeHint); + return BindDocument(context, feed, replace, submitPrediction); + } + /// /// Creates a typed document handle from snapshot/watch delegates and binds it to a Verse. /// @@ -968,6 +1023,24 @@ namespace GameCult.Mesh return Document(documentId, verse.Context, latest, watch, replace, sources, routeHint); } + /// + /// Creates a mutable predicted typed document handle from snapshot/watch/replace/prediction delegates and binds it to a Verse. + /// + public static CultMeshDocumentHandle Document( + string documentId, + CultMeshVerse verse, + Func> latest, + Func> watch, + Func replace, + Func submitPrediction, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Document(documentId, verse.Context, latest, watch, replace, submitPrediction, sources, routeHint); + } + /// /// Creates a typed document handle directly over one CultCache record. /// @@ -1068,6 +1141,10 @@ namespace GameCult.Mesh { await database.PutAsync(key, value).ConfigureAwait(false); }, + async value => + { + await database.PutPredictedAsync(key, value).ConfigureAwait(false); + }, sourceList, route); } diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 67ce480f..35ffa740 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -900,6 +900,9 @@ namespace GameCult.Mesh /// Gets whether this handle can replace the underlying document value. bool CanReplace { get; } + /// Gets whether this handle can submit a client prediction for the underlying document value. + bool CanSubmitPrediction { get; } + /// Creates a same-schema alias presentation for another CLR document type. CultMeshDocumentHandle AsSchemaAlias() where TAlias : class; } @@ -915,14 +918,17 @@ namespace GameCult.Mesh private readonly CultMeshBoundLiveFeed _feed; private readonly Func? _replace; + private readonly Func? _submitPrediction; /// Creates a document handle from a Verse-bound live feed. public CultMeshDocumentHandle( CultMeshBoundLiveFeed feed, - Func? replace = null) + Func? replace = null, + Func? submitPrediction = null) { _feed = feed ?? throw new ArgumentNullException(nameof(feed)); _replace = replace; + _submitPrediction = submitPrediction; } /// Gets the semantic document id. @@ -955,6 +961,9 @@ namespace GameCult.Mesh /// Gets whether this handle can replace the underlying document value. public bool CanReplace => _replace != null; + /// Gets whether this handle can submit a client prediction for the underlying document value. + public bool CanSubmitPrediction => _submitPrediction != null; + /// Reads one coherent document snapshot. public Task LatestAsync() { @@ -992,6 +1001,18 @@ namespace GameCult.Mesh return _replace(value); } + /// Submits a locally predicted document value when this handle is backed by client-authoritative state. + public Task SubmitPredictionAsync(TDocument value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (_submitPrediction == null) + { + throw new NotSupportedException($"Document handle '{DocumentId}' does not accept client predictions."); + } + + return _submitPrediction(value); + } + /// Creates a same-schema alias presentation for another CLR document type. public CultMeshDocumentHandle AsSchemaAlias() where TAlias : class { @@ -1018,8 +1039,11 @@ namespace GameCult.Mesh Func? replace = _replace == null ? null : value => _replace(ConvertDocument(value)); + Func? submitPrediction = _submitPrediction == null + ? null + : value => _submitPrediction(ConvertDocument(value)); - return new CultMeshDocumentHandle(aliasFeed.Bind(Context), replace); + return new CultMeshDocumentHandle(aliasFeed.Bind(Context), replace, submitPrediction); } private static TTarget ConvertDocument(TSource document) diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index 5bd3b1a0..a6699bde 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -155,13 +155,14 @@ the record. ```csharp var db = node.Database; +var input = CultMesh.Document(db, inputKey, verse); -await db.PutPredictedAsync(inputKey, inputState); +await input.SubmitPredictionAsync(inputState); -using var sub = db.WatchRecord(inputKey) - .Subscribe(change => +using var sub = input.Watch() + .Subscribe(latest => { - ReconcilePrediction(change); + ReconcilePrediction(latest); }); ``` diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 3f46eef7..2fd2ae9d 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -163,13 +163,16 @@ portable machinery that makes it feel native. - `CultMeshDocumentHandle` is the typed reactive document edge for CultCache/CultNet state. It exposes document metadata, one coherent `LatestAsync()` read, a `Watch()` stream, and `ReplaceAsync(...)` when the - handle is backed by mutable state. Callers can bind a projected live feed, a - local `CultCache` record, a distributed `CultNetDatabase` record, or a + handle is backed by mutable state. Distributed `CultNetDatabase` handles also + expose `SubmitPredictionAsync(...)`, which applies a local prediction through + the database's configured client-authority scope and later reconciles through + the normal shard log. Callers can bind a projected live feed, a local + `CultCache` record, a distributed `CultNetDatabase` record, or a `CultMeshNode` record through the same surface instead of walking transport and projection layers themselves. Same-schema CLR aliases can be requested with `AsSchemaAlias()`; CultMesh verifies the shared `[CultDocument(schemaName, schemaVersion)]` identity and uses the shared - CultCache MessagePack codec for read/watch/replace conversion. + CultCache MessagePack codec for read/watch/replace/prediction conversion. - `CultMeshDocumentCatalog` is the schema-aware lookup edge for a set of document handles. It indexes handles by CLR document type, schema name, and schema version, and resolves same-schema CLR aliases by delegating to @@ -487,8 +490,10 @@ methods. Subscriptions receive domain changes rather than storage envelopes. ### Client Prediction `CultNetClientAuthorityScope` declares input documents a runtime may predict. -`PutPredictedAsync` writes local state and emits `Predicted`. When the -authoritative log arrives, the database emits `Reconciled`. +CultMesh document handles opened over a `CultNetDatabase` expose +`SubmitPredictionAsync(...)`, which writes local state and emits `Predicted` +through the configured database authority policy. When the authoritative log +arrives, the database emits `Reconciled`. ### Replica Catch-Up diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 8c73327d..29f675dd 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -754,6 +754,8 @@ public sealed class CultMeshStreamingTests handle.CanReplace.Should().BeTrue(); alias.CanReplace.Should().BeTrue(); + handle.CanSubmitPrediction.Should().BeFalse(); + alias.CanSubmitPrediction.Should().BeFalse(); handle.DocumentId.Should().Be(key.Value); handle.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(handle.SchemaId); @@ -772,6 +774,66 @@ public sealed class CultMeshStreamingTests cache.Get(key)!.Text.Should().Be("cache-replaced"); observed.Text.Should().Be("cache-replaced"); observed.Revision.Should().Be(2); + + Func act = () => alias.SubmitPredictionAsync(new MeshNoteAliasDocument + { + Schema = "tests.mesh_note.v1", + Text = "cache-predicted", + Revision = 3 + }); + await act.Should().ThrowAsync(); + } + + [Test] + public async Task DocumentHandle_SubmitsPredictionsThroughCultNetDatabase() + { + var cache = new CultCache(); + var schemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId; + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + RuntimeId = "pilot-a", + Shards = new[] + { + new CultNetShardDescriptor( + "pilot-inputs", + "server", + epoch: 1, + isPrimary: false, + schemaIds: new[] { schemaId }, + keyPrefix: "input:") + }, + ClientAuthorityScopes = new[] + { + new CultNetClientAuthorityScope( + "pilot-a", + schemaIds: new[] { schemaId }, + keyPrefix: "input:pilot-a") + } + }); + var key = new CultRecordKey("input:pilot-a:thermal"); + + var handle = CultMesh.Document( + database, + key, + CultMesh.Verse("starbridge", "pilot-a")); + var alias = handle.AsSchemaAlias(); + + handle.CanReplace.Should().BeTrue(); + alias.CanSubmitPrediction.Should().BeTrue(); + + MeshNoteDocument observed = null!; + using var subscription = handle.Watch(value => observed = value); + await alias.SubmitPredictionAsync(new MeshNoteAliasDocument + { + Schema = "tests.mesh_note.v1", + Text = "predicted-thermal", + Revision = 5 + }); + + cache.Get(key)!.Text.Should().Be("predicted-thermal"); + observed.Text.Should().Be("predicted-thermal"); + observed.Revision.Should().Be(5); + (await handle.LatestAsync()).Text.Should().Be("predicted-thermal"); } [Test] From cb6301e1cf7d6904cc15d53654b22b62e0fed443 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 16:24:08 +0100 Subject: [PATCH 023/498] Add TS document prediction submissions --- packages/cultmesh-ts/README.md | 20 ++++++++ packages/cultmesh-ts/src/index.ts | 52 +++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 58 ++++++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index 9a210f33..b3ebbf8a 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -67,6 +67,26 @@ const stop = objects.watch( ); ``` +Typed document handles use the same shape for local cache reads, network +snapshots, replacement, and client prediction. Authority-specific code plugs in +the submitter once; gameplay callers keep using the document: + +```ts +const input = CultMesh.document( + "input:pilot-a:thermal", + pilotInputDocument, + async (context) => readInput(context), + { + routeHint: CultMesh.routeHint("network", "Starbridge Verse"), + watchDocument: watchInput, + submitPrediction: async (context, value) => submitPredictedInput(context, value), + }, +); + +await input.submitPrediction("pilot-a", predictedInput); +const stop = input.watch("pilot-a", latest => reconcileInput(latest)); +``` + State pointers are the same kind of managed surface for UI and tools. They can advertise source documents, inherit a Verse route when bound, and resolve through that Verse without caller-side context plumbing: diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 31cb0e07..1e8b6477 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -111,6 +111,11 @@ export type CultMeshDocumentReplacer = ( value: TDocument, ) => Promise; +export type CultMeshDocumentPredictionSubmitter = ( + context: CultMeshQueryContext, + value: TDocument, +) => Promise; + export type CultMeshCollectionSnapshot = readonly TDocument[]; export type CultMeshCollectionChangeKind = "added" | "updated" | "removed" | "reset"; @@ -724,6 +729,7 @@ export class CultMeshDocumentHandle { routeHint?: CultMeshRouteHint; watchDocument?: CultMeshDocumentWatcher; replaceDocument?: CultMeshDocumentReplacer; + submitPrediction?: CultMeshDocumentPredictionSubmitter; } = {}, ) { requireNonEmpty(documentId, "documentId"); @@ -732,15 +738,23 @@ export class CultMeshDocumentHandle { this.routeHint = options.routeHint ?? cultMeshRouteHint(); this.watchDocument = options.watchDocument; this.replaceDocument = options.replaceDocument; + this.submitPredictionDocument = options.submitPrediction; } private readonly watchDocument: CultMeshDocumentWatcher | undefined; private readonly replaceDocument: CultMeshDocumentReplacer | undefined; + private readonly submitPredictionDocument: + | CultMeshDocumentPredictionSubmitter + | undefined; public get canReplace(): boolean { return this.replaceDocument !== undefined; } + public get canSubmitPrediction(): boolean { + return this.submitPredictionDocument !== undefined; + } + public latest(context: CultMeshQueryContext | string = "local"): Promise { return this.snapshotDocument( this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), @@ -802,6 +816,31 @@ export class CultMeshDocumentHandle { return this.replaceDocument(this.resolveContext(context), value); } + public submitPrediction(value: TDocument): Promise; + public submitPrediction(context: CultMeshQueryContext | string, value: TDocument): Promise; + public submitPrediction( + contextOrValue: CultMeshQueryContext | string | TDocument, + maybeValue?: TDocument, + ): Promise { + if (!this.submitPredictionDocument) { + throw new Error(`Document '${this.documentId}' does not support prediction submission.`); + } + + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? typeof contextOrValue === "string" + ? cultMeshQueryContext(contextOrValue) + : contextOrValue as CultMeshQueryContext + : cultMeshQueryContext("local"); + const value = hasContext ? maybeValue : contextOrValue as TDocument; + if (value === undefined) { + throw new Error(`Document '${this.documentId}' requires a prediction value.`); + } + + return this.submitPredictionDocument(this.resolveContext(context), value); + } + public asSchemaAlias( schema: CultMeshDocumentSchemaDescriptor, options: { parse?: (value: unknown) => TAlias } = {}, @@ -827,6 +866,9 @@ export class CultMeshDocumentHandle { replaceDocument: this.replaceDocument ? (context, value) => this.replace(context, value as unknown as TDocument) : undefined, + submitPrediction: this.submitPredictionDocument + ? (context, value) => this.submitPrediction(context, value as unknown as TDocument) + : undefined, }, ); } @@ -864,6 +906,10 @@ export class CultMeshBoundDocumentHandle { return this.document.canReplace; } + public get canSubmitPrediction(): boolean { + return this.document.canSubmitPrediction; + } + public latest(): Promise { return this.document.latest(cultMeshQueryContextFromVerse(this.verse)); } @@ -880,6 +926,10 @@ export class CultMeshBoundDocumentHandle { return this.document.replace(cultMeshQueryContextFromVerse(this.verse), value); } + public submitPrediction(value: TDocument): Promise { + return this.document.submitPrediction(cultMeshQueryContextFromVerse(this.verse), value); + } + public asSchemaAlias( schema: CultMeshDocumentSchemaDescriptor, options: { parse?: (value: unknown) => TAlias } = {}, @@ -1794,6 +1844,7 @@ export function cultMeshDocument( routeHint?: CultMeshRouteHint; watchDocument?: CultMeshDocumentWatcher; replaceDocument?: CultMeshDocumentReplacer; + submitPrediction?: CultMeshDocumentPredictionSubmitter; } = {}, ): CultMeshDocumentHandle { return new CultMeshDocumentHandle(documentId, schema, snapshotDocument, options); @@ -3592,6 +3643,7 @@ export class CultMesh { routeHint?: CultMeshRouteHint; watchDocument?: CultMeshDocumentWatcher; replaceDocument?: CultMeshDocumentReplacer; + submitPrediction?: CultMeshDocumentPredictionSubmitter; } = {}, ): CultMeshDocumentHandle { return cultMeshDocument(documentId, schema, snapshotDocument, options); diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 48fd7dd7..b467f9ba 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -88,6 +88,7 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti assert.equal(document.documentId, "cultmesh.note:note:1"); assert.equal(document.canReplace, true); + assert.equal(document.canSubmitPrediction, false); assert.equal((await bound.latest()).body, "initial"); await bound.replace({ @@ -115,6 +116,63 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti ); }); +test("CultMesh TS document handles submit predictions through configured authority hooks", async () => { + let current = { + noteId: "note:prediction", + body: "initial", + }; + const predictions: string[] = []; + const contexts: string[] = []; + const document = CultMesh.document( + "cultmesh.note:prediction", + noteDocument, + async () => current, + { + routeHint: CultMesh.routeHint("network", "CultNet prediction"), + submitPrediction: async (context, value) => { + contexts.push(`${context.runtimeId}:${context.routeHint.kind}`); + predictions.push(value.body); + current = value; + }, + }, + ); + const verse = CultMesh.verse("starbridge", "pilot-a"); + const bound = document.bind(verse); + + assert.equal(document.canReplace, false); + assert.equal(document.canSubmitPrediction, true); + assert.equal(bound.canSubmitPrediction, true); + + await bound.submitPrediction({ + noteId: "note:prediction", + body: "predicted", + }); + + assert.deepEqual(contexts, ["pilot-a:network"]); + assert.deepEqual(predictions, ["predicted"]); + assert.equal((await bound.latest()).body, "predicted"); + + const alias = bound.asSchemaAlias(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }); + assert.equal(alias.canSubmitPrediction, true); + await alias.submitPrediction({ + noteId: "note:prediction", + body: "alias-predicted", + }); + + assert.deepEqual(predictions, ["predicted", "alias-predicted"]); + assert.equal((await document.latest("pilot-a")).body, "alias-predicted"); + + assert.throws( + () => document.replace({ + noteId: "note:prediction", + body: "not-authoritative", + }), + /does not support replacement/, + ); +}); + test("CultMesh TS document handles read schema publications from single-file stores", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-")), "publication.ccmp"); const node = await CultMesh.startNode(filePath, { From 52ff632d70cf2dedd5d1ca6290e29d79783f0be6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 16:28:42 +0100 Subject: [PATCH 024/498] Add CultMesh document catalog mutations --- packages/cultmesh-ts/src/index.ts | 62 +++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 16 ++++- src/GameCult.Mesh/CultMeshPrimitives.cs | 28 +++++++++ src/GameCult.Mesh/docs/public-api.md | 5 +- .../CultMeshStreamingTests.cs | 16 ++++- 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 1e8b6477..bd1c6ba9 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -1010,6 +1010,68 @@ export class CultMeshDocumentCatalog { return this.document(schema, options).latest(context); } + public canReplace(schema: CultMeshDocumentSchemaDescriptor): boolean { + return this.tryDocument(schema)?.canReplace ?? false; + } + + public replace( + schema: CultMeshDocumentSchemaDescriptor, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public replace( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public replace( + schema: CultMeshDocumentSchemaDescriptor, + contextOrValue: CultMeshQueryContext | string | TDocument, + valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? contextOrValue as CultMeshQueryContext | string + : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; + const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).replace(context, value); + } + + public canSubmitPrediction(schema: CultMeshDocumentSchemaDescriptor): boolean { + return this.tryDocument(schema)?.canSubmitPrediction ?? false; + } + + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + contextOrValue: CultMeshQueryContext | string | TDocument, + valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? contextOrValue as CultMeshQueryContext | string + : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; + const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).submitPrediction(context, value); + } + public watch( schema: CultMeshDocumentSchemaDescriptor, callback: (value: TDocument) => void, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index b467f9ba..219e3eb5 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -104,11 +104,19 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti assert.equal((await alias.latest()).body, "updated"); const catalog = CultMesh.documents(document); + assert.equal(catalog.canReplace(noteAliasDocument), true); + await catalog.replace(noteAliasDocument, { + noteId: "note:1", + body: "catalog-updated", + }, { + parse: value => noteAliasDocument.schema.parse(value), + context: "browser-client", + }); assert.equal( (await catalog.latest(noteAliasDocument, "browser-client", { parse: value => noteAliasDocument.schema.parse(value), })).body, - "updated", + "catalog-updated", ); assert.throws( () => document.asSchemaAlias({ schemaId: "cultmesh.other.v0" }), @@ -138,14 +146,18 @@ test("CultMesh TS document handles submit predictions through configured authori ); const verse = CultMesh.verse("starbridge", "pilot-a"); const bound = document.bind(verse); + const catalog = CultMesh.documents(document); assert.equal(document.canReplace, false); assert.equal(document.canSubmitPrediction, true); assert.equal(bound.canSubmitPrediction, true); + assert.equal(catalog.canSubmitPrediction(noteAliasDocument), true); - await bound.submitPrediction({ + await catalog.submitPrediction(noteAliasDocument, "pilot-a", { noteId: "note:prediction", body: "predicted", + }, { + parse: value => noteAliasDocument.schema.parse(value), }); assert.deepEqual(contexts, ["pilot-a:network"]); diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 35ffa740..a35f1c24 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1177,6 +1177,34 @@ namespace GameCult.Mesh return Document().Latest(); } + /// Gets whether one typed document can be replaced by CLR type or same-schema alias. + public bool CanReplace() + where TDocument : class + { + return TryGetDocument(out var document) && document.CanReplace; + } + + /// Replaces one typed document by CLR type or same-schema alias. + public Task ReplaceAsync(TDocument value) + where TDocument : class + { + return Document().ReplaceAsync(value); + } + + /// Gets whether one typed document can submit client predictions by CLR type or same-schema alias. + public bool CanSubmitPrediction() + where TDocument : class + { + return TryGetDocument(out var document) && document.CanSubmitPrediction; + } + + /// Submits a client prediction for one typed document by CLR type or same-schema alias. + public Task SubmitPredictionAsync(TDocument value) + where TDocument : class + { + return Document().SubmitPredictionAsync(value); + } + /// Watches one typed document by CLR type or same-schema alias. public Observable Watch() where TDocument : class diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 2fd2ae9d..d56a5a88 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -178,7 +178,10 @@ portable machinery that makes it feel native. schema version, and resolves same-schema CLR aliases by delegating to `CultMeshDocumentHandle.AsSchemaAlias()`. Domain facades can expose `Document()`, `DocumentBySchema(...)`, `LatestAsync()`, and - `Watch()` without owning schema dictionaries or alias serializers. + `Watch()` without owning schema dictionaries or alias serializers. Mutable + facades can also call `ReplaceAsync(...)` or + `SubmitPredictionAsync(...)` directly on the catalog when they only need + the schema-aliased operation and not the intermediate handle. - `CultMeshCollectionHandle` is the typed reactive edge for multi-record state. It exposes one `LatestAsync()` collection snapshot and a `WatchChanges()` stream while hiding whether the backing source is an diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 29f675dd..e4856572 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -775,11 +775,21 @@ public sealed class CultMeshStreamingTests observed.Text.Should().Be("cache-replaced"); observed.Revision.Should().Be(2); + var catalog = CultMesh.Documents(handle); + catalog.CanReplace().Should().BeTrue(); + await catalog.ReplaceAsync(new MeshNoteAliasDocument + { + Schema = "tests.mesh_note.v1", + Text = "cache-catalog-replaced", + Revision = 3 + }); + cache.Get(key)!.Text.Should().Be("cache-catalog-replaced"); + Func act = () => alias.SubmitPredictionAsync(new MeshNoteAliasDocument { Schema = "tests.mesh_note.v1", Text = "cache-predicted", - Revision = 3 + Revision = 4 }); await act.Should().ThrowAsync(); } @@ -820,10 +830,12 @@ public sealed class CultMeshStreamingTests handle.CanReplace.Should().BeTrue(); alias.CanSubmitPrediction.Should().BeTrue(); + var catalog = CultMesh.Documents(handle); + catalog.CanSubmitPrediction().Should().BeTrue(); MeshNoteDocument observed = null!; using var subscription = handle.Watch(value => observed = value); - await alias.SubmitPredictionAsync(new MeshNoteAliasDocument + await catalog.SubmitPredictionAsync(new MeshNoteAliasDocument { Schema = "tests.mesh_note.v1", Text = "predicted-thermal", From a57fa1113a92e776fe8daa99dd167302c3bfa77c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 16:37:44 +0100 Subject: [PATCH 025/498] Add C# peer snapshot document handles --- src/GameCult.Mesh/CultMesh.cs | 281 ++++++++++++++++++ src/GameCult.Mesh/README.md | 11 + src/GameCult.Mesh/docs/public-api.md | 26 +- .../CultMeshStreamingTests.cs | 99 ++++++ 4 files changed, 412 insertions(+), 5 deletions(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 08ecfe36..0c015818 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -107,6 +107,39 @@ namespace GameCult.Mesh } } + /// + /// Options for opening one typed document from a remote CultNet snapshot endpoint. + /// + public sealed class CultMeshPeerSnapshotDocumentOptions + { + /// Gets or sets the semantic document id. Defaults to the record key. + public string? DocumentId { get; set; } + + /// Gets or sets the source id advertised by diagnostics. Defaults to the record key. + public string? SourceId { get; set; } + + /// Gets or sets the route hint for the resulting handle. + public CultMeshRouteHint? RouteHint { get; set; } + + /// Gets or sets the response timeout for each snapshot request. + public TimeSpan ResponseTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// Gets or sets the connection timeout for endpoint-created clients. + public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// Gets or sets the polling interval for watch fallback. + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250); + + /// Gets or sets the message id prefix used for snapshot requests. + public string? MessageIdPrefix { get; set; } + + /// Gets or sets client security options for endpoint-created clients. + public ClientSecurityOptions? Security { get; set; } + + /// Gets or sets a callback used to configure endpoint-created LiteNetLib clients. + public Action? ConfigureClient { get; set; } + } + /// /// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade. /// @@ -1165,6 +1198,144 @@ namespace GameCult.Mesh return Document(database, key, verse.Context, documentId, sources, routeHint); } + /// + /// Creates a typed document handle over one remote CultNet snapshot response provider. + /// + public static CultMeshDocumentHandle DocumentFromPeerSnapshot( + Func> snapshot, + string recordKey, + CultMeshVerseContext context, + CultMeshPeerSnapshotDocumentOptions? options = null) + where TDocument : class + { + if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var resolvedOptions = options ?? new CultMeshPeerSnapshotDocumentOptions(); + var documentId = string.IsNullOrWhiteSpace(resolvedOptions.DocumentId) + ? recordKey + : resolvedOptions.DocumentId!; + var route = resolvedOptions.RouteHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Network, "CultNet snapshot"); + var sources = new[] + { + ProjectionSource( + string.IsNullOrWhiteSpace(resolvedOptions.SourceId) ? recordKey : resolvedOptions.SourceId!, + descriptor.SchemaId, + "CultNet snapshot") + }; + Func> latest = async queryContext => + ReadDocumentFromSnapshotResponse( + await snapshot(queryContext).ConfigureAwait(false), + descriptor.SchemaId, + recordKey); + var watch = PollingQueryWatcher( + async (_parameters, queryContext) => await latest(queryContext).ConfigureAwait(false), + new CultMeshPollingWatchOptions(resolvedOptions.PollInterval)); + + return Document( + documentId, + context, + latest, + queryContext => watch(CultMeshDocumentQueryParameters.Empty, queryContext), + sources, + route); + } + + /// + /// Creates a typed document handle over one remote CultNet snapshot response provider. + /// + public static CultMeshDocumentHandle DocumentFromPeerSnapshot( + Func> snapshot, + string recordKey, + CultMeshVerse verse, + CultMeshPeerSnapshotDocumentOptions? options = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return DocumentFromPeerSnapshot(snapshot, recordKey, verse.Context, options); + } + + /// + /// Creates a typed document handle over one remote CultNet schema endpoint. + /// + public static CultMeshDocumentHandle DocumentFromPeerSnapshot( + string endpoint, + string recordKey, + CultMeshVerseContext context, + CultMeshPeerSnapshotDocumentOptions? options = null) + where TDocument : class + { + if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); + var resolvedOptions = options ?? new CultMeshPeerSnapshotDocumentOptions(); + return DocumentFromPeerSnapshot( + queryContext => RequestPeerSnapshotAsync( + () => CultNetSchemaClients.CreateForEndpoint(endpoint, resolvedOptions.Security, resolvedOptions.ConfigureClient), + endpoint, + recordKey, + queryContext, + resolvedOptions), + recordKey, + context, + resolvedOptions); + } + + /// + /// Creates a typed document handle over one remote CultNet schema endpoint. + /// + public static CultMeshDocumentHandle DocumentFromPeerSnapshot( + string endpoint, + string recordKey, + CultMeshVerse verse, + CultMeshPeerSnapshotDocumentOptions? options = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return DocumentFromPeerSnapshot(endpoint, recordKey, verse.Context, options); + } + + /// + /// Creates a typed document handle over one remote CultNet schema client factory. + /// + public static CultMeshDocumentHandle DocumentFromPeerSnapshot( + Func createClient, + string endpoint, + string recordKey, + CultMeshVerseContext context, + CultMeshPeerSnapshotDocumentOptions? options = null) + where TDocument : class + { + if (createClient == null) throw new ArgumentNullException(nameof(createClient)); + if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); + var resolvedOptions = options ?? new CultMeshPeerSnapshotDocumentOptions(); + return DocumentFromPeerSnapshot( + queryContext => RequestPeerSnapshotAsync( + createClient, + endpoint, + recordKey, + queryContext, + resolvedOptions), + recordKey, + context, + resolvedOptions); + } + + /// + /// Creates a typed document handle over one remote CultNet schema client factory. + /// + public static CultMeshDocumentHandle DocumentFromPeerSnapshot( + Func createClient, + string endpoint, + string recordKey, + CultMeshVerse verse, + CultMeshPeerSnapshotDocumentOptions? options = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return DocumentFromPeerSnapshot(createClient, endpoint, recordKey, verse.Context, options); + } + /// /// Creates a typed document handle directly over one CultMesh node database record. /// @@ -1639,6 +1810,116 @@ namespace GameCult.Mesh }); } + private static async Task RequestPeerSnapshotAsync( + Func createClient, + string endpoint, + string recordKey, + CultMeshQueryContext context, + CultMeshPeerSnapshotDocumentOptions options) + where TDocument : class + { + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var messageId = CreateSnapshotMessageId(options, context); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var client = createClient(); + client.OnCultNet(response => + { + if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal)) + { + completion.TrySetResult(response); + } + }); + client.OnCultNet(error => + completion.TrySetException(new InvalidOperationException(error.Error))); + + var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint); + client.Connect(host, port); + await WaitForSchemaClientConnectionAsync(client, endpoint, options.ConnectTimeout).ConfigureAwait(false); + client.SendCultNet(new CultNetSnapshotRequestMessage + { + MessageId = messageId, + SchemaIds = new[] { descriptor.SchemaId }, + RecordKeys = new[] { recordKey } + }); + + return await WaitForPeerSnapshotResponseAsync(completion.Task, endpoint, options.ResponseTimeout) + .ConfigureAwait(false); + } + + private static string CreateSnapshotMessageId( + CultMeshPeerSnapshotDocumentOptions options, + CultMeshQueryContext context) + { + var prefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) + ? $"cultmesh:{context.RuntimeId}:snapshot" + : options.MessageIdPrefix!; + return $"{prefix}:{Guid.NewGuid():N}"; + } + + private static async Task WaitForSchemaClientConnectionAsync( + ICultNetSchemaClient client, + string endpoint, + TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (!client.Connected) + { + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException($"Timed out connecting to CultNet snapshot endpoint {endpoint}."); + } + + await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); + } + } + + private static async Task WaitForPeerSnapshotResponseAsync( + Task responseTask, + string endpoint, + TimeSpan timeout) + { + var timeoutTask = Task.Delay(timeout); + var completed = await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false); + if (completed != responseTask) + { + throw new TimeoutException($"Timed out waiting for CultNet snapshot response from {endpoint}."); + } + + return await responseTask.ConfigureAwait(false); + } + + private static TDocument ReadDocumentFromSnapshotResponse( + CultNetSnapshotResponseRawMessage response, + string schemaId, + string recordKey) + where TDocument : class + { + if (response == null) throw new ArgumentNullException(nameof(response)); + var record = response.Documents.FirstOrDefault(candidate => + string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) && + string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal)) + ?? response.Documents.FirstOrDefault(candidate => + string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal)) + ?? response.Documents.FirstOrDefault(candidate => + string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal)); + if (record == null) + { + throw new InvalidOperationException( + $"CultNet snapshot response '{response.MessageId}' did not contain schema '{schemaId}' record '{recordKey}'."); + } + + if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{record.PayloadEncoding}\"."); + } + + return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped( + typeof(TDocument), + record.Payload); + } + /// /// Parses a rudp://host:port endpoint into its host/port parts. /// diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index a6699bde..77ebb014 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -221,6 +221,17 @@ player.Value!.Health = 90; await player.CommitAsync(); ``` +Remote publications use the same document handle shape: + +```csharp +var health = CultMesh.DocumentFromPeerSnapshot( + "cultnet://daemon.local:3075", + "daemon:aetheria.health.v1", + verse); + +var latest = await health.LatestAsync(); +``` + The physical cache table remains SoA for CPU-local scans: ```csharp diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index d56a5a88..7f5ef40f 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -273,6 +273,21 @@ using var subscription = cockpit.Watch(next => RenderCockpit(next)); var daemonAlias = cockpit.AsSchemaAlias(); ``` +Remote CultNet snapshots use the same document handle surface. The caller names +the endpoint, record key, and typed document; CultMesh owns the snapshot request +id, response filtering, raw MessagePack payload decode, route metadata, and +polling watch fallback: + +```csharp +var remoteHealth = CultMesh.DocumentFromPeerSnapshot( + "cultnet://daemon.local:3075", + "daemon:aetheria.health.v1", + verse); + +var health = await remoteHealth.LatestAsync(); +var uiHealth = remoteHealth.AsSchemaAlias(); +``` + A domain facade can collect handles into one schema-aware catalog: ```csharp @@ -346,11 +361,12 @@ const health = await remoteHealth.latest(); ``` Projected, remote, shared-memory, IPC, WASM, and local-cache documents all use -the same TS handle contract. The caller names the typed document or collection -it wants; the handle owns route defaults, snapshot reads, reactive watches, -schema alias validation, and replacement when the backing source is mutable. -Generated Aetheria and Ymir facades should bind those handles to a Verse once -and keep transport, quorum, and cache mechanics behind CultMesh. +the same C# and TS handle contract. The caller names the typed document or +collection it wants; the handle owns route defaults, snapshot reads, reactive +watches, schema alias validation, prediction submission, and replacement when +the backing source supports those mutations. Generated Aetheria and Ymir +facades should bind those handles to a Verse once and keep transport, quorum, +and cache mechanics behind CultMesh. Native view descriptors can name unmanaged columns without hand-entered byte sizes: diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index e4856572..fed12c54 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -848,6 +848,50 @@ public sealed class CultMeshStreamingTests (await handle.LatestAsync()).Text.Should().Be("predicted-thermal"); } + [Test] + public async Task DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments() + { + var cache = new CultCache(); + var key = new CultRecordKey("mesh-note:remote"); + await cache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "remote-snapshot", + Revision = 9 + }, new CultRecordHandle(key)); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared) + .Register(CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared)); + var requests = new List(); + + var handle = CultMesh.DocumentFromPeerSnapshot( + () => new MeshSnapshotSchemaClient(request => + { + requests.Add(request); + return registry.CreateRawSnapshotResponse(cache, request.MessageId, request); + }), + "cultnet://snapshot.test:3075", + key.Value, + CultMesh.Verse("starbridge", "unity-pilot"), + new CultMeshPeerSnapshotDocumentOptions + { + PollInterval = TimeSpan.FromMilliseconds(10), + MessageIdPrefix = "mesh-test-snapshot" + }); + var alias = handle.AsSchemaAlias(); + var catalog = CultMesh.Documents(handle); + + var snapshot = await alias.LatestAsync(); + + snapshot.Text.Should().Be("remote-snapshot"); + snapshot.Revision.Should().Be(9); + handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); + handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be(key.Value); + requests.Should().ContainSingle(); + requests[0].SchemaIds.Should().ContainSingle().Which.Should().Be(handle.SchemaId); + requests[0].RecordKeys.Should().ContainSingle().Which.Should().Be(key.Value); + (await catalog.LatestAsync()).Text.Should().Be("remote-snapshot"); + } + [Test] public async Task CollectionHandle_ReadsAndWatchesDatabaseCollectionsByIndex() { @@ -1538,3 +1582,58 @@ public sealed class MeshObjectsFacade return _visibleObjects.ExecuteAsync(request); } } + +internal sealed class MeshSnapshotSchemaClient : ICultNetSchemaClient +{ + private readonly Func _respond; + private readonly List> _snapshotHandlers = new(); + private readonly List> _errorHandlers = new(); + + public MeshSnapshotSchemaClient( + Func respond) + { + _respond = respond; + } + + public bool Connected { get; private set; } + + public void Connect(string host, int port) + { + Connected = true; + } + + public void SendCultNet(T message) where T : ICultNetSchemaMessage + { + if (message is not CultNetSnapshotRequestMessage request) + { + foreach (var handler in _errorHandlers) + { + handler(new CultNetErrorMessage { Error = $"Unexpected message {typeof(T).Name}." }); + } + + return; + } + + var response = _respond(request); + foreach (var handler in _snapshotHandlers) + { + handler(response); + } + } + + public void OnCultNet(Action callback) where T : ICultNetSchemaMessage + { + if (typeof(T) == typeof(CultNetSnapshotResponseRawMessage)) + { + _snapshotHandlers.Add(message => callback((T)(object)message)); + } + else if (typeof(T) == typeof(CultNetErrorMessage)) + { + _errorHandlers.Add(message => callback((T)(object)message)); + } + } + + public void Dispose() + { + } +} From bd095a583796ab1173653da5016df832e1ffd162 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 16:40:42 +0100 Subject: [PATCH 026/498] Request peer snapshot documents by record key --- src/GameCult.Mesh/CultMesh.cs | 1 - src/GameCult.Mesh/docs/public-api.md | 5 +++ .../CultMeshStreamingTests.cs | 31 ++++++++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 0c015818..5eefdaba 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1839,7 +1839,6 @@ namespace GameCult.Mesh client.SendCultNet(new CultNetSnapshotRequestMessage { MessageId = messageId, - SchemaIds = new[] { descriptor.SchemaId }, RecordKeys = new[] { recordKey } }); diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 7f5ef40f..52df0845 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -288,6 +288,11 @@ var health = await remoteHealth.LatestAsync(); var uiHealth = remoteHealth.AsSchemaAlias(); ``` +Snapshot requests are record-key-first so same logical documents can still be +read when another runtime generated a different schema id for a compatible +schema alias. The decode path prefers exact schema id matches when present and +falls back to the requested record key. + A domain facade can collect handles into one schema-aware catalog: ```csharp diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index fed12c54..9729d555 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using FluentAssertions; using GameCult.Caching; +using GameCult.Caching.MessagePack; using GameCult.Networking; using MessagePack; using NUnit.Framework; @@ -887,9 +888,37 @@ public sealed class CultMeshStreamingTests handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be(key.Value); requests.Should().ContainSingle(); - requests[0].SchemaIds.Should().ContainSingle().Which.Should().Be(handle.SchemaId); + requests[0].SchemaIds.Should().BeNull(); requests[0].RecordKeys.Should().ContainSingle().Which.Should().Be(key.Value); (await catalog.LatestAsync()).Text.Should().Be("remote-snapshot"); + + var aliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( + _ => Task.FromResult(new CultNetSnapshotResponseRawMessage + { + MessageId = "alias-schema", + Documents = new[] + { + new CultNetRawDocumentRecord + { + SchemaId = "remote.generated.mesh_note_alias.v99", + RecordKey = key.Value, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "record-key-fallback", + Revision = 10 + }, + typeof(MeshNoteDocument)) + } + } + }), + key.Value, + CultMesh.Verse("starbridge", "unity-pilot")); + + (await aliasSchemaHandle.LatestAsync()).Text.Should().Be("record-key-fallback"); } [Test] From a0509f223b330eadb91954b2d3bd080721d058f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 16:45:14 +0100 Subject: [PATCH 027/498] Type TS peer snapshot document handles --- packages/cultmesh-ts/README.md | 6 +- packages/cultmesh-ts/src/index.ts | 101 ++++++++++++++++++--- packages/cultmesh-ts/test/cultmesh.test.ts | 7 +- 3 files changed, 97 insertions(+), 17 deletions(-) diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index b3ebbf8a..25e84c3f 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -288,7 +288,7 @@ await client.syncSchemaCatalog(schemas, { kinds: ["document_payload"] }); const health = CultMesh.documentFromPeerSnapshot( () => client, - "gamecult.aetheria.daemon_health.v1", + daemonHealthDocument, "daemon:aetheria.health.v1", { routeHint: CultMesh.routeHint("network", endpoint.uri), @@ -313,7 +313,9 @@ payload binary normalization, and MessagePack decode behind the same `CultMeshDocumentHandle` shape as local documents. The helper prefers an exact schema id match and falls back to the requested record key, so runtimes that alias the same logical document through different generated schema names can -still read the publication with one CultMesh call. +still read the publication with one CultMesh call. Pass a CultCache document +definition instead of a raw schema id when you want a typed handle whose +`latest()` result is parsed through that definition. ## Streaming Mode diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index bd1c6ba9..b00ffaaa 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -2003,6 +2003,32 @@ export function cultMeshDocumentFromPeerSnapshot( peer: CultNetPeer | (() => CultNetPeer | Promise), schemaId: string, recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, +): CultMeshDocumentHandle; +export function cultMeshDocumentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + definition: TDefinition, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, +): CultMeshDocumentHandle>; +export function cultMeshDocumentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + recordKey: string, options: { documentId?: string; routeHint?: CultMeshRouteHint; @@ -2012,29 +2038,41 @@ export function cultMeshDocumentFromPeerSnapshot( messageIdPrefix?: string; } = {}, ): CultMeshDocumentHandle { - requireNonEmpty(schemaId, "schemaId"); + const schema = typeof schemaOrDefinition === "string" + ? { schemaId: schemaOrDefinition } + : cultMeshSchemaFromDefinition(schemaOrDefinition); + const resolvedSchemaId = schema.schemaId ?? schema.type; + if (!resolvedSchemaId) { + throw new Error("CultMesh peer snapshot document requires a schema id or document type."); + } + if (typeof schemaOrDefinition === "string") { + requireNonEmpty(schemaOrDefinition, "schemaId"); + } requireNonEmpty(recordKey, "recordKey"); const documentId = options.documentId ?? recordKey; const read = async () => - decodeCultNetRawDocumentPayload( - await requestCultNetRawSnapshotDocument( - peer, - schemaId, - recordKey, - { - timeoutMs: options.timeoutMs, - messageIdPrefix: options.messageIdPrefix ?? documentId, - }, + parseCultMeshPeerSnapshotDocument( + schemaOrDefinition, + decodeCultNetRawDocumentPayload( + await requestCultNetRawSnapshotDocument( + peer, + resolvedSchemaId, + recordKey, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix ?? documentId, + }, + ), ), ); return cultMeshDocument( documentId, - { schemaId }, + schema, async () => read(), { routeHint: options.routeHint ?? cultMeshRouteHint("network", "CultNet snapshot"), - sources: [cultMeshProjectionSource(options.sourceId ?? documentId, { schemaId })], + sources: [cultMeshProjectionSource(options.sourceId ?? documentId, { schemaId: resolvedSchemaId })], watchDocument: cultMeshPollingDocumentWatcher(read, { intervalMs: options.pollMs ?? 250, }), @@ -3754,6 +3792,32 @@ export class CultMesh { peer: CultNetPeer | (() => CultNetPeer | Promise), schemaId: string, recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, + ): CultMeshDocumentHandle; + public static documentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + definition: TDefinition, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, + ): CultMeshDocumentHandle>; + public static documentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + recordKey: string, options: { documentId?: string; routeHint?: CultMeshRouteHint; @@ -3763,7 +3827,9 @@ export class CultMesh { messageIdPrefix?: string; } = {}, ): CultMeshDocumentHandle { - return cultMeshDocumentFromPeerSnapshot(peer, schemaId, recordKey, options); + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromPeerSnapshot(peer, schemaOrDefinition, recordKey, options) + : cultMeshDocumentFromPeerSnapshot(peer, schemaOrDefinition, recordKey, options); } public static globalDocumentFromCache( @@ -4862,6 +4928,15 @@ function decodeCultNetRawDocumentPayload(document: CultNetRawDocumentRecord): un return decode(toUint8Array(document.payload)); } +function parseCultMeshPeerSnapshotDocument( + schemaOrDefinition: string | TDefinition, + value: unknown, +): unknown | CultCacheDocumentValue { + return typeof schemaOrDefinition === "string" + ? value + : schemaOrDefinition.schema.parse(value); +} + function isCultNetErrorMessage(message: CultNetMessage): message is CultNetErrorMessage { return message.schemaVersion === "cultnet.error.v0"; } diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 219e3eb5..08196d43 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -1950,7 +1950,7 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async ( const document = CultMesh.documentFromPeerSnapshot( peer, - "cultmesh.note.v0", + noteDocument, "note:remote", { documentId: "note:remote", @@ -1958,10 +1958,13 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async ( }, ); - assert.deepEqual(await document.latest(), { + const latest = await document.latest(); + assert.equal(latest.body, "remote handles feel local"); + assert.deepEqual(latest, { noteId: "note:remote", body: "remote handles feel local", }); + assert.equal(document.schema.type, "cultmesh.note"); const alias = CultMesh.documentFromPeerSnapshot( peer, From e905aa7a0c965a82055daa24a9a6e3bfc95655af Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 16:49:43 +0100 Subject: [PATCH 028/498] Type TS store document handles --- packages/cultmesh-ts/README.md | 5 + packages/cultmesh-ts/src/index.ts | 131 ++++++++++++++++++--- packages/cultmesh-ts/test/cultmesh.test.ts | 7 +- 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index 25e84c3f..b4946d26 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -317,6 +317,11 @@ still read the publication with one CultMesh call. Pass a CultCache document definition instead of a raw schema id when you want a typed handle whose `latest()` result is parsed through that definition. +The same typed-definition overload is available for +`CultMesh.documentFromStore(...)` and `CultMesh.documentFromSingleFile(...)`, so +local schema publications can be read as typed reactive documents without +manually pulling records, decoding MessagePack, or parsing the payload. + ## Streaming Mode CultMesh streaming mode is for audio, video, tensor, and opaque byte frames that diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index b00ffaaa..8751b50e 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -1946,6 +1946,26 @@ export function cultMeshDocumentFromCache; +export function cultMeshDocumentFromStore( + store: CacheBackingStore, + definition: TDefinition, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, +): CultMeshDocumentHandle>; +export function cultMeshDocumentFromStore( + store: CacheBackingStore, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, options: { documentId?: string; routeHint?: CultMeshRouteHint; @@ -1953,24 +1973,33 @@ export function cultMeshDocumentFromStore( pollMs?: number; } = {}, ): CultMeshDocumentHandle { - requireNonEmpty(schemaId, "schemaId"); - const documentId = options.documentId ?? schemaId; + const schema = typeof schemaOrDefinition === "string" + ? { schemaId: schemaOrDefinition } + : cultMeshSchemaFromDefinition(schemaOrDefinition); + const resolvedSchemaId = schema.schemaId ?? schema.type; + if (!resolvedSchemaId) { + throw new Error("CultMesh store document requires a schema id or document type."); + } + if (typeof schemaOrDefinition === "string") { + requireNonEmpty(schemaOrDefinition, "schemaId"); + } + const documentId = options.documentId ?? resolvedSchemaId; const read = async () => { const records = await store.pullAll(); - const record = records.find(candidate => candidate.schemaId === schemaId); + const record = records.find(candidate => candidate.schemaId === resolvedSchemaId); if (!record) { - throw new Error(`CultMesh store document '${documentId}' did not contain schema ${schemaId}.`); + throw new Error(`CultMesh store document '${documentId}' did not contain schema ${resolvedSchemaId}.`); } - return decode(record.payload); + return parseCultMeshDocumentValue(schemaOrDefinition, decode(record.payload)); }; return cultMeshDocument( documentId, - { schemaId }, + schema, async () => read(), { routeHint: options.routeHint ?? cultMeshRouteHint("in-process", "CultCache store"), - sources: [cultMeshProjectionSource(options.sourceId ?? documentId, { schemaId })], + sources: [cultMeshProjectionSource(options.sourceId ?? documentId, { schemaId: resolvedSchemaId })], watchDocument: cultMeshPollingDocumentWatcher(read, { intervalMs: options.pollMs ?? 50, }), @@ -1981,6 +2010,26 @@ export function cultMeshDocumentFromStore( export function cultMeshDocumentFromSingleFile( path: string, schemaId: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, +): CultMeshDocumentHandle; +export function cultMeshDocumentFromSingleFile( + path: string, + definition: TDefinition, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, +): CultMeshDocumentHandle>; +export function cultMeshDocumentFromSingleFile( + path: string, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, options: { documentId?: string; routeHint?: CultMeshRouteHint; @@ -1989,14 +2038,14 @@ export function cultMeshDocumentFromSingleFile( } = {}, ): CultMeshDocumentHandle { requireNonEmpty(path, "path"); - return cultMeshDocumentFromStore( - new SingleFileMessagePackBackingStore(path), - schemaId, - { - ...options, - routeHint: options.routeHint ?? cultMeshRouteHint("shared-memory", path), - }, - ); + const store = new SingleFileMessagePackBackingStore(path); + const resolvedOptions = { + ...options, + routeHint: options.routeHint ?? cultMeshRouteHint("shared-memory", path), + }; + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromStore(store, schemaOrDefinition, resolvedOptions) + : cultMeshDocumentFromStore(store, schemaOrDefinition, resolvedOptions); } export function cultMeshDocumentFromPeerSnapshot( @@ -2051,7 +2100,7 @@ export function cultMeshDocumentFromPeerSnapshot( requireNonEmpty(recordKey, "recordKey"); const documentId = options.documentId ?? recordKey; const read = async () => - parseCultMeshPeerSnapshotDocument( + parseCultMeshDocumentValue( schemaOrDefinition, decodeCultNetRawDocumentPayload( await requestCultNetRawSnapshotDocument( @@ -3765,6 +3814,26 @@ export class CultMesh { public static documentFromStore( store: CacheBackingStore, schemaId: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, + ): CultMeshDocumentHandle; + public static documentFromStore( + store: CacheBackingStore, + definition: TDefinition, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, + ): CultMeshDocumentHandle>; + public static documentFromStore( + store: CacheBackingStore, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, options: { documentId?: string; routeHint?: CultMeshRouteHint; @@ -3772,12 +3841,34 @@ export class CultMesh { pollMs?: number; } = {}, ): CultMeshDocumentHandle { - return cultMeshDocumentFromStore(store, schemaId, options); + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromStore(store, schemaOrDefinition, options) + : cultMeshDocumentFromStore(store, schemaOrDefinition, options); } public static documentFromSingleFile( path: string, schemaId: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, + ): CultMeshDocumentHandle; + public static documentFromSingleFile( + path: string, + definition: TDefinition, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + pollMs?: number; + }, + ): CultMeshDocumentHandle>; + public static documentFromSingleFile( + path: string, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, options: { documentId?: string; routeHint?: CultMeshRouteHint; @@ -3785,7 +3876,9 @@ export class CultMesh { pollMs?: number; } = {}, ): CultMeshDocumentHandle { - return cultMeshDocumentFromSingleFile(path, schemaId, options); + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromSingleFile(path, schemaOrDefinition, options) + : cultMeshDocumentFromSingleFile(path, schemaOrDefinition, options); } public static documentFromPeerSnapshot( @@ -4928,7 +5021,7 @@ function decodeCultNetRawDocumentPayload(document: CultNetRawDocumentRecord): un return decode(toUint8Array(document.payload)); } -function parseCultMeshPeerSnapshotDocument( +function parseCultMeshDocumentValue( schemaOrDefinition: string | TDefinition, value: unknown, ): unknown | CultCacheDocumentValue { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 08196d43..b80c375b 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -196,18 +196,19 @@ test("CultMesh TS document handles read schema publications from single-file sto }); await node.flush(); - const document = CultMesh.documentFromSingleFile(filePath, "cultmesh.note.v0", { + const document = CultMesh.documentFromSingleFile(filePath, noteDocument, { documentId: "daemon:cultmesh.note.latest", sourceId: "daemon:cultmesh.note.latest.v0", pollMs: 50, }); const observed: string[] = []; const unsubscribe = document.watch(value => { - observed.push(noteDocument.schema.parse(value).body); + observed.push(value.body); }); try { - assert.equal(noteDocument.schema.parse(await document.latest()).body, "published"); + assert.equal((await document.latest()).body, "published"); + assert.equal(document.schema.type, "cultmesh.note"); const republisher = await CultMesh.startNode(filePath, { documents: [noteDocument], From a6537a2d93afa8c2fd25d14dfbbd8304c0b71674 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 17:03:10 +0100 Subject: [PATCH 029/498] Add C# store document handles --- src/GameCult.Mesh/CultMesh.cs | 121 ++++++++++++++++++ src/GameCult.Mesh/README.md | 17 +++ .../CultMeshStreamingTests.cs | 87 +++++++++++++ 3 files changed, 225 insertions(+) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 5eefdaba..1f6e665f 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -140,6 +140,27 @@ namespace GameCult.Mesh public Action? ConfigureClient { get; set; } } + /// + /// Options for opening one typed document from a CultCache backing-store publication. + /// + public sealed class CultMeshStoreDocumentOptions + { + /// Gets or sets the semantic document id. Defaults to the record key. + public string? DocumentId { get; set; } + + /// Gets or sets the source id advertised by diagnostics. Defaults to the record key. + public string? SourceId { get; set; } + + /// Gets or sets the route hint for the resulting handle. + public CultMeshRouteHint? RouteHint { get; set; } + + /// Gets or sets the polling interval for watch fallback. + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250); + + /// Gets or sets the document registry used to resolve persisted schema aliases. + public CultDocumentRegistry? Registry { get; set; } + } + /// /// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade. /// @@ -1144,6 +1165,106 @@ namespace GameCult.Mesh routeHint); } + /// + /// Creates a read-only typed document handle over one CultCache backing-store publication. + /// + public static CultMeshDocumentHandle DocumentFromStore( + CacheBackingStore store, + CultRecordKey key, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? options = null) + where TDocument : class + { + if (store == null) throw new ArgumentNullException(nameof(store)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var resolvedOptions = options ?? new CultMeshStoreDocumentOptions(); + var cache = new CultCache(resolvedOptions.Registry ?? CultDocumentRegistry.Shared); + cache.AddBackingStore(store); + + var descriptor = cache.Registry.GetRequired(); + var documentId = ResolveDocumentId(resolvedOptions.DocumentId, key); + var route = resolvedOptions.RouteHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache backing store"); + var sources = new[] + { + ProjectionSource( + string.IsNullOrWhiteSpace(resolvedOptions.SourceId) ? key.Value : resolvedOptions.SourceId!, + descriptor.SchemaId, + "CultCache backing store") + }; + Func> latest = async _ => + { + await cache.PullAllBackingStoresAsync().ConfigureAwait(false); + return ReadRequired(cache, key); + }; + var watch = PollingQueryWatcher( + async (_parameters, queryContext) => await latest(queryContext).ConfigureAwait(false), + new CultMeshPollingWatchOptions(resolvedOptions.PollInterval)); + + return Document( + documentId, + context, + latest, + queryContext => watch(CultMeshDocumentQueryParameters.Empty, queryContext), + sources, + route); + } + + /// + /// Creates a read-only typed document handle over one CultCache backing-store publication. + /// + public static CultMeshDocumentHandle DocumentFromStore( + CacheBackingStore store, + CultRecordKey key, + CultMeshVerse verse, + CultMeshStoreDocumentOptions? options = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return DocumentFromStore(store, key, verse.Context, options); + } + + /// + /// Creates a read-only typed document handle over one single-file MessagePack CultCache publication. + /// + public static CultMeshDocumentHandle DocumentFromSingleFile( + string path, + CultRecordKey key, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? options = null) + where TDocument : class + { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); + var resolvedOptions = options ?? new CultMeshStoreDocumentOptions(); + var fileOptions = new CultMeshStoreDocumentOptions + { + DocumentId = resolvedOptions.DocumentId, + SourceId = resolvedOptions.SourceId, + RouteHint = resolvedOptions.RouteHint ?? new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, path), + PollInterval = resolvedOptions.PollInterval, + Registry = resolvedOptions.Registry + }; + return DocumentFromStore( + new SingleFileMessagePackBackingStore(path), + key, + context, + fileOptions); + } + + /// + /// Creates a read-only typed document handle over one single-file MessagePack CultCache publication. + /// + public static CultMeshDocumentHandle DocumentFromSingleFile( + string path, + CultRecordKey key, + CultMeshVerse verse, + CultMeshStoreDocumentOptions? options = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return DocumentFromSingleFile(path, key, verse.Context, options); + } + /// /// Creates a typed document handle directly over one distributed CultNet database record. /// diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index 77ebb014..c03e63d7 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -166,6 +166,23 @@ using var sub = input.Watch() }); ``` +Read-only CultCache publications can be lifted into the same document handle +shape without local store plumbing: + +```csharp +var health = CultMesh.DocumentFromSingleFile( + "daemon-publication.ccmp", + new CultRecordKey("daemon:aetheria.health.v1"), + verse); + +using var healthSub = health.Watch().Subscribe(RenderHealth); +var latestHealth = await health.LatestAsync(); +``` + +`DocumentFromStore()` accepts any `CacheBackingStore`; both helpers pull, +decode, resolve persisted schema metadata, and poll for updates behind the +`CultMeshDocumentHandle` surface. + ## Game Session `CultMeshGameSession` wires the common gameplay loop: local catalogs, diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 9729d555..b753e86a 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -849,6 +849,63 @@ public sealed class CultMeshStreamingTests (await handle.LatestAsync()).Text.Should().Be("predicted-thermal"); } + [Test] + public async Task DocumentHandle_ReadsSchemaPublicationsFromSingleFileStores() + { + using var directory = new TemporaryDirectory(); + var filePath = Path.Combine(directory.Path, "publication.ccmp"); + var key = new CultRecordKey("mesh-note:publication"); + + var writerCache = new CultCache(); + writerCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)); + await writerCache.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument + { + Schema = "tests.mesh_publication_note.v1", + Text = "published", + Revision = 1 + }, key); + writerCache.FlushAllBackingStores(); + File.Exists(filePath).Should().BeTrue(); + + var directReadCache = new CultCache(); + directReadCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)); + await directReadCache.PullAllBackingStoresAsync(); + directReadCache.Get(key)!.Text.Should().Be("published"); + + var handle = CultMesh.DocumentFromSingleFile( + filePath, + key, + CultMesh.Verse("starbridge", "unity-pilot"), + new CultMeshStoreDocumentOptions + { + DocumentId = "daemon:tests.mesh_note.latest", + SourceId = "daemon:tests.mesh_note.latest.v1", + PollInterval = TimeSpan.FromMilliseconds(10) + }); + var observed = new List(); + using var subscription = handle.Watch(value => observed.Add(value)); + + (await handle.LatestAsync()).Text.Should().Be("published"); + handle.CanReplace.Should().BeFalse(); + handle.CanSubmitPrediction.Should().BeFalse(); + handle.DocumentId.Should().Be("daemon:tests.mesh_note.latest"); + handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory); + handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:tests.mesh_note.latest.v1"); + + var republisher = new CultCache(); + republisher.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)); + await republisher.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument + { + Schema = "tests.mesh_publication_note.v1", + Text = "republished", + Revision = 2 + }, key); + republisher.FlushAllBackingStores(); + + await WaitForAsync(() => observed.Any(value => value.Text == "republished")); + (await handle.LatestAsync()).Revision.Should().Be(2); + } + [Test] public async Task DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments() { @@ -1393,6 +1450,22 @@ public sealed class CultMeshStreamingTests return catalog; } + private static async Task WaitForAsync(Func predicate) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(2); + while (DateTimeOffset.UtcNow < deadline) + { + if (predicate()) + { + return; + } + + await Task.Delay(20); + } + + predicate().Should().BeTrue(); + } + private sealed class TemporaryDirectory : IDisposable { public TemporaryDirectory() @@ -1470,6 +1543,20 @@ public sealed class CultMeshStreamingTests public int Revision { get; set; } } + [CultDocument("tests.mesh_publication_note", "tests.mesh_publication_note.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class MeshPublicationNoteDocument + { + [Key(0)] + public string Schema { get; set; } = string.Empty; + + [Key(1)] + public string Text { get; set; } = string.Empty; + + [Key(2)] + public int Revision { get; set; } + } + [CultDocument("tests.mesh_other", "tests.mesh_other.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class MeshOtherDocument From 47941b5fb3b6f2519710ebf7dc3ea09fd860bb2a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 17:07:04 +0100 Subject: [PATCH 030/498] Add TS configured publication document handles --- packages/cultmesh-ts/README.md | 4 + packages/cultmesh-ts/src/index.ts | 155 +++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 13 +- 3 files changed, 169 insertions(+), 3 deletions(-) diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index b4946d26..a6dbf6c8 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -321,6 +321,10 @@ The same typed-definition overload is available for `CultMesh.documentFromStore(...)` and `CultMesh.documentFromSingleFile(...)`, so local schema publications can be read as typed reactive documents without manually pulling records, decoding MessagePack, or parsing the payload. +When the caller wants source selection to be configuration, use +`CultMesh.documentFromPublication(...)` with a `single-file`, `store`, or +`peer-snapshot` source; it returns the same typed handle while hiding whether +sync came from local persistence or a remote CultNet snapshot. ## Streaming Mode diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 8751b50e..741f52be 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -67,6 +67,21 @@ export interface CultMeshRouteRecord { readonly description: string; } +export type CultMeshDocumentPublicationSource = + | { + readonly kind: "single-file"; + readonly path: string; + } + | { + readonly kind: "store"; + readonly store: CacheBackingStore; + } + | { + readonly kind: "peer-snapshot"; + readonly peer: CultNetPeer | (() => CultNetPeer | Promise); + readonly endpoint?: string; + }; + export interface CultMeshAuthorityClaim { readonly role: string; readonly shardId?: string; @@ -2129,6 +2144,102 @@ export function cultMeshDocumentFromPeerSnapshot( ); } +export function cultMeshDocumentFromPublication( + source: CultMeshDocumentPublicationSource, + schemaId: string, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, +): CultMeshDocumentHandle; +export function cultMeshDocumentFromPublication( + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, +): CultMeshDocumentHandle>; +export function cultMeshDocumentFromPublication( + source: CultMeshDocumentPublicationSource, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, +): CultMeshDocumentHandle { + if (!source) { + throw new Error("CultMesh publication source is required."); + } + requireNonEmpty(recordKey, "recordKey"); + + switch (source.kind) { + case "single-file": + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromSingleFile(source.path, schemaOrDefinition, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint, + sourceId: options.sourceId ?? recordKey, + pollMs: options.pollMs, + }) + : cultMeshDocumentFromSingleFile(source.path, schemaOrDefinition, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint, + sourceId: options.sourceId ?? recordKey, + pollMs: options.pollMs, + }); + case "store": + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromStore(source.store, schemaOrDefinition, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint, + sourceId: options.sourceId ?? recordKey, + pollMs: options.pollMs, + }) + : cultMeshDocumentFromStore(source.store, schemaOrDefinition, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint, + sourceId: options.sourceId ?? recordKey, + pollMs: options.pollMs, + }); + case "peer-snapshot": + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromPeerSnapshot(source.peer, schemaOrDefinition, recordKey, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint ?? cultMeshRouteHint("network", source.endpoint ?? "CultNet snapshot"), + sourceId: options.sourceId ?? recordKey, + timeoutMs: options.timeoutMs, + pollMs: options.pollMs, + messageIdPrefix: options.messageIdPrefix, + }) + : cultMeshDocumentFromPeerSnapshot(source.peer, schemaOrDefinition, recordKey, { + documentId: options.documentId ?? recordKey, + routeHint: options.routeHint ?? cultMeshRouteHint("network", source.endpoint ?? "CultNet snapshot"), + sourceId: options.sourceId ?? recordKey, + timeoutMs: options.timeoutMs, + pollMs: options.pollMs, + messageIdPrefix: options.messageIdPrefix, + }); + default: + throw new Error(`Unsupported CultMesh publication source kind '${(source as { kind?: string }).kind}'.`); + } +} + export function cultMeshGlobalDocumentFromCache( cache: CultCache, definition: TDefinition, @@ -3925,6 +4036,50 @@ export class CultMesh { : cultMeshDocumentFromPeerSnapshot(peer, schemaOrDefinition, recordKey, options); } + public static documentFromPublication( + source: CultMeshDocumentPublicationSource, + schemaId: string, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, + ): CultMeshDocumentHandle; + public static documentFromPublication( + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + recordKey: string, + options?: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + }, + ): CultMeshDocumentHandle>; + public static documentFromPublication( + source: CultMeshDocumentPublicationSource, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + routeHint?: CultMeshRouteHint; + sourceId?: string; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): CultMeshDocumentHandle { + return typeof schemaOrDefinition === "string" + ? cultMeshDocumentFromPublication(source, schemaOrDefinition, recordKey, options) + : cultMeshDocumentFromPublication(source, schemaOrDefinition, recordKey, options); + } + public static globalDocumentFromCache( cache: CultCache, definition: TDefinition, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index b80c375b..4aa1ab0c 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -196,7 +196,10 @@ test("CultMesh TS document handles read schema publications from single-file sto }); await node.flush(); - const document = CultMesh.documentFromSingleFile(filePath, noteDocument, { + const document = CultMesh.documentFromPublication({ + kind: "single-file", + path: filePath, + }, noteDocument, "note:publication", { documentId: "daemon:cultmesh.note.latest", sourceId: "daemon:cultmesh.note.latest.v0", pollMs: 50, @@ -1949,8 +1952,12 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async ( }, ); - const document = CultMesh.documentFromPeerSnapshot( - peer, + const document = CultMesh.documentFromPublication( + { + kind: "peer-snapshot", + peer, + endpoint: `rudp://127.0.0.1:${server.bind.port}`, + }, noteDocument, "note:remote", { From bcf877d87dadfdcd01a323fac19d4e037fc30b77 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 17:10:52 +0100 Subject: [PATCH 031/498] Add C# configured publication document handles --- src/GameCult.Mesh/CultMesh.cs | 146 ++++++++++++++++++ src/GameCult.Mesh/README.md | 3 + .../CultMeshStreamingTests.cs | 23 +-- 3 files changed, 161 insertions(+), 11 deletions(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 1f6e665f..e2e4de44 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -161,6 +161,105 @@ namespace GameCult.Mesh public CultDocumentRegistry? Registry { get; set; } } + /// + /// Describes where a typed document publication should be read from. + /// + public abstract class CultMeshDocumentPublicationSource + { + private CultMeshDocumentPublicationSource() + { + } + + /// Creates a publication source from a CultCache backing store. + public static CultMeshDocumentPublicationSource Store(CacheBackingStore store) + { + return new StoreSource(store); + } + + /// Creates a publication source from a single-file MessagePack CultCache publication. + public static CultMeshDocumentPublicationSource SingleFile(string path) + { + return new SingleFileSource(path); + } + + /// Creates a publication source from a remote CultNet snapshot response provider. + public static CultMeshDocumentPublicationSource PeerSnapshot(Func> snapshot) + { + return new PeerSnapshotSource(snapshot); + } + + /// Creates a publication source from a remote CultNet schema endpoint. + public static CultMeshDocumentPublicationSource PeerSnapshot(string endpoint) + { + return new PeerSnapshotEndpointSource(endpoint); + } + + /// Creates a publication source from a remote CultNet schema client factory. + public static CultMeshDocumentPublicationSource PeerSnapshot(Func createClient, string endpoint) + { + return new PeerSnapshotClientSource(createClient, endpoint); + } + + internal sealed class StoreSource : CultMeshDocumentPublicationSource + { + public StoreSource(CacheBackingStore store) + { + BackingStore = store ?? throw new ArgumentNullException(nameof(store)); + } + + public CacheBackingStore BackingStore { get; } + } + + internal sealed class SingleFileSource : CultMeshDocumentPublicationSource + { + public SingleFileSource(string path) + { + Path = string.IsNullOrWhiteSpace(path) + ? throw new ArgumentException("Value must be non-empty.", nameof(path)) + : path; + } + + public string Path { get; } + } + + internal sealed class PeerSnapshotSource : CultMeshDocumentPublicationSource + { + public PeerSnapshotSource(Func> snapshot) + { + Snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + } + + public Func> Snapshot { get; } + } + + internal sealed class PeerSnapshotEndpointSource : CultMeshDocumentPublicationSource + { + public PeerSnapshotEndpointSource(string endpoint) + { + Endpoint = string.IsNullOrWhiteSpace(endpoint) + ? throw new ArgumentException("Value must be non-empty.", nameof(endpoint)) + : endpoint; + } + + public string Endpoint { get; } + } + + internal sealed class PeerSnapshotClientSource : CultMeshDocumentPublicationSource + { + public PeerSnapshotClientSource(Func createClient, string endpoint) + { + CreateClient = createClient ?? throw new ArgumentNullException(nameof(createClient)); + Endpoint = string.IsNullOrWhiteSpace(endpoint) + ? throw new ArgumentException("Value must be non-empty.", nameof(endpoint)) + : endpoint; + } + + public Func CreateClient { get; } + + public string Endpoint { get; } + } + } + /// /// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade. /// @@ -1265,6 +1364,53 @@ namespace GameCult.Mesh return DocumentFromSingleFile(path, key, verse.Context, options); } + /// + /// Creates a typed document handle from a configured publication source. + /// + public static CultMeshDocumentHandle DocumentFromPublication( + CultMeshDocumentPublicationSource source, + CultRecordKey key, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null) + where TDocument : class + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + switch (source) + { + case CultMeshDocumentPublicationSource.StoreSource store: + return DocumentFromStore(store.BackingStore, key, context, storeOptions); + case CultMeshDocumentPublicationSource.SingleFileSource file: + return DocumentFromSingleFile(file.Path, key, context, storeOptions); + case CultMeshDocumentPublicationSource.PeerSnapshotSource snapshot: + return DocumentFromPeerSnapshot(snapshot.Snapshot, key.Value, context, peerOptions); + case CultMeshDocumentPublicationSource.PeerSnapshotEndpointSource endpoint: + return DocumentFromPeerSnapshot(endpoint.Endpoint, key.Value, context, peerOptions); + case CultMeshDocumentPublicationSource.PeerSnapshotClientSource client: + return DocumentFromPeerSnapshot(client.CreateClient, client.Endpoint, key.Value, context, peerOptions); + default: + throw new NotSupportedException( + $"Unsupported CultMesh document publication source '{source.GetType().FullName}'."); + } + } + + /// + /// Creates a typed document handle from a configured publication source. + /// + public static CultMeshDocumentHandle DocumentFromPublication( + CultMeshDocumentPublicationSource source, + CultRecordKey key, + CultMeshVerse verse, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return DocumentFromPublication(source, key, verse.Context, storeOptions, peerOptions); + } + /// /// Creates a typed document handle directly over one distributed CultNet database record. /// diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index c03e63d7..20ae25c2 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -182,6 +182,9 @@ var latestHealth = await health.LatestAsync(); `DocumentFromStore()` accepts any `CacheBackingStore`; both helpers pull, decode, resolve persisted schema metadata, and poll for updates behind the `CultMeshDocumentHandle` surface. +Use `DocumentFromPublication()` when the caller should provide a configured +source instead of branching between local files, backing stores, and remote +CultNet snapshots itself. ## Game Session diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index b753e86a..84db81eb 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -872,8 +872,8 @@ public sealed class CultMeshStreamingTests await directReadCache.PullAllBackingStoresAsync(); directReadCache.Get(key)!.Text.Should().Be("published"); - var handle = CultMesh.DocumentFromSingleFile( - filePath, + var handle = CultMesh.DocumentFromPublication( + CultMeshDocumentPublicationSource.SingleFile(filePath), key, CultMesh.Verse("starbridge", "unity-pilot"), new CultMeshStoreDocumentOptions @@ -921,16 +921,17 @@ public sealed class CultMeshStreamingTests .Register(CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared)); var requests = new List(); - var handle = CultMesh.DocumentFromPeerSnapshot( - () => new MeshSnapshotSchemaClient(request => - { - requests.Add(request); - return registry.CreateRawSnapshotResponse(cache, request.MessageId, request); - }), - "cultnet://snapshot.test:3075", - key.Value, + var handle = CultMesh.DocumentFromPublication( + CultMeshDocumentPublicationSource.PeerSnapshot( + () => new MeshSnapshotSchemaClient(request => + { + requests.Add(request); + return registry.CreateRawSnapshotResponse(cache, request.MessageId, request); + }), + "cultnet://snapshot.test:3075"), + key, CultMesh.Verse("starbridge", "unity-pilot"), - new CultMeshPeerSnapshotDocumentOptions + peerOptions: new CultMeshPeerSnapshotDocumentOptions { PollInterval = TimeSpan.FromMilliseconds(10), MessageIdPrefix = "mesh-test-snapshot" From 04561a5bd9ea399752b3a392ad9ff397293af08f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 17:28:15 +0100 Subject: [PATCH 032/498] Add CultMesh single-file document helpers --- src/GameCult.Mesh/CultMesh.cs | 2 +- .../CultMeshSingleFileDocuments.cs | 339 ++++++++++++++++++ .../CultMeshStreamingTests.cs | 91 +++++ 3 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 src/GameCult.Mesh/CultMeshSingleFileDocuments.cs diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index e2e4de44..6d392918 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -397,7 +397,7 @@ namespace GameCult.Mesh /// /// Public CultMesh entrypoints. /// - public static class CultMesh + public static partial class CultMesh { /// /// Creates a local CultMesh node with a durable CultCache and the canonical MessagePack backing store. diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs new file mode 100644 index 00000000..5e2295df --- /dev/null +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -0,0 +1,339 @@ +using System; +using System.Buffers; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +namespace GameCult.Mesh +{ + /// + /// Schema metadata used when publishing one raw document payload as a CultCache single-file snapshot. + /// + public sealed class CultMeshSingleFileDocumentSchema + { + /// + /// Creates schema metadata for a single-file document publication. + /// + public CultMeshSingleFileDocumentSchema(string schemaId, string schemaName, string schemaVersion) + { + if (string.IsNullOrWhiteSpace(schemaId)) throw new ArgumentException("Value must be non-empty.", nameof(schemaId)); + if (string.IsNullOrWhiteSpace(schemaName)) throw new ArgumentException("Value must be non-empty.", nameof(schemaName)); + if (string.IsNullOrWhiteSpace(schemaVersion)) throw new ArgumentException("Value must be non-empty.", nameof(schemaVersion)); + + SchemaId = schemaId; + SchemaName = schemaName; + SchemaVersion = schemaVersion; + } + + /// + /// Gets the content-derived schema identifier. + /// + public string SchemaId { get; } + + /// + /// Gets the stable schema name. + /// + public string SchemaName { get; } + + /// + /// Gets the schema version. + /// + public string SchemaVersion { get; } + + /// + /// Gets or sets an optional canonical schema description. + /// + public string CanonicalSchemaJson { get; set; } = string.Empty; + + /// + /// Gets or sets compatible schema identifiers accepted for this document. + /// + public string[] CompatibleSchemaIds { get; set; } = Array.Empty(); + + internal CultSchemaCatalogEntry ToCatalogEntry(byte[] payload) + { + return new CultSchemaCatalogEntry + { + SchemaId = SchemaId, + SchemaName = SchemaName, + SchemaVersion = SchemaVersion, + ContentHash = StableHash(payload), + CanonicalSchemaJson = CanonicalSchemaJson, + CompatibleSchemaIds = CompatibleSchemaIds + .Append(SchemaId) + .Where(candidate => !string.IsNullOrWhiteSpace(candidate)) + .Distinct(StringComparer.Ordinal) + .ToArray(), + Members = Array.Empty() + }; + } + + private static string StableHash(byte[] payload) + { + if (payload == null || payload.Length == 0) + return "empty"; + + using var sha = SHA256.Create(); + return BitConverter.ToString(sha.ComputeHash(payload)).Replace("-", "", StringComparison.Ordinal).ToLowerInvariant(); + } + } + + public static partial class CultMesh + { + /// + /// Writes one typed document as a canonical single-file MessagePack CultCache snapshot. + /// + public static void WriteSingleFileDocument( + string path, + CultRecordKey key, + TDocument document, + string? storedAt = null, + CultDocumentRegistry? registry = null) + where TDocument : class + { + if (document == null) throw new ArgumentNullException(nameof(document)); + + var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; + var descriptor = resolvedRegistry.GetRequired(); + WriteSingleFileDocumentPayload( + path, + key, + descriptor.ToCatalogEntry(), + storedAt, + CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument))); + } + + /// + /// Reads one typed document from a single-file MessagePack CultCache snapshot. + /// + public static TDocument ReadSingleFileDocument( + string path, + CultRecordKey key, + CultDocumentRegistry? registry = null) + where TDocument : class + { + var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; + var descriptor = resolvedRegistry.GetRequired(); + var payload = ReadSingleFileDocumentPayload(path, key, descriptor.SchemaId); + return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), payload); + } + + /// + /// Writes one raw document payload as a canonical single-file MessagePack CultCache snapshot. + /// + public static void WriteSingleFileDocumentPayload( + string path, + CultRecordKey key, + CultMeshSingleFileDocumentSchema schema, + string? storedAt, + byte[] payload) + { + if (schema == null) throw new ArgumentNullException(nameof(schema)); + WriteSingleFileDocumentPayload(path, key, schema.ToCatalogEntry(payload), storedAt, payload); + } + + /// + /// Reads one raw document payload from a single-file MessagePack CultCache snapshot. + /// + public static byte[] ReadSingleFileDocumentPayload( + string path, + CultRecordKey key, + string expectedSchemaId) + { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); + if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId)); + + var snapshot = ReadSingleFileSnapshot(path); + var record = snapshot.Records.SingleOrDefault(candidate => string.Equals(candidate.Key, key.Value, StringComparison.Ordinal)) + ?? throw new InvalidDataException($"CultCache document '{path}' does not contain record '{key.Value}'."); + + if (!string.Equals(record.SchemaId, expectedSchemaId, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"CultCache document '{path}' record '{key.Value}' has schema '{record.SchemaId}', expected '{expectedSchemaId}'."); + } + + if (!PublishesSchema(snapshot.SchemaCatalog, expectedSchemaId)) + { + throw new InvalidDataException( + $"CultCache document '{path}' does not publish schema '{expectedSchemaId}' in its catalog."); + } + + return record.Payload; + } + + /// + /// Reads the only raw document payload from a single-file MessagePack CultCache snapshot. + /// + public static byte[] ReadSingleFileDocumentPayload( + string path, + string expectedSchemaId) + { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); + if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId)); + + var snapshot = ReadSingleFileSnapshot(path); + if (snapshot.Records.Length != 1) + { + throw new InvalidDataException( + $"CultCache document '{path}' must contain exactly one record."); + } + + var record = snapshot.Records[0]; + if (!string.Equals(record.SchemaId, expectedSchemaId, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"CultCache document '{path}' has schema '{record.SchemaId}', expected '{expectedSchemaId}'."); + } + + if (!PublishesSchema(snapshot.SchemaCatalog, expectedSchemaId)) + { + throw new InvalidDataException( + $"CultCache document '{path}' does not publish schema '{expectedSchemaId}' in its catalog."); + } + + return record.Payload; + } + + private static void WriteSingleFileDocumentPayload( + string path, + CultRecordKey key, + CultSchemaCatalogEntry catalogEntry, + string? storedAt, + byte[] payload) + { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); + if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); + if (string.IsNullOrWhiteSpace(catalogEntry.SchemaId)) + throw new ArgumentException("Catalog entry must include a schema id.", nameof(catalogEntry)); + + payload ??= Array.Empty(); + var snapshot = new CultPersistedStoreSnapshot + { + SchemaCatalog = new[] { catalogEntry }, + Records = new[] + { + new CultPersistedRecord + { + Key = key.Value, + SchemaId = catalogEntry.SchemaId, + StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!, + Payload = payload + } + } + }; + + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + + private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path) + { + var bytes = File.ReadAllBytes(path); + try + { + return CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); + } + catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException) + { + return ReadLegacySingleFileSnapshot(bytes); + } + } + + private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes) + { + var reader = new MessagePackReader(bytes); + var fieldCount = reader.ReadArrayHeader(); + var snapshot = new CultPersistedStoreSnapshot(); + if (fieldCount > 0) + snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1"; + + if (fieldCount > 1) + { + var catalogCount = reader.ReadArrayHeader(); + snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount]; + for (var index = 0; index < catalogCount; index++) + snapshot.SchemaCatalog[index] = ReadLegacySchemaCatalogEntry(ref reader); + } + + if (fieldCount > 2) + { + var recordCount = reader.ReadArrayHeader(); + snapshot.Records = new CultPersistedRecord[recordCount]; + for (var index = 0; index < recordCount; index++) + snapshot.Records[index] = ReadLegacyPersistedRecord(ref reader); + } + + for (var index = 3; index < fieldCount; index++) + reader.Skip(); + + return snapshot; + } + + private static CultSchemaCatalogEntry ReadLegacySchemaCatalogEntry(ref MessagePackReader reader) + { + var fieldCount = reader.ReadArrayHeader(); + var entry = new CultSchemaCatalogEntry(); + if (fieldCount > 0) entry.SchemaId = reader.ReadString() ?? string.Empty; + if (fieldCount > 1) entry.SchemaName = reader.ReadString() ?? string.Empty; + if (fieldCount > 2) entry.SchemaVersion = reader.ReadString() ?? string.Empty; + if (fieldCount > 3) entry.CanonicalSchemaJson = reader.ReadString() ?? string.Empty; + if (fieldCount > 4) reader.Skip(); + if (fieldCount > 5) entry.ContentHash = reader.ReadString() ?? string.Empty; + if (fieldCount > 6) + { + var memberCount = reader.ReadArrayHeader(); + for (var index = 0; index < memberCount; index++) + reader.Skip(); + } + + for (var index = 7; index < fieldCount; index++) + reader.Skip(); + + entry.CompatibleSchemaIds = string.IsNullOrWhiteSpace(entry.SchemaId) + ? Array.Empty() + : new[] { entry.SchemaId }; + entry.Members = Array.Empty(); + return entry; + } + + private static CultPersistedRecord ReadLegacyPersistedRecord(ref MessagePackReader reader) + { + var fieldCount = reader.ReadArrayHeader(); + var record = new CultPersistedRecord(); + if (fieldCount > 0) record.Key = reader.ReadString() ?? string.Empty; + if (fieldCount > 1) record.SchemaId = reader.ReadString() ?? string.Empty; + if (fieldCount > 2) record.StoredAt = reader.ReadString() ?? string.Empty; + if (fieldCount > 3) + { + var payload = reader.ReadBytes(); + record.Payload = payload.HasValue ? payload.Value.ToArray() : Array.Empty(); + } + for (var index = 4; index < fieldCount; index++) + reader.Skip(); + return record; + } + + private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId) + { + return catalog.Any(entry => + string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal) || + entry.CompatibleSchemaIds.Any(candidate => string.Equals(candidate, schemaId, StringComparison.Ordinal))); + } + + private static void WriteFileAtomically(string path, byte[] bytes) + { + var tempPath = path + ".tmp"; + File.WriteAllBytes(tempPath, bytes); + if (File.Exists(path)) + File.Delete(path); + File.Move(tempPath, path); + } + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 84db81eb..ae862f9f 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; @@ -906,6 +907,63 @@ public sealed class CultMeshStreamingTests (await handle.LatestAsync()).Revision.Should().Be(2); } + [Test] + public async Task SingleFileDocumentHelpers_RoundTripTypedDocuments() + { + using var directory = new TemporaryDirectory(); + var filePath = Path.Combine(directory.Path, "single-document.ccmp"); + var key = new CultRecordKey("mesh-note:single-document"); + + CultMesh.WriteSingleFileDocument( + filePath, + key, + new MeshPublicationNoteDocument + { + Schema = "tests.mesh_publication_note.v1", + Text = "published-directly", + Revision = 3 + }, + storedAt: "2026-06-27T12:00:00.0000000Z"); + + var document = CultMesh.ReadSingleFileDocument(filePath, key); + + document.Text.Should().Be("published-directly"); + document.Revision.Should().Be(3); + + var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)); + await cache.PullAllBackingStoresAsync(); + cache.Get(key)!.Text.Should().Be("published-directly"); + } + + [Test] + public void SingleFileDocumentHelpers_ReadLegacySingleDocumentSnapshots() + { + using var directory = new TemporaryDirectory(); + var filePath = Path.Combine(directory.Path, "legacy-single-document.ccmp"); + var key = new CultRecordKey("mesh-note:legacy-single-document"); + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var payload = CultDocumentMessagePackSerialization.Serialize(new MeshPublicationNoteDocument + { + Schema = "tests.mesh_publication_note.v1", + Text = "legacy-published", + Revision = 4 + }); + + File.WriteAllBytes(filePath, WriteLegacySingleDocumentSnapshot( + key.Value, + descriptor.SchemaId, + descriptor.SchemaName, + descriptor.SchemaVersion, + "2026-06-27T12:00:00.0000000Z", + payload)); + + var document = CultMesh.ReadSingleFileDocument(filePath, key); + + document.Text.Should().Be("legacy-published"); + document.Revision.Should().Be(4); + } + [Test] public async Task DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments() { @@ -1438,6 +1496,39 @@ public sealed class CultMeshStreamingTests ring.Stats().UnavoidableCopyCount.Should().Be(1); } + private static byte[] WriteLegacySingleDocumentSnapshot( + string key, + string schemaId, + string schemaName, + string schemaVersion, + string storedAt, + byte[] payload) + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + + writer.WriteArrayHeader(3); + writer.Write("cultcache.store.v1"); + writer.WriteArrayHeader(1); + writer.WriteArrayHeader(7); + writer.Write(schemaId); + writer.Write(schemaName); + writer.Write(schemaVersion); + writer.Write("Legacy Aetheria single document"); + writer.Write(storedAt); + writer.Write("legacy-hash"); + writer.WriteArrayHeader(0); + writer.WriteArrayHeader(1); + writer.WriteArrayHeader(4); + writer.Write(key); + writer.Write(schemaId); + writer.Write(storedAt); + writer.Write(payload); + writer.Flush(); + + return buffer.WrittenSpan.ToArray(); + } + private static CultMeshStreamCatalog CatalogWithByteStream() { var catalog = CultMesh.CreateStreamCatalog(); From a6172aa586b427f955720996a56917897e9c7f31 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 17:36:35 +0100 Subject: [PATCH 033/498] Add CultMesh scoped snapshot helpers --- src/GameCult.Mesh/CultMeshSnapshots.cs | 257 ++++++++++++++++++ .../CultMeshStreamingTests.cs | 63 +++++ 2 files changed, 320 insertions(+) create mode 100644 src/GameCult.Mesh/CultMeshSnapshots.cs diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs new file mode 100644 index 00000000..986b901f --- /dev/null +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using GameCult.Networking; + +namespace GameCult.Mesh +{ + /// + /// Options for one scoped CultNet snapshot request. + /// + public sealed class CultMeshSnapshotRequestOptions + { + /// Gets or sets schema ids to request. Empty means no schema filter. + public IReadOnlyList? SchemaIds { get; set; } + + /// Gets or sets record keys to request. Empty means no record-key filter. + public IReadOnlyList? RecordKeys { get; set; } + + /// Gets or sets the target shard id, when the endpoint is shard-aware. + public string? ShardId { get; set; } + + /// Gets or sets the target shard epoch, when the endpoint is shard-aware. + public long? ShardEpoch { get; set; } + + /// Gets or sets the response timeout. + public TimeSpan ResponseTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// Gets or sets the connection timeout. + public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// Gets or sets the message id prefix used for snapshot requests. + public string? MessageIdPrefix { get; set; } + + /// Gets or sets client security options for endpoint-created LiteNetLib clients. + public ClientSecurityOptions? Security { get; set; } + + /// Gets or sets a callback used to configure endpoint-created LiteNetLib clients. + public Action? ConfigureClient { get; set; } + + /// Gets or sets a custom schema client factory. + public Func? CreateClient { get; set; } + + /// Gets or sets the runtime id used by endpoint-created RUDP clients. + public string? RudpRuntimeId { get; set; } + + /// Gets or sets the connection id used by endpoint-created RUDP clients. + public uint RudpConnectionId { get; set; } = 0x43554c54; + + /// Gets or sets the connect payload used by endpoint-created RUDP clients. + public string RudpConnectPayload { get; set; } = "cultnet-schema-rudp"; + + /// Gets or sets the maximum fragment size used by endpoint-created RUDP clients. + public int RudpMaxFragmentBytes { get; set; } = 1024; + + /// Gets or sets the resend delay used by endpoint-created RUDP clients. + public long RudpResendDelayMs { get; set; } = 25; + } + + public static partial class CultMesh + { + /// + /// Fetches one scoped raw CultNet snapshot from an endpoint. + /// + public static Task FetchSnapshotAsync( + string endpoint, + CultMeshSnapshotRequestOptions? options = null) + { + if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); + var resolvedOptions = options ?? new CultMeshSnapshotRequestOptions(); + return FetchSnapshotAsync(CreateSnapshotClient(endpoint, resolvedOptions), endpoint, resolvedOptions); + } + + /// + /// Fetches one scoped raw CultNet snapshot through a caller-provided schema client factory. + /// + public static async Task FetchSnapshotAsync( + Func createClient, + string endpoint, + CultMeshSnapshotRequestOptions? options = null) + { + if (createClient == null) throw new ArgumentNullException(nameof(createClient)); + if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); + + var resolvedOptions = options ?? new CultMeshSnapshotRequestOptions(); + var messageId = CreateSnapshotMessageId(resolvedOptions); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var client = createClient(); + client.OnCultNet(response => + { + if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal)) + completion.TrySetResult(response); + }); + client.OnCultNet(error => + completion.TrySetException(new InvalidOperationException(error.Error))); + + var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint); + client.Connect(host, port); + await WaitForSnapshotClientConnectionAsync(client, endpoint, resolvedOptions.ConnectTimeout) + .ConfigureAwait(false); + client.SendCultNet(new CultNetSnapshotRequestMessage + { + MessageId = messageId, + SchemaIds = CleanSnapshotFilter(resolvedOptions.SchemaIds), + RecordKeys = CleanSnapshotFilter(resolvedOptions.RecordKeys), + ShardId = string.IsNullOrWhiteSpace(resolvedOptions.ShardId) ? null : resolvedOptions.ShardId, + ShardEpoch = resolvedOptions.ShardEpoch + }); + + return await WaitForSnapshotResponseAsync( + completion.Task, + endpoint, + resolvedOptions, + messageId) + .ConfigureAwait(false); + } + + /// + /// Fetches a scoped raw CultNet snapshot and applies it into the node's cache. + /// + public static async Task> ApplySnapshotAsync( + CultMeshNode node, + string endpoint, + CultMeshSnapshotRequestOptions? options = null) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + var snapshot = await FetchSnapshotAsync(endpoint, options).ConfigureAwait(false); + return await node.Database.Documents.ApplyRawSnapshotResponseAsync(node.Cache, snapshot).ConfigureAwait(false); + } + + /// + /// Fetches a scoped raw CultNet snapshot and decodes documents assignable to the requested type. + /// + public static async Task> FetchSnapshotDocumentsAsync( + string endpoint, + CultMeshSnapshotRequestOptions? options = null, + CultNetDocumentRegistry? registry = null) + where TDocument : class + { + var snapshot = await FetchSnapshotAsync(endpoint, options).ConfigureAwait(false); + return DecodeSnapshotDocuments(snapshot, registry ?? new CultNetDocumentRegistry()); + } + + private static Func CreateSnapshotClient( + string endpoint, + CultMeshSnapshotRequestOptions options) + { + if (options.CreateClient != null) + return options.CreateClient; + + return () => + { + if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && + string.Equals(uri.Scheme, "rudp", StringComparison.OrdinalIgnoreCase)) + { + return CultNetSchemaClients.CreateRudp( + string.IsNullOrWhiteSpace(options.RudpRuntimeId) + ? "cultmesh-snapshot-client" + : options.RudpRuntimeId!, + options.RudpConnectionId, + options.RudpConnectPayload, + options.RudpMaxFragmentBytes, + options.RudpResendDelayMs); + } + + return CultNetSchemaClients.CreateForEndpoint(endpoint, options.Security, options.ConfigureClient); + }; + } + + private static string CreateSnapshotMessageId(CultMeshSnapshotRequestOptions options) + { + var prefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) + ? "cultmesh:snapshot" + : options.MessageIdPrefix!; + return $"{prefix}:{Guid.NewGuid():N}"; + } + + private static async Task WaitForSnapshotClientConnectionAsync( + ICultNetSchemaClient client, + string endpoint, + TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (!client.Connected) + { + if (DateTimeOffset.UtcNow >= deadline) + throw new TimeoutException($"Timed out connecting to CultNet snapshot endpoint {endpoint}."); + + await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); + } + } + + private static async Task WaitForSnapshotResponseAsync( + Task responseTask, + string endpoint, + CultMeshSnapshotRequestOptions options, + string messageId) + { + var timeoutTask = Task.Delay(options.ResponseTimeout); + var completed = await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false); + if (completed != responseTask) + { + throw new TimeoutException( + $"Timed out waiting for CultNet snapshot response '{messageId}' from {endpoint} " + + $"for schemas [{string.Join(", ", CleanSnapshotFilter(options.SchemaIds) ?? Array.Empty())}] " + + $"and records [{string.Join(", ", CleanSnapshotFilter(options.RecordKeys) ?? Array.Empty())}]."); + } + + return await responseTask.ConfigureAwait(false); + } + + private static string[]? CleanSnapshotFilter(IReadOnlyList? values) + { + if (values is not { Count: > 0 }) + return null; + + var filtered = values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return filtered.Length == 0 ? null : filtered; + } + + private static IReadOnlyList DecodeSnapshotDocuments( + CultNetSnapshotResponseRawMessage snapshot, + CultNetDocumentRegistry registry) + where TDocument : class + { + if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); + if (registry == null) throw new ArgumentNullException(nameof(registry)); + + var documents = new List(snapshot.Documents.Length); + foreach (var record in snapshot.Documents) + { + if (record == null) + continue; + + var binding = registry.GetBySchemaId(record.SchemaId); + if (binding == null || !typeof(TDocument).IsAssignableFrom(binding.DocumentType)) + continue; + + if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{record.PayloadEncoding}\"."); + } + + documents.Add((TDocument)binding.PayloadDeserializer(record.Payload)); + } + + return documents; + } + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index ae862f9f..c1a9240d 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1037,6 +1037,69 @@ public sealed class CultMeshStreamingTests (await aliasSchemaHandle.LatestAsync()).Text.Should().Be("record-key-fallback"); } + [Test] + public async Task SnapshotHelpers_FetchApplyAndDecodeScopedSnapshots() + { + var sourceCache = new CultCache(); + var targetCache = new CultCache(); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); + registry.Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); + var key = new CultRecordKey("mesh-note:snapshot-helper"); + await sourceCache.UpsertAsync(new MeshPublicationNoteDocument + { + Schema = "tests.mesh_publication_note.v1", + Text = "snapshot-helper", + Revision = 11 + }, new CultRecordHandle(key)); + var requests = new List(); + var endpoint = "cultnet://snapshot-helper.test:3075"; + var options = new CultMeshSnapshotRequestOptions + { + RecordKeys = new[] { key.Value }, + ShardId = "primary", + ShardEpoch = 7, + MessageIdPrefix = "mesh-test-scoped-snapshot", + CreateClient = () => new MeshSnapshotSchemaClient(request => + { + requests.Add(request); + return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request); + }) + }; + + var snapshot = await CultMesh.FetchSnapshotAsync(endpoint, options); + var decoded = await CultMesh.FetchSnapshotDocumentsAsync( + endpoint, + options, + registry); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(Path.GetTempPath(), $"cultmesh-snapshot-helper-{Guid.NewGuid():N}.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = targetCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + var applied = await CultMesh.ApplySnapshotAsync(node, endpoint, options); + + snapshot.Documents.Should().ContainSingle().Which.RecordKey.Should().Be(key.Value); + decoded.Should().ContainSingle().Which.Text.Should().Be("snapshot-helper"); + applied.Should().ContainSingle(); + node.Cache.Get(key)!.Revision.Should().Be(11); + requests.Should().HaveCount(3); + requests.Should().OnlyContain(request => + request.RecordKeys!.SequenceEqual(new[] { key.Value }) && + request.ShardId == "primary" && + request.ShardEpoch == 7 && + request.MessageId.StartsWith("mesh-test-scoped-snapshot:", StringComparison.Ordinal)); + } + [Test] public async Task CollectionHandle_ReadsAndWatchesDatabaseCollectionsByIndex() { From 8b48aa512f2dc0918ccd3d4572439f7dcdf022f2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 17:45:18 +0100 Subject: [PATCH 034/498] Add typed snapshot endpoint surface --- src/GameCult.Mesh/CultMesh.cs | 117 ++++++----- src/GameCult.Mesh/CultMeshSnapshots.cs | 190 +++++++++++++++++- .../CultMeshStreamingTests.cs | 57 +++++- 3 files changed, 300 insertions(+), 64 deletions(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 6d392918..ecc79c49 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -133,11 +133,35 @@ namespace GameCult.Mesh /// Gets or sets the message id prefix used for snapshot requests. public string? MessageIdPrefix { get; set; } + /// Gets or sets schema ids to request. Empty means the document type schema is requested. + public IReadOnlyList? SchemaIds { get; set; } + + /// Gets or sets the target shard id, when the endpoint is shard-aware. + public string? ShardId { get; set; } + + /// Gets or sets the target shard epoch, when the endpoint is shard-aware. + public long? ShardEpoch { get; set; } + /// Gets or sets client security options for endpoint-created clients. public ClientSecurityOptions? Security { get; set; } /// Gets or sets a callback used to configure endpoint-created LiteNetLib clients. public Action? ConfigureClient { get; set; } + + /// Gets or sets the runtime id used by endpoint-created RUDP clients. + public string? RudpRuntimeId { get; set; } + + /// Gets or sets the connection id used by endpoint-created RUDP clients. + public uint RudpConnectionId { get; set; } = 0x43554c54; + + /// Gets or sets the connect payload used by endpoint-created RUDP clients. + public string RudpConnectPayload { get; set; } = "cultnet-schema-rudp"; + + /// Gets or sets the maximum fragment size used by endpoint-created RUDP clients. + public int RudpMaxFragmentBytes { get; set; } = 1024; + + /// Gets or sets the resend delay used by endpoint-created RUDP clients. + public long RudpResendDelayMs { get; set; } = 25; } /// @@ -2086,73 +2110,44 @@ namespace GameCult.Mesh where TDocument : class { var descriptor = CultDocumentRegistry.Shared.GetRequired(); - var messageId = CreateSnapshotMessageId(options, context); - var completion = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - using var client = createClient(); - client.OnCultNet(response => - { - if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal)) - { - completion.TrySetResult(response); - } - }); - client.OnCultNet(error => - completion.TrySetException(new InvalidOperationException(error.Error))); - - var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint); - client.Connect(host, port); - await WaitForSchemaClientConnectionAsync(client, endpoint, options.ConnectTimeout).ConfigureAwait(false); - client.SendCultNet(new CultNetSnapshotRequestMessage - { - MessageId = messageId, - RecordKeys = new[] { recordKey } - }); - - return await WaitForPeerSnapshotResponseAsync(completion.Task, endpoint, options.ResponseTimeout) + return await FetchSnapshotAsync( + createClient, + endpoint, + ToSnapshotRequestOptions( + options, + context, + options.SchemaIds ?? new[] { descriptor.SchemaId }, + new[] { recordKey })) .ConfigureAwait(false); } - private static string CreateSnapshotMessageId( + private static CultMeshSnapshotRequestOptions ToSnapshotRequestOptions( CultMeshPeerSnapshotDocumentOptions options, - CultMeshQueryContext context) + CultMeshQueryContext context, + IReadOnlyList? schemaIds, + IReadOnlyList? recordKeys) { - var prefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) - ? $"cultmesh:{context.RuntimeId}:snapshot" - : options.MessageIdPrefix!; - return $"{prefix}:{Guid.NewGuid():N}"; - } - - private static async Task WaitForSchemaClientConnectionAsync( - ICultNetSchemaClient client, - string endpoint, - TimeSpan timeout) - { - var deadline = DateTimeOffset.UtcNow + timeout; - while (!client.Connected) + return new CultMeshSnapshotRequestOptions { - if (DateTimeOffset.UtcNow >= deadline) - { - throw new TimeoutException($"Timed out connecting to CultNet snapshot endpoint {endpoint}."); - } - - await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); - } - } - - private static async Task WaitForPeerSnapshotResponseAsync( - Task responseTask, - string endpoint, - TimeSpan timeout) - { - var timeoutTask = Task.Delay(timeout); - var completed = await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false); - if (completed != responseTask) - { - throw new TimeoutException($"Timed out waiting for CultNet snapshot response from {endpoint}."); - } - - return await responseTask.ConfigureAwait(false); + SchemaIds = schemaIds, + RecordKeys = recordKeys, + ShardId = options.ShardId, + ShardEpoch = options.ShardEpoch, + ResponseTimeout = options.ResponseTimeout, + ConnectTimeout = options.ConnectTimeout, + MessageIdPrefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) + ? $"cultmesh:{context.RuntimeId}:snapshot" + : options.MessageIdPrefix, + Security = options.Security, + ConfigureClient = options.ConfigureClient, + RudpRuntimeId = string.IsNullOrWhiteSpace(options.RudpRuntimeId) + ? context.RuntimeId + : options.RudpRuntimeId, + RudpConnectionId = options.RudpConnectionId, + RudpConnectPayload = options.RudpConnectPayload, + RudpMaxFragmentBytes = options.RudpMaxFragmentBytes, + RudpResendDelayMs = options.RudpResendDelayMs + }; } private static TDocument ReadDocumentFromSnapshotResponse( diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 986b901f..bd595799 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -59,8 +59,186 @@ namespace GameCult.Mesh public long RudpResendDelayMs { get; set; } = 25; } + /// + /// Options for a typed remote snapshot surface bound to one CultNet endpoint. + /// + public sealed class CultMeshSnapshotEndpointOptions + { + /// Gets or sets the Verse context used by handles from this endpoint. + public CultMeshVerseContext? Context { get; set; } + + /// Gets or sets the document registry used for raw snapshot payload decoding. + public CultNetDocumentRegistry? DocumentRegistry { get; set; } + + /// Gets or sets request options applied to each snapshot fetch. + public CultMeshSnapshotRequestOptions? Request { get; set; } + + /// Gets or sets the source id advertised by diagnostics. Defaults to the endpoint. + public string? SourceId { get; set; } + + /// Gets or sets the route hint for resulting handles. + public CultMeshRouteHint? RouteHint { get; set; } + + /// Gets or sets the polling interval for watch fallback. + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250); + } + + /// + /// Typed snapshot surface for one remote CultNet endpoint. + /// + public sealed class CultMeshSnapshotEndpoint + { + internal CultMeshSnapshotEndpoint(string endpoint, CultMeshSnapshotEndpointOptions? options) + { + Endpoint = string.IsNullOrWhiteSpace(endpoint) + ? throw new ArgumentException("Value must be non-empty.", nameof(endpoint)) + : endpoint; + + var resolvedOptions = options ?? new CultMeshSnapshotEndpointOptions(); + Context = resolvedOptions.Context ?? CultMesh.Verse("remote", "cultmesh-snapshot-client").Context; + DocumentRegistry = resolvedOptions.DocumentRegistry ?? new CultNetDocumentRegistry(); + Request = CloneSnapshotRequestOptions(resolvedOptions.Request); + RouteHint = resolvedOptions.RouteHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Network, Endpoint); + SourceId = string.IsNullOrWhiteSpace(resolvedOptions.SourceId) ? Endpoint : resolvedOptions.SourceId!; + PollInterval = resolvedOptions.PollInterval; + } + + /// Gets the endpoint this typed surface reads from. + public string Endpoint { get; } + + /// Gets the Verse context used by document handles from this endpoint. + public CultMeshVerseContext Context { get; } + + /// Gets the document registry used for raw snapshot payload decoding. + public CultNetDocumentRegistry DocumentRegistry { get; } + + /// Gets request options applied to each snapshot fetch. + public CultMeshSnapshotRequestOptions Request { get; } + + /// Gets the source id advertised by diagnostics. + public string SourceId { get; } + + /// Gets the route hint for resulting handles. + public CultMeshRouteHint RouteHint { get; } + + /// Gets the polling interval for watch fallback. + public TimeSpan PollInterval { get; } + + /// Fetches one raw snapshot with this endpoint's configured request policy. + public Task FetchSnapshotAsync( + IReadOnlyList? schemaIds = null, + IReadOnlyList? recordKeys = null) + { + return CultMesh.FetchSnapshotAsync(Endpoint, CreateRequest(schemaIds, recordKeys)); + } + + /// Fetches and decodes documents assignable to the requested type or matching its schema. + public Task> FetchDocumentsAsync( + IReadOnlyList? recordKeys = null, + IReadOnlyList? schemaIds = null) + where TDocument : class + { + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + return CultMesh.FetchSnapshotDocumentsAsync( + Endpoint, + CreateRequest(schemaIds ?? new[] { descriptor.SchemaId }, recordKeys), + DocumentRegistry); + } + + /// Fetches one typed document by record key. + public async Task FetchDocumentAsync(string recordKey) + where TDocument : class + { + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + var documents = await FetchDocumentsAsync(new[] { recordKey }).ConfigureAwait(false); + return documents.FirstOrDefault() + ?? throw new InvalidOperationException( + $"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'."); + } + + /// Creates a typed document handle over one remote endpoint record. + public CultMeshDocumentHandle Document( + string recordKey, + string? documentId = null) + where TDocument : class + { + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var sources = new[] + { + CultMesh.ProjectionSource($"{SourceId}:{recordKey}", descriptor.SchemaId, "CultNet snapshot endpoint") + }; + var watch = CultMesh.PollingQueryWatcher( + async (_parameters, _context) => await FetchDocumentAsync(recordKey).ConfigureAwait(false), + new CultMeshPollingWatchOptions(PollInterval)); + + return CultMesh.Document( + string.IsNullOrWhiteSpace(documentId) ? recordKey : documentId!, + Context, + _ => FetchDocumentAsync(recordKey), + queryContext => watch(CultMeshDocumentQueryParameters.Empty, queryContext), + sources, + RouteHint); + } + + /// Creates a schema-aware catalog over endpoint-backed document handles. + public CultMeshDocumentCatalog Documents(params ICultMeshDocumentHandle[] documents) + { + return CultMesh.Documents(documents); + } + + private CultMeshSnapshotRequestOptions CreateRequest( + IReadOnlyList? schemaIds, + IReadOnlyList? recordKeys) + { + var request = CloneSnapshotRequestOptions(Request); + request.SchemaIds = schemaIds ?? request.SchemaIds; + request.RecordKeys = recordKeys ?? request.RecordKeys; + if (string.IsNullOrWhiteSpace(request.RudpRuntimeId)) + request.RudpRuntimeId = Context.RuntimeId; + if (string.IsNullOrWhiteSpace(request.MessageIdPrefix)) + request.MessageIdPrefix = $"cultmesh:{Context.RuntimeId}:snapshot"; + return request; + } + + private static CultMeshSnapshotRequestOptions CloneSnapshotRequestOptions(CultMeshSnapshotRequestOptions? source) + { + if (source == null) + return new CultMeshSnapshotRequestOptions(); + + return new CultMeshSnapshotRequestOptions + { + SchemaIds = source.SchemaIds, + RecordKeys = source.RecordKeys, + ShardId = source.ShardId, + ShardEpoch = source.ShardEpoch, + ResponseTimeout = source.ResponseTimeout, + ConnectTimeout = source.ConnectTimeout, + MessageIdPrefix = source.MessageIdPrefix, + Security = source.Security, + ConfigureClient = source.ConfigureClient, + CreateClient = source.CreateClient, + RudpRuntimeId = source.RudpRuntimeId, + RudpConnectionId = source.RudpConnectionId, + RudpConnectPayload = source.RudpConnectPayload, + RudpMaxFragmentBytes = source.RudpMaxFragmentBytes, + RudpResendDelayMs = source.RudpResendDelayMs + }; + } + } + public static partial class CultMesh { + /// + /// Binds one CultNet endpoint as a typed snapshot surface. + /// + public static CultMeshSnapshotEndpoint SnapshotEndpoint( + string endpoint, + CultMeshSnapshotEndpointOptions? options = null) + { + return new CultMeshSnapshotEndpoint(endpoint, options); + } + /// /// Fetches one scoped raw CultNet snapshot from an endpoint. /// @@ -232,6 +410,7 @@ namespace GameCult.Mesh if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); if (registry == null) throw new ArgumentNullException(nameof(registry)); + var descriptor = CultDocumentRegistry.Shared.GetRequired(); var documents = new List(snapshot.Documents.Length); foreach (var record in snapshot.Documents) { @@ -239,7 +418,12 @@ namespace GameCult.Mesh continue; var binding = registry.GetBySchemaId(record.SchemaId); - if (binding == null || !typeof(TDocument).IsAssignableFrom(binding.DocumentType)) + var canDeserializeWithBinding = + binding != null && + typeof(TDocument).IsAssignableFrom(binding.DocumentType); + var canDeserializeAsSchemaAlias = + string.Equals(record.SchemaId, descriptor.SchemaId, StringComparison.Ordinal); + if (!canDeserializeWithBinding && !canDeserializeAsSchemaAlias) continue; if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal)) @@ -248,7 +432,9 @@ namespace GameCult.Mesh $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{record.PayloadEncoding}\"."); } - documents.Add((TDocument)binding.PayloadDeserializer(record.Payload)); + documents.Add(canDeserializeWithBinding + ? (TDocument)binding!.PayloadDeserializer(record.Payload) + : CultDocumentMessagePackSerialization.Deserialize(record.Payload)); } return documents; diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index c1a9240d..c93f5ee7 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1004,7 +1004,8 @@ public sealed class CultMeshStreamingTests handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be(key.Value); requests.Should().ContainSingle(); - requests[0].SchemaIds.Should().BeNull(); + requests[0].SchemaIds.Should().ContainSingle().Which + .Should().Be(CultDocumentRegistry.Shared.GetRequired().SchemaId); requests[0].RecordKeys.Should().ContainSingle().Which.Should().Be(key.Value); (await catalog.LatestAsync()).Text.Should().Be("remote-snapshot"); @@ -1100,6 +1101,60 @@ public sealed class CultMeshStreamingTests request.MessageId.StartsWith("mesh-test-scoped-snapshot:", StringComparison.Ordinal)); } + [Test] + public async Task SnapshotEndpoint_ProvidesTypedHandlesAndSchemaAliases() + { + var sourceCache = new CultCache(); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); + registry.Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); + var key = new CultRecordKey("mesh-note:snapshot-endpoint"); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "snapshot-endpoint", + Revision = 19 + }, new CultRecordHandle(key)); + var requests = new List(); + var endpoint = "cultnet://snapshot-endpoint.test:3076"; + var surface = CultMesh.SnapshotEndpoint( + endpoint, + new CultMeshSnapshotEndpointOptions + { + Context = CultMesh.Verse("starbridge", "browser-starfire").Context, + DocumentRegistry = registry, + Request = new CultMeshSnapshotRequestOptions + { + ShardId = "primary", + ShardEpoch = 9, + CreateClient = () => new MeshSnapshotSchemaClient(request => + { + requests.Add(request); + return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request); + }) + } + }); + + var fetchedAlias = await surface.FetchDocumentAsync(key.Value); + var aliasHandle = surface.Document(key.Value); + var aliasLatest = await aliasHandle.LatestAsync(); + var catalog = surface.Documents(surface.Document(key.Value)); + var catalogAlias = await catalog.LatestAsync(); + + fetchedAlias.Text.Should().Be("snapshot-endpoint"); + aliasLatest.Revision.Should().Be(19); + catalogAlias.Text.Should().Be("snapshot-endpoint"); + requests.Should().HaveCount(3); + requests.Should().OnlyContain(request => + request.SchemaIds!.SequenceEqual(new[] + { + CultDocumentRegistry.Shared.GetRequired().SchemaId + }) && + request.RecordKeys!.SequenceEqual(new[] { key.Value }) && + request.ShardId == "primary" && + request.ShardEpoch == 9 && + request.MessageId.StartsWith("cultmesh:browser-starfire:snapshot:", StringComparison.Ordinal)); + } + [Test] public async Task CollectionHandle_ReadsAndWatchesDatabaseCollectionsByIndex() { From 39e13fdb3c1e307c48d52fbf6710396e4fadf79b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 17:50:02 +0100 Subject: [PATCH 035/498] Add typed snapshot sync helpers --- src/GameCult.Mesh/CultMeshSnapshots.cs | 81 ++++++++++++++++++- .../CultMeshStreamingTests.cs | 20 ++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index bd595799..eb60a8d6 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -83,6 +83,32 @@ namespace GameCult.Mesh public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250); } + /// + /// Result from syncing a remote snapshot into a local CultMesh node. + /// + public sealed class CultMeshSnapshotSyncResult + { + internal CultMeshSnapshotSyncResult( + CultNetSnapshotResponseRawMessage snapshot, + IReadOnlyList appliedDocuments) + { + Snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + AppliedDocuments = appliedDocuments ?? throw new ArgumentNullException(nameof(appliedDocuments)); + } + + /// Gets the raw snapshot that was fetched from the endpoint. + public CultNetSnapshotResponseRawMessage Snapshot { get; } + + /// Gets the documents applied into the local node cache. + public IReadOnlyList AppliedDocuments { get; } + + /// Gets the number of documents applied into the local node cache. + public int AppliedCount => AppliedDocuments.Count; + + /// Gets the source shard log sequence reported by the snapshot, when present. + public long ShardLogSequence => Snapshot.ShardLogSequence ?? 0L; + } + /// /// Typed snapshot surface for one remote CultNet endpoint. /// @@ -132,6 +158,24 @@ namespace GameCult.Mesh return CultMesh.FetchSnapshotAsync(Endpoint, CreateRequest(schemaIds, recordKeys)); } + /// Fetches one snapshot and applies it into the node's cache. + public async Task SyncSnapshotAsync( + CultMeshNode node, + IReadOnlyList? schemaIds = null, + IReadOnlyList? recordKeys = null, + bool flush = false) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + var snapshot = await FetchSnapshotAsync(schemaIds, recordKeys).ConfigureAwait(false); + var applied = await node.Database.Documents.ApplyRawSnapshotResponseAsync(node.Cache, snapshot) + .ConfigureAwait(false); + if (flush) + await node.FlushAsync().ConfigureAwait(false); + + return new CultMeshSnapshotSyncResult(snapshot, applied); + } + /// Fetches and decodes documents assignable to the requested type or matching its schema. public Task> FetchDocumentsAsync( IReadOnlyList? recordKeys = null, @@ -145,6 +189,26 @@ namespace GameCult.Mesh DocumentRegistry); } + /// Fetches typed documents and syncs their raw snapshot into the node's cache. + public async Task> SyncDocumentsAsync( + CultMeshNode node, + IReadOnlyList? recordKeys = null, + IReadOnlyList? schemaIds = null, + bool flush = false) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var result = await SyncSnapshotAsync( + node, + schemaIds ?? new[] { descriptor.SchemaId }, + recordKeys, + flush) + .ConfigureAwait(false); + return CultMesh.DecodeSnapshotDocuments(result.Snapshot, DocumentRegistry); + } + /// Fetches one typed document by record key. public async Task FetchDocumentAsync(string recordKey) where TDocument : class @@ -156,6 +220,21 @@ namespace GameCult.Mesh $"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'."); } + /// Fetches one typed document by record key and syncs it into the node's cache. + public async Task SyncDocumentAsync( + CultMeshNode node, + string recordKey, + bool flush = false) + where TDocument : class + { + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + var documents = await SyncDocumentsAsync(node, new[] { recordKey }, flush: flush) + .ConfigureAwait(false); + return documents.FirstOrDefault() + ?? throw new InvalidOperationException( + $"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'."); + } + /// Creates a typed document handle over one remote endpoint record. public CultMeshDocumentHandle Document( string recordKey, @@ -402,7 +481,7 @@ namespace GameCult.Mesh return filtered.Length == 0 ? null : filtered; } - private static IReadOnlyList DecodeSnapshotDocuments( + internal static IReadOnlyList DecodeSnapshotDocuments( CultNetSnapshotResponseRawMessage snapshot, CultNetDocumentRegistry registry) where TDocument : class diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index c93f5ee7..bad9afb0 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1135,15 +1135,33 @@ public sealed class CultMeshStreamingTests }); var fetchedAlias = await surface.FetchDocumentAsync(key.Value); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(Path.GetTempPath(), $"cultmesh-snapshot-endpoint-{Guid.NewGuid():N}.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = sourceCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + var syncedAlias = await surface.SyncDocumentAsync(node, key.Value); var aliasHandle = surface.Document(key.Value); var aliasLatest = await aliasHandle.LatestAsync(); var catalog = surface.Documents(surface.Document(key.Value)); var catalogAlias = await catalog.LatestAsync(); fetchedAlias.Text.Should().Be("snapshot-endpoint"); + syncedAlias.Text.Should().Be("snapshot-endpoint"); + node.Cache.Get(key)!.Revision.Should().Be(19); aliasLatest.Revision.Should().Be(19); catalogAlias.Text.Should().Be("snapshot-endpoint"); - requests.Should().HaveCount(3); + requests.Should().HaveCount(4); requests.Should().OnlyContain(request => request.SchemaIds!.SequenceEqual(new[] { From c45a11e2c8cf2118dd3f7ffd5330fb94cab8f470 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:01:12 +0100 Subject: [PATCH 036/498] Add CultMesh document registry helpers --- .../CultMeshDocumentRegistries.cs | 111 ++++++++++++++++++ .../CultNetDocumentRegistry.cs | 36 ++++++ .../CultMeshStreamingTests.cs | 37 ++++++ 3 files changed, 184 insertions(+) create mode 100644 src/GameCult.Mesh/CultMeshDocumentRegistries.cs diff --git a/src/GameCult.Mesh/CultMeshDocumentRegistries.cs b/src/GameCult.Mesh/CultMeshDocumentRegistries.cs new file mode 100644 index 00000000..a210671a --- /dev/null +++ b/src/GameCult.Mesh/CultMeshDocumentRegistries.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using GameCult.Networking; + +namespace GameCult.Mesh +{ + public static partial class CultMesh + { + /// + /// Creates a CultCache document registry and resolves the supplied document types into it. + /// + public static CultDocumentRegistry CreateCultCacheDocumentRegistry(params Type[] documentTypes) + { + return CreateCultCacheDocumentRegistry((IEnumerable)documentTypes); + } + + /// + /// Creates a CultCache document registry and resolves the supplied document types into it. + /// + public static CultDocumentRegistry CreateCultCacheDocumentRegistry(IEnumerable documentTypes) + { + if (documentTypes == null) + { + throw new ArgumentNullException(nameof(documentTypes)); + } + + var registry = new CultDocumentRegistry(); + foreach (var documentType in DistinctDocumentTypes(documentTypes)) + { + registry.GetRequired(documentType); + } + + return registry; + } + + /// + /// Creates a CultNet document registry with default bindings for the supplied document types. + /// + public static CultNetDocumentRegistry CreateCultNetDocumentRegistry(params Type[] documentTypes) + { + return CreateCultNetDocumentRegistry((IEnumerable)documentTypes); + } + + /// + /// Creates a CultNet document registry with default bindings for the supplied document types. + /// + public static CultNetDocumentRegistry CreateCultNetDocumentRegistry(IEnumerable documentTypes) + { + return CreateCultNetDocumentRegistry(documentTypes, null); + } + + /// + /// Creates a CultNet document registry with default bindings for the supplied document types. + /// + public static CultNetDocumentRegistry CreateCultNetDocumentRegistry( + IEnumerable documentTypes, + CultDocumentRegistry? documents) + { + if (documentTypes == null) + { + throw new ArgumentNullException(nameof(documentTypes)); + } + + documents ??= CreateCultCacheDocumentRegistry(documentTypes); + var registry = new CultNetDocumentRegistry(documents); + var descriptors = DistinctDocumentTypes(documentTypes) + .Select(documentType => documents.GetRequired(documentType)) + .ToArray(); + foreach (var descriptorGroup in descriptors.GroupBy(descriptor => descriptor.SchemaId, StringComparer.Ordinal)) + { + foreach (var descriptor in descriptorGroup.Reverse()) + { + registry.Register(CultNetDocumentBinding.ForDocument(descriptor.DocumentType, documents)); + } + } + + return registry; + } + + /// + /// Creates a CultNet document registry with default bindings for the supplied document types. + /// + public static CultNetDocumentRegistry CreateCultNetDocumentRegistry( + CultDocumentRegistry documents, + params Type[] documentTypes) + { + return CreateCultNetDocumentRegistry(documentTypes, documents); + } + + /// + /// Creates a CultNet document registry with default bindings for the supplied document types. + /// + public static CultNetDocumentRegistry CreateCultNetDocumentRegistry( + CultDocumentRegistry documents, + IEnumerable documentTypes) + { + return CreateCultNetDocumentRegistry(documentTypes, documents); + } + + private static IEnumerable DistinctDocumentTypes(IEnumerable documentTypes) + { + return documentTypes + .Select(documentType => documentType ?? throw new ArgumentException( + "Document type collections cannot contain null entries.", + nameof(documentTypes))) + .Distinct(); + } + } +} diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index d6446b2d..fe039dfa 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -94,6 +94,42 @@ namespace GameCult.Networking ? payloadDeserializer(payload) : CultDocumentMessagePackSerialization.Deserialize(payload)); } + + /// + /// Creates a document binding for a CultCache document type discovered at runtime. + /// + public static CultNetDocumentBinding ForDocument( + Type documentType, + CultDocumentRegistry? registry = null, + string? schemaId = null, + Func? payloadSerializer = null, + Func? payloadDeserializer = null) + { + if (documentType == null) + { + throw new ArgumentNullException(nameof(documentType)); + } + + var descriptor = (registry ?? CultDocumentRegistry.Shared).GetRequired(documentType); + return new CultNetDocumentBinding( + documentType, + string.IsNullOrWhiteSpace(schemaId) ? descriptor.SchemaId : schemaId!, + document => + { + if (!documentType.IsInstanceOfType(document)) + { + throw new InvalidOperationException( + $"Document payload type {document.GetType().FullName} is not assignable to {documentType.FullName}."); + } + + return payloadSerializer != null + ? payloadSerializer(document) + : CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType); + }, + payload => payloadDeserializer != null + ? payloadDeserializer(payload) + : CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload)); + } } /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index bad9afb0..356b5dec 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1101,6 +1101,43 @@ public sealed class CultMeshStreamingTests request.MessageId.StartsWith("mesh-test-scoped-snapshot:", StringComparison.Ordinal)); } + [Test] + public async Task DocumentRegistryHelpers_CreateTypedNetworkRegistriesForAliases() + { + var sourceCache = new CultCache(); + var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry( + typeof(MeshNoteDocument), + typeof(MeshNoteAliasDocument)); + var networkRegistry = CultMesh.CreateCultNetDocumentRegistry( + new[] { typeof(MeshNoteDocument), typeof(MeshNoteAliasDocument) }, + cacheRegistry); + var key = new CultRecordKey("mesh-note:registry-helper"); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "registry-helper", + Revision = 23 + }, new CultRecordHandle(key)); + var endpoint = "cultnet://registry-helper.test:3077"; + + var documents = await CultMesh.FetchSnapshotDocumentsAsync( + endpoint, + new CultMeshSnapshotRequestOptions + { + RecordKeys = new[] { key.Value }, + CreateClient = () => new MeshSnapshotSchemaClient(request => + networkRegistry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request)) + }, + networkRegistry); + + var schemaId = cacheRegistry.GetRequired().SchemaId; + cacheRegistry.GetRequired().SchemaId.Should().Be(schemaId); + networkRegistry.GetByDocumentType(typeof(MeshNoteDocument)).Should().NotBeNull(); + networkRegistry.GetByDocumentType(typeof(MeshNoteAliasDocument)).Should().NotBeNull(); + networkRegistry.GetBySchemaId(schemaId)!.DocumentType.Should().Be(typeof(MeshNoteDocument)); + documents.Should().ContainSingle().Which.Text.Should().Be("registry-helper"); + } + [Test] public async Task SnapshotEndpoint_ProvidesTypedHandlesAndSchemaAliases() { From 7bb6d7da8c65222c614dbee51973fb40124e043b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:23:26 +0100 Subject: [PATCH 037/498] Add CultMesh multi-document latest helpers --- src/GameCult.Mesh/CultMesh.cs | 49 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 31 ++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index ecc79c49..45694e6a 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1675,6 +1675,55 @@ namespace GameCult.Mesh return new CultMeshDocumentCatalog(documents); } + /// + /// Reads two typed document handles and projects them into one caller-defined view. + /// + public static async Task LatestAsync( + CultMeshDocumentHandle first, + CultMeshDocumentHandle second, + Func project) + where TFirst : class + where TSecond : class + { + if (first == null) throw new ArgumentNullException(nameof(first)); + if (second == null) throw new ArgumentNullException(nameof(second)); + if (project == null) throw new ArgumentNullException(nameof(project)); + + var firstTask = first.LatestAsync(); + var secondTask = second.LatestAsync(); + await Task.WhenAll(firstTask, secondTask).ConfigureAwait(false); + return project( + await firstTask.ConfigureAwait(false), + await secondTask.ConfigureAwait(false)); + } + + /// + /// Reads three typed document handles and projects them into one caller-defined view. + /// + public static async Task LatestAsync( + CultMeshDocumentHandle first, + CultMeshDocumentHandle second, + CultMeshDocumentHandle third, + Func project) + where TFirst : class + where TSecond : class + where TThird : class + { + if (first == null) throw new ArgumentNullException(nameof(first)); + if (second == null) throw new ArgumentNullException(nameof(second)); + if (third == null) throw new ArgumentNullException(nameof(third)); + if (project == null) throw new ArgumentNullException(nameof(project)); + + var firstTask = first.LatestAsync(); + var secondTask = second.LatestAsync(); + var thirdTask = third.LatestAsync(); + await Task.WhenAll(firstTask, secondTask, thirdTask).ConfigureAwait(false); + return project( + await firstTask.ConfigureAwait(false), + await secondTask.ConfigureAwait(false), + await thirdTask.ConfigureAwait(false)); + } + /// /// Creates a typed collection handle over all local CultCache records assignable to the document type. /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 356b5dec..6f8fae18 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -736,6 +736,37 @@ public sealed class CultMeshStreamingTests catalog.Latest().Text.Should().Be("catalog-primary"); } + [Test] + public async Task DocumentHelpers_ProjectLatestSnapshotsFromMultipleHandles() + { + var primary = CultMesh.Document( + "mesh.note.primary", + CultMesh.Verse("starbridge", "unity-pilot"), + _ => Task.FromResult(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "alpha", + Revision = 3 + }), + _ => new Subject()); + var secondary = CultMesh.Document( + "mesh.note.secondary", + CultMesh.Verse("starbridge", "unity-pilot"), + _ => Task.FromResult(new MeshOtherDocument + { + Schema = "tests.mesh_other.v1", + Text = "beta" + }), + _ => new Subject()); + + var projected = await CultMesh.LatestAsync( + primary, + secondary, + (first, second) => $"{first.Text}:{first.Revision}:{second.Text}"); + + projected.Should().Be("alpha:3:beta"); + } + [Test] public async Task DocumentHandle_ReadsWatchesAndReplacesCultCacheRecords() { From 3720a8d39d37e2b8a98d74b38fb01cba9ff2af39 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:30:17 +0100 Subject: [PATCH 038/498] Add snapshot endpoint document bindings --- src/GameCult.Mesh/CultMeshSnapshots.cs | 71 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 4 +- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index eb60a8d6..62978b35 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -83,6 +83,55 @@ namespace GameCult.Mesh public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250); } + /// + /// Describes one typed document handle to bind from a snapshot endpoint. + /// + public interface ICultMeshSnapshotDocumentBinding + { + /// Gets the CLR document type requested by this binding. + Type DocumentType { get; } + + /// Gets the remote record key to read. + string RecordKey { get; } + + /// Gets the optional semantic document id for the resulting handle. + string? DocumentId { get; } + + /// Binds this document request to an endpoint-backed handle. + ICultMeshDocumentHandle Bind(CultMeshSnapshotEndpoint endpoint); + } + + /// + /// Describes one typed document handle to bind from a snapshot endpoint. + /// + public sealed class CultMeshSnapshotDocumentBinding : ICultMeshSnapshotDocumentBinding + where TDocument : class + { + internal CultMeshSnapshotDocumentBinding(string recordKey, string? documentId) + { + RecordKey = string.IsNullOrWhiteSpace(recordKey) + ? throw new ArgumentException("Value must be non-empty.", nameof(recordKey)) + : recordKey; + DocumentId = documentId; + } + + /// + public Type DocumentType => typeof(TDocument); + + /// + public string RecordKey { get; } + + /// + public string? DocumentId { get; } + + /// + public ICultMeshDocumentHandle Bind(CultMeshSnapshotEndpoint endpoint) + { + if (endpoint == null) throw new ArgumentNullException(nameof(endpoint)); + return endpoint.Document(RecordKey, DocumentId); + } + } + /// /// Result from syncing a remote snapshot into a local CultMesh node. /// @@ -266,6 +315,17 @@ namespace GameCult.Mesh return CultMesh.Documents(documents); } + /// Creates a schema-aware catalog from endpoint-backed typed document bindings. + public CultMeshDocumentCatalog Documents(params ICultMeshSnapshotDocumentBinding[] documents) + { + if (documents == null) throw new ArgumentNullException(nameof(documents)); + return CultMesh.Documents(documents.Select(document => + { + if (document == null) throw new ArgumentNullException(nameof(documents)); + return document.Bind(this); + })); + } + private CultMeshSnapshotRequestOptions CreateRequest( IReadOnlyList? schemaIds, IReadOnlyList? recordKeys) @@ -318,6 +378,17 @@ namespace GameCult.Mesh return new CultMeshSnapshotEndpoint(endpoint, options); } + /// + /// Describes one typed document to bind from a CultMesh snapshot endpoint. + /// + public static CultMeshSnapshotDocumentBinding SnapshotDocument( + string recordKey, + string? documentId = null) + where TDocument : class + { + return new CultMeshSnapshotDocumentBinding(recordKey, documentId); + } + /// /// Fetches one scoped raw CultNet snapshot from an endpoint. /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 6f8fae18..01443beb 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1221,13 +1221,15 @@ public sealed class CultMeshStreamingTests var syncedAlias = await surface.SyncDocumentAsync(node, key.Value); var aliasHandle = surface.Document(key.Value); var aliasLatest = await aliasHandle.LatestAsync(); - var catalog = surface.Documents(surface.Document(key.Value)); + var catalog = surface.Documents( + CultMesh.SnapshotDocument(key.Value, "daemon:mesh-note:snapshot-endpoint")); var catalogAlias = await catalog.LatestAsync(); fetchedAlias.Text.Should().Be("snapshot-endpoint"); syncedAlias.Text.Should().Be("snapshot-endpoint"); node.Cache.Get(key)!.Revision.Should().Be(19); aliasLatest.Revision.Should().Be(19); + catalog.Document().DocumentId.Should().Be("daemon:mesh-note:snapshot-endpoint"); catalogAlias.Text.Should().Be("snapshot-endpoint"); requests.Should().HaveCount(4); requests.Should().OnlyContain(request => From c8fd765aeffc2b0677c765820ce5e082e8ecebe9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:34:45 +0100 Subject: [PATCH 039/498] Add synced snapshot endpoint handles --- src/GameCult.Mesh/CultMeshSnapshots.cs | 61 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 24 +++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 62978b35..2515b4e9 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -99,6 +99,9 @@ namespace GameCult.Mesh /// Binds this document request to an endpoint-backed handle. ICultMeshDocumentHandle Bind(CultMeshSnapshotEndpoint endpoint); + + /// Binds this document request to an endpoint-backed handle that syncs into a local node before returning values. + ICultMeshDocumentHandle BindSynced(CultMeshSnapshotEndpoint endpoint, CultMeshNode node, bool flush); } /// @@ -130,6 +133,13 @@ namespace GameCult.Mesh if (endpoint == null) throw new ArgumentNullException(nameof(endpoint)); return endpoint.Document(RecordKey, DocumentId); } + + /// + public ICultMeshDocumentHandle BindSynced(CultMeshSnapshotEndpoint endpoint, CultMeshNode node, bool flush) + { + if (endpoint == null) throw new ArgumentNullException(nameof(endpoint)); + return endpoint.SyncedDocument(node, RecordKey, DocumentId, flush); + } } /// @@ -284,6 +294,34 @@ namespace GameCult.Mesh $"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'."); } + /// Creates a typed document handle that fetches one endpoint record and syncs it into a local node before returning values. + public CultMeshDocumentHandle SyncedDocument( + CultMeshNode node, + string recordKey, + string? documentId = null, + bool flush = false) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var sources = new[] + { + CultMesh.ProjectionSource($"{SourceId}:{recordKey}", descriptor.SchemaId, "synced CultNet snapshot endpoint") + }; + var watch = CultMesh.PollingQueryWatcher( + async (_parameters, _context) => await SyncDocumentAsync(node, recordKey, flush).ConfigureAwait(false), + new CultMeshPollingWatchOptions(PollInterval)); + + return CultMesh.Document( + string.IsNullOrWhiteSpace(documentId) ? recordKey : documentId!, + Context, + _ => SyncDocumentAsync(node, recordKey, flush), + queryContext => watch(CultMeshDocumentQueryParameters.Empty, queryContext), + sources, + RouteHint); + } + /// Creates a typed document handle over one remote endpoint record. public CultMeshDocumentHandle Document( string recordKey, @@ -326,6 +364,29 @@ namespace GameCult.Mesh })); } + /// Creates a schema-aware catalog from typed endpoint bindings that sync into a local node before returning values. + public CultMeshDocumentCatalog SyncedDocuments( + CultMeshNode node, + params ICultMeshSnapshotDocumentBinding[] documents) + { + return SyncedDocuments(node, flush: false, documents); + } + + /// Creates a schema-aware catalog from typed endpoint bindings that sync into a local node before returning values. + public CultMeshDocumentCatalog SyncedDocuments( + CultMeshNode node, + bool flush = false, + params ICultMeshSnapshotDocumentBinding[] documents) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (documents == null) throw new ArgumentNullException(nameof(documents)); + return CultMesh.Documents(documents.Select(document => + { + if (document == null) throw new ArgumentNullException(nameof(documents)); + return document.BindSynced(this, node, flush); + })); + } + private CultMeshSnapshotRequestOptions CreateRequest( IReadOnlyList? schemaIds, IReadOnlyList? recordKeys) diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 01443beb..65ce4752 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1224,14 +1224,34 @@ public sealed class CultMeshStreamingTests var catalog = surface.Documents( CultMesh.SnapshotDocument(key.Value, "daemon:mesh-note:snapshot-endpoint")); var catalogAlias = await catalog.LatestAsync(); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "snapshot-endpoint-synced", + Revision = 20 + }, new CultRecordHandle(key)); + var syncedHandle = surface.SyncedDocument(node, key.Value); + var syncedLatest = await syncedHandle.LatestAsync(); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "snapshot-endpoint-catalog-synced", + Revision = 21 + }, new CultRecordHandle(key)); + var syncedCatalog = surface.SyncedDocuments( + node, + CultMesh.SnapshotDocument(key.Value)); + var syncedCatalogAlias = await syncedCatalog.LatestAsync(); fetchedAlias.Text.Should().Be("snapshot-endpoint"); syncedAlias.Text.Should().Be("snapshot-endpoint"); - node.Cache.Get(key)!.Revision.Should().Be(19); aliasLatest.Revision.Should().Be(19); catalog.Document().DocumentId.Should().Be("daemon:mesh-note:snapshot-endpoint"); catalogAlias.Text.Should().Be("snapshot-endpoint"); - requests.Should().HaveCount(4); + syncedLatest.Text.Should().Be("snapshot-endpoint-synced"); + node.Cache.Get(key)!.Revision.Should().Be(21); + syncedCatalogAlias.Text.Should().Be("snapshot-endpoint-catalog-synced"); + requests.Should().HaveCount(6); requests.Should().OnlyContain(request => request.SchemaIds!.SequenceEqual(new[] { From 07d4c4164ae88c10090095ca500c2dd00f42b705 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:37:56 +0100 Subject: [PATCH 040/498] Add synced snapshot endpoint facade --- src/GameCult.Mesh/CultMeshSnapshots.cs | 46 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 6 +-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 2515b4e9..0b17e6bb 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -168,6 +168,46 @@ namespace GameCult.Mesh public long ShardLogSequence => Snapshot.ShardLogSequence ?? 0L; } + /// + /// Snapshot endpoint view with a local node sync policy already bound. + /// + public sealed class CultMeshSyncedSnapshotEndpoint + { + internal CultMeshSyncedSnapshotEndpoint( + CultMeshSnapshotEndpoint endpoint, + CultMeshNode node, + bool flush) + { + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + Node = node ?? throw new ArgumentNullException(nameof(node)); + Flush = flush; + } + + /// Gets the remote endpoint that provides snapshots. + public CultMeshSnapshotEndpoint Endpoint { get; } + + /// Gets the local node snapshots are synced into. + public CultMeshNode Node { get; } + + /// Gets whether each sync should flush the local node afterward. + public bool Flush { get; } + + /// Creates a typed document handle that syncs into the local node before returning values. + public CultMeshDocumentHandle Document( + string recordKey, + string? documentId = null) + where TDocument : class + { + return Endpoint.SyncedDocument(Node, recordKey, documentId, Flush); + } + + /// Creates a schema-aware catalog from typed endpoint bindings that sync into the local node before returning values. + public CultMeshDocumentCatalog Documents(params ICultMeshSnapshotDocumentBinding[] documents) + { + return Endpoint.SyncedDocuments(Node, Flush, documents); + } + } + /// /// Typed snapshot surface for one remote CultNet endpoint. /// @@ -209,6 +249,12 @@ namespace GameCult.Mesh /// Gets the polling interval for watch fallback. public TimeSpan PollInterval { get; } + /// Returns a view of this endpoint whose typed document handles sync into the supplied local node. + public CultMeshSyncedSnapshotEndpoint SyncTo(CultMeshNode node, bool flush = false) + { + return new CultMeshSyncedSnapshotEndpoint(this, node, flush); + } + /// Fetches one raw snapshot with this endpoint's configured request policy. public Task FetchSnapshotAsync( IReadOnlyList? schemaIds = null, diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 65ce4752..418d09f7 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1230,7 +1230,8 @@ public sealed class CultMeshStreamingTests Text = "snapshot-endpoint-synced", Revision = 20 }, new CultRecordHandle(key)); - var syncedHandle = surface.SyncedDocument(node, key.Value); + var syncedEndpoint = surface.SyncTo(node); + var syncedHandle = syncedEndpoint.Document(key.Value); var syncedLatest = await syncedHandle.LatestAsync(); await sourceCache.UpsertAsync(new MeshNoteDocument { @@ -1238,8 +1239,7 @@ public sealed class CultMeshStreamingTests Text = "snapshot-endpoint-catalog-synced", Revision = 21 }, new CultRecordHandle(key)); - var syncedCatalog = surface.SyncedDocuments( - node, + var syncedCatalog = syncedEndpoint.Documents( CultMesh.SnapshotDocument(key.Value)); var syncedCatalogAlias = await syncedCatalog.LatestAsync(); From 4189ee08115dbd715c49381673ea36f403f28cef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:45:52 +0100 Subject: [PATCH 041/498] Add CultMesh TS publication document catalogs --- packages/cultmesh-ts/src/index.ts | 111 +++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 60 +++++++++++ 2 files changed, 171 insertions(+) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 741f52be..fc427e24 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -82,6 +82,18 @@ export type CultMeshDocumentPublicationSource = readonly endpoint?: string; }; +export interface CultMeshPublicationDocumentBinding { + readonly schema: string | AnyCultCacheDocumentDefinition; + readonly recordKey: string; + readonly documentId?: string; + readonly sourceId?: string; + readonly source?: CultMeshDocumentPublicationSource; +} + +export type CultMeshDocumentPublicationSourceResolver = + | CultMeshDocumentPublicationSource + | ((binding: CultMeshPublicationDocumentBinding) => CultMeshDocumentPublicationSource); + export interface CultMeshAuthorityClaim { readonly role: string; readonly shardId?: string; @@ -2275,6 +2287,80 @@ export function cultMeshDocuments( return new CultMeshDocumentCatalog(documents); } +export function cultMeshPublicationDocument( + schema: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + sourceId?: string; + source?: CultMeshDocumentPublicationSource; + } = {}, +): CultMeshPublicationDocumentBinding { + requireNonEmpty(recordKey, "recordKey"); + return { + schema, + recordKey, + documentId: options.documentId, + sourceId: options.sourceId, + source: options.source, + }; +} + +export function cultMeshDocumentsFromPublication( + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, +): CultMeshDocumentCatalog { + if (!source) { + throw new Error("CultMesh publication source is required."); + } + if (!bindings) { + throw new Error("CultMesh publication bindings are required."); + } + + return cultMeshDocuments( + ...bindings.map(binding => { + if (!binding) { + throw new Error("CultMesh publication binding is required."); + } + const resolvedSource = binding.source ?? + (typeof source === "function" ? source(binding) : source); + return typeof binding.schema === "string" + ? cultMeshDocumentFromPublication( + resolvedSource, + binding.schema, + binding.recordKey, + { + documentId: binding.documentId ?? binding.recordKey, + sourceId: binding.sourceId ?? binding.documentId ?? binding.recordKey, + routeHint: options.routeHint, + timeoutMs: options.timeoutMs, + pollMs: options.pollMs, + messageIdPrefix: options.messageIdPrefix, + }, + ) + : cultMeshDocumentFromPublication( + resolvedSource, + binding.schema, + binding.recordKey, + { + documentId: binding.documentId ?? binding.recordKey, + sourceId: binding.sourceId ?? binding.documentId ?? binding.recordKey, + routeHint: options.routeHint, + timeoutMs: options.timeoutMs, + pollMs: options.pollMs, + messageIdPrefix: options.messageIdPrefix, + }, + ); + }), + ); +} + export function cultMeshCollection( collectionId: string, schema: CultMeshDocumentSchemaDescriptor, @@ -4098,6 +4184,31 @@ export class CultMesh { return cultMeshDocuments(...documents); } + public static publicationDocument( + schema: string | AnyCultCacheDocumentDefinition, + recordKey: string, + options: { + documentId?: string; + sourceId?: string; + source?: CultMeshDocumentPublicationSource; + } = {}, + ): CultMeshPublicationDocumentBinding { + return cultMeshPublicationDocument(schema, recordKey, options); + } + + public static documentsFromPublication( + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): CultMeshDocumentCatalog { + return cultMeshDocumentsFromPublication(source, bindings, options); + } + public static bindDocument( verse: CultMeshVerseContext | CultMeshVerse, document: CultMeshDocumentHandle, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 4aa1ab0c..2182e8b3 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -232,6 +232,66 @@ test("CultMesh TS document handles read schema publications from single-file sto ]); }); +test("CultMesh TS binds publication document catalogs from source resolvers", async () => { + const root = await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-catalog-")); + const firstPath = join(root, "first.ccmp"); + const secondPath = join(root, "second.ccmp"); + const first = await CultMesh.startNode(firstPath, { + documents: [noteDocument], + }); + const second = await CultMesh.startNode(secondPath, { + documents: [noteDocument], + }); + await first.put(noteDocument, "note:first", { + noteId: "note:first", + body: "first source", + }); + await second.put(noteDocument, "note:second", { + noteId: "note:second", + body: "second source", + }); + await first.flush(); + await second.flush(); + + const bindings = [ + CultMesh.publicationDocument(noteDocument, "note:first", { + documentId: "daemon:first", + sourceId: "daemon:first.latest", + }), + CultMesh.publicationDocument(noteDocument, "note:second", { + documentId: "daemon:second", + sourceId: "daemon:second.latest", + }), + ]; + const paths = new Map([ + ["note:first", firstPath], + ["note:second", secondPath], + ]); + const catalog = CultMesh.documentsFromPublication( + binding => ({ + kind: "single-file", + path: paths.get(binding.recordKey) ?? firstPath, + }), + bindings, + { + routeHint: CultMesh.routeHint("shared-memory", "publication catalog"), + pollMs: 50, + }, + ); + + assert.deepEqual( + catalog.documents.map(document => document.documentId), + ["daemon:first", "daemon:second"], + ); + assert.equal( + (await catalog.document(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).latest()).body, + "second source", + ); + assert.equal(catalog.document(noteDocument).routeHint.description, "publication catalog"); +}); + test("CultMesh TS collection handles expose typed snapshots and reset watches", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-coll-")), "node.ccmp"); const node = await CultMesh.startNode(filePath, { From 6f610a2908804b7f5573a4c6b3202e7c98a94316 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:50:35 +0100 Subject: [PATCH 042/498] Add transparent CultMesh TS document mutation --- packages/cultmesh-ts/src/index.ts | 133 +++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 23 +++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index fc427e24..5a9079e6 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -143,6 +143,10 @@ export type CultMeshDocumentPredictionSubmitter = ( value: TDocument, ) => Promise; +export type CultMeshDocumentUpdater = ( + current: TDocument, +) => TDocument | Promise; + export type CultMeshCollectionSnapshot = readonly TDocument[]; export type CultMeshCollectionChangeKind = "added" | "updated" | "removed" | "reset"; @@ -782,6 +786,10 @@ export class CultMeshDocumentHandle { return this.submitPredictionDocument !== undefined; } + public get canSet(): boolean { + return this.canSubmitPrediction || this.canReplace; + } + public latest(context: CultMeshQueryContext | string = "local"): Promise { return this.snapshotDocument( this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), @@ -868,6 +876,59 @@ export class CultMeshDocumentHandle { return this.submitPredictionDocument(this.resolveContext(context), value); } + public set(value: TDocument): Promise; + public set(context: CultMeshQueryContext | string, value: TDocument): Promise; + public set( + contextOrValue: CultMeshQueryContext | string | TDocument, + maybeValue?: TDocument, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? typeof contextOrValue === "string" + ? cultMeshQueryContext(contextOrValue) + : contextOrValue as CultMeshQueryContext + : cultMeshQueryContext("local"); + const value = hasContext ? maybeValue : contextOrValue as TDocument; + if (value === undefined) { + throw new Error(`Document '${this.documentId}' requires a value.`); + } + + const resolved = this.resolveContext(context); + if (this.submitPredictionDocument) { + return this.submitPredictionDocument(resolved, value); + } + if (this.replaceDocument) { + return this.replaceDocument(resolved, value); + } + + throw new Error(`Document '${this.documentId}' does not support mutation.`); + } + + public update(update: CultMeshDocumentUpdater): Promise; + public update( + context: CultMeshQueryContext | string, + update: CultMeshDocumentUpdater, + ): Promise; + public async update( + contextOrUpdate: CultMeshQueryContext | string | CultMeshDocumentUpdater, + maybeUpdate?: CultMeshDocumentUpdater, + ): Promise { + const hasContext = + typeof contextOrUpdate === "string" || isCultMeshQueryContext(contextOrUpdate); + const context = hasContext + ? contextOrUpdate as CultMeshQueryContext | string + : "local"; + const update = hasContext ? maybeUpdate : contextOrUpdate as CultMeshDocumentUpdater; + if (!update) { + throw new Error(`Document '${this.documentId}' requires an update function.`); + } + + const next = await update(await this.latest(context)); + await this.set(context, next); + return next; + } + public asSchemaAlias( schema: CultMeshDocumentSchemaDescriptor, options: { parse?: (value: unknown) => TAlias } = {}, @@ -937,6 +998,10 @@ export class CultMeshBoundDocumentHandle { return this.document.canSubmitPrediction; } + public get canSet(): boolean { + return this.document.canSet; + } + public latest(): Promise { return this.document.latest(cultMeshQueryContextFromVerse(this.verse)); } @@ -957,6 +1022,14 @@ export class CultMeshBoundDocumentHandle { return this.document.submitPrediction(cultMeshQueryContextFromVerse(this.verse), value); } + public set(value: TDocument): Promise { + return this.document.set(cultMeshQueryContextFromVerse(this.verse), value); + } + + public update(update: CultMeshDocumentUpdater): Promise { + return this.document.update(cultMeshQueryContextFromVerse(this.verse), update); + } + public asSchemaAlias( schema: CultMeshDocumentSchemaDescriptor, options: { parse?: (value: unknown) => TAlias } = {}, @@ -1072,6 +1145,10 @@ export class CultMeshDocumentCatalog { return this.tryDocument(schema)?.canSubmitPrediction ?? false; } + public canSet(schema: CultMeshDocumentSchemaDescriptor): boolean { + return this.tryDocument(schema)?.canSet ?? false; + } + public submitPrediction( schema: CultMeshDocumentSchemaDescriptor, value: TDocument, @@ -1099,6 +1176,62 @@ export class CultMeshDocumentCatalog { return this.document(schema, options).submitPrediction(context, value); } + public set( + schema: CultMeshDocumentSchemaDescriptor, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public set( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public set( + schema: CultMeshDocumentSchemaDescriptor, + contextOrValue: CultMeshQueryContext | string | TDocument, + valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? contextOrValue as CultMeshQueryContext | string + : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; + const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).set(context, value); + } + + public update( + schema: CultMeshDocumentSchemaDescriptor, + update: CultMeshDocumentUpdater, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public update( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + update: CultMeshDocumentUpdater, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public update( + schema: CultMeshDocumentSchemaDescriptor, + contextOrUpdate: CultMeshQueryContext | string | CultMeshDocumentUpdater, + updateOrOptions?: CultMeshDocumentUpdater | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrUpdate === "string" || isCultMeshQueryContext(contextOrUpdate); + const context = hasContext + ? contextOrUpdate as CultMeshQueryContext | string + : (updateOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const update = hasContext + ? updateOrOptions as CultMeshDocumentUpdater + : contextOrUpdate as CultMeshDocumentUpdater; + const options = hasContext ? maybeOptions : updateOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).update(context, update); + } + public watch( schema: CultMeshDocumentSchemaDescriptor, callback: (value: TDocument) => void, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 2182e8b3..b44b1770 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -89,6 +89,7 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti assert.equal(document.documentId, "cultmesh.note:note:1"); assert.equal(document.canReplace, true); assert.equal(document.canSubmitPrediction, false); + assert.equal(document.canSet, true); assert.equal((await bound.latest()).body, "initial"); await bound.replace({ @@ -105,7 +106,8 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti const catalog = CultMesh.documents(document); assert.equal(catalog.canReplace(noteAliasDocument), true); - await catalog.replace(noteAliasDocument, { + assert.equal(catalog.canSet(noteAliasDocument), true); + await catalog.set(noteAliasDocument, { noteId: "note:1", body: "catalog-updated", }, { @@ -118,6 +120,12 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti })).body, "catalog-updated", ); + const incremented = await bound.update(value => ({ + ...value, + body: `${value.body}:updated-through-bound-set`, + })); + assert.equal(incremented.body, "catalog-updated:updated-through-bound-set"); + assert.equal((await document.latest()).body, "catalog-updated:updated-through-bound-set"); assert.throws( () => document.asSchemaAlias({ schemaId: "cultmesh.other.v0" }), /not compatible/, @@ -150,10 +158,13 @@ test("CultMesh TS document handles submit predictions through configured authori assert.equal(document.canReplace, false); assert.equal(document.canSubmitPrediction, true); + assert.equal(document.canSet, true); assert.equal(bound.canSubmitPrediction, true); + assert.equal(bound.canSet, true); assert.equal(catalog.canSubmitPrediction(noteAliasDocument), true); + assert.equal(catalog.canSet(noteAliasDocument), true); - await catalog.submitPrediction(noteAliasDocument, "pilot-a", { + await catalog.set(noteAliasDocument, "pilot-a", { noteId: "note:prediction", body: "predicted", }, { @@ -168,13 +179,19 @@ test("CultMesh TS document handles submit predictions through configured authori parse: value => noteAliasDocument.schema.parse(value), }); assert.equal(alias.canSubmitPrediction, true); - await alias.submitPrediction({ + await alias.set({ noteId: "note:prediction", body: "alias-predicted", }); assert.deepEqual(predictions, ["predicted", "alias-predicted"]); assert.equal((await document.latest("pilot-a")).body, "alias-predicted"); + const updated = await document.update("pilot-a", value => ({ + ...value, + body: "updated-as-prediction", + })); + assert.equal(updated.body, "updated-as-prediction"); + assert.deepEqual(predictions, ["predicted", "alias-predicted", "updated-as-prediction"]); assert.throws( () => document.replace({ From 1758a9c52fbd446327d2d388e5e0935043a42478 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:58:19 +0100 Subject: [PATCH 043/498] Add transparent CultMesh document mutation --- src/GameCult.Mesh/CultMeshPrimitives.cs | 67 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 13 +++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index a35f1c24..267b330a 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -903,6 +903,9 @@ namespace GameCult.Mesh /// Gets whether this handle can submit a client prediction for the underlying document value. bool CanSubmitPrediction { get; } + /// Gets whether this handle can accept a transparent document mutation. + bool CanSet { get; } + /// Creates a same-schema alias presentation for another CLR document type. CultMeshDocumentHandle AsSchemaAlias() where TAlias : class; } @@ -964,6 +967,9 @@ namespace GameCult.Mesh /// Gets whether this handle can submit a client prediction for the underlying document value. public bool CanSubmitPrediction => _submitPrediction != null; + /// Gets whether this handle can accept a transparent document mutation. + public bool CanSet => CanSubmitPrediction || CanReplace; + /// Reads one coherent document snapshot. public Task LatestAsync() { @@ -1013,6 +1019,39 @@ namespace GameCult.Mesh return _submitPrediction(value); } + /// + /// Sets the document value through the configured authority shape. + /// Prediction-backed documents publish a prediction; mutable documents replace authoritatively. + /// + public Task SetAsync(TDocument value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (_submitPrediction != null) + return _submitPrediction(value); + if (_replace != null) + return _replace(value); + + throw new NotSupportedException($"Document handle '{DocumentId}' does not accept mutations."); + } + + /// Reads, updates, and sets the document value through the configured authority shape. + public async Task UpdateAsync(Func update) + { + if (update == null) throw new ArgumentNullException(nameof(update)); + var next = update(await LatestAsync().ConfigureAwait(false)); + await SetAsync(next).ConfigureAwait(false); + return next; + } + + /// Reads, updates, and sets the document value through the configured authority shape. + public async Task UpdateAsync(Func> update) + { + if (update == null) throw new ArgumentNullException(nameof(update)); + var next = await update(await LatestAsync().ConfigureAwait(false)).ConfigureAwait(false); + await SetAsync(next).ConfigureAwait(false); + return next; + } + /// Creates a same-schema alias presentation for another CLR document type. public CultMeshDocumentHandle AsSchemaAlias() where TAlias : class { @@ -1198,6 +1237,13 @@ namespace GameCult.Mesh return TryGetDocument(out var document) && document.CanSubmitPrediction; } + /// Gets whether one typed document can accept transparent mutations by CLR type or same-schema alias. + public bool CanSet() + where TDocument : class + { + return TryGetDocument(out var document) && document.CanSet; + } + /// Submits a client prediction for one typed document by CLR type or same-schema alias. public Task SubmitPredictionAsync(TDocument value) where TDocument : class @@ -1205,6 +1251,27 @@ namespace GameCult.Mesh return Document().SubmitPredictionAsync(value); } + /// Sets one typed document through its configured authority shape by CLR type or same-schema alias. + public Task SetAsync(TDocument value) + where TDocument : class + { + return Document().SetAsync(value); + } + + /// Reads, updates, and sets one typed document through its configured authority shape. + public Task UpdateAsync(Func update) + where TDocument : class + { + return Document().UpdateAsync(update); + } + + /// Reads, updates, and sets one typed document through its configured authority shape. + public Task UpdateAsync(Func> update) + where TDocument : class + { + return Document().UpdateAsync(update); + } + /// Watches one typed document by CLR type or same-schema alias. public Observable Watch() where TDocument : class diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 418d09f7..d228788a 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -863,12 +863,14 @@ public sealed class CultMeshStreamingTests handle.CanReplace.Should().BeTrue(); alias.CanSubmitPrediction.Should().BeTrue(); + alias.CanSet.Should().BeTrue(); var catalog = CultMesh.Documents(handle); catalog.CanSubmitPrediction().Should().BeTrue(); + catalog.CanSet().Should().BeTrue(); MeshNoteDocument observed = null!; using var subscription = handle.Watch(value => observed = value); - await catalog.SubmitPredictionAsync(new MeshNoteAliasDocument + await catalog.SetAsync(new MeshNoteAliasDocument { Schema = "tests.mesh_note.v1", Text = "predicted-thermal", @@ -879,6 +881,15 @@ public sealed class CultMeshStreamingTests observed.Text.Should().Be("predicted-thermal"); observed.Revision.Should().Be(5); (await handle.LatestAsync()).Text.Should().Be("predicted-thermal"); + + var updated = await alias.UpdateAsync(value => new MeshNoteAliasDocument + { + Schema = value.Schema, + Text = "updated-as-prediction", + Revision = value.Revision + 1 + }); + updated.Text.Should().Be("updated-as-prediction"); + cache.Get(key)!.Revision.Should().Be(6); } [Test] From bac41dd5c00ab6ecc4388fd27fcca0e449eb8231 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 19:02:46 +0100 Subject: [PATCH 044/498] Add reactive CultMesh TS document proxies --- packages/cultmesh-ts/src/index.ts | 189 +++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 38 +++++ 2 files changed, 227 insertions(+) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 5a9079e6..aba1b530 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -201,6 +201,21 @@ export interface CultMeshPollingWatchOptions { readonly equals?: (left: TResult, right: TResult) => boolean; } +export interface CultMeshReactiveDocumentOptions { + readonly context?: CultMeshQueryContext | string; + readonly watch?: boolean; + readonly debounceMs?: number; + readonly onError?: (error: unknown) => void; +} + +export interface CultMeshReactiveDocument { + readonly current: TDocument; + readonly ready: Promise; + readonly disposed: boolean; + refresh(): Promise; + dispose(): void; +} + export interface CultMeshNativeSliceColumn { readonly name: string; readonly valueType: string; @@ -929,6 +944,15 @@ export class CultMeshDocumentHandle { return next; } + public reactive( + options: CultMeshReactiveDocumentOptions = {}, + ): CultMeshReactiveDocument { + return new CultMeshReactiveDocumentHandle( + this as unknown as CultMeshDocumentHandle, + options, + ); + } + public asSchemaAlias( schema: CultMeshDocumentSchemaDescriptor, options: { parse?: (value: unknown) => TAlias } = {}, @@ -976,6 +1000,162 @@ export class CultMeshDocumentHandle { } } +class CultMeshReactiveDocumentHandle + implements CultMeshReactiveDocument { + readonly #document: CultMeshDocumentHandle; + readonly #context: CultMeshQueryContext | string; + readonly #onError: (error: unknown) => void; + readonly #debounceMs: number; + #current: TDocument; + #disposed = false; + #applyingRemote = false; + #pendingSet: Promise = Promise.resolve(); + #flushScheduled = false; + #flushTimer: ReturnType | undefined; + #unsubscribe: CultMeshUnsubscribe | undefined; + + public readonly ready: Promise; + + public constructor( + document: CultMeshDocumentHandle, + options: CultMeshReactiveDocumentOptions = {}, + ) { + this.#document = document; + this.#context = options.context ?? "local"; + this.#debounceMs = options.debounceMs ?? 0; + this.#onError = options.onError ?? ((error) => { + queueMicrotask(() => { + throw error; + }); + }); + this.#current = this.createProxy({} as TDocument); + this.ready = this.refresh(); + + if (options.watch !== false) { + try { + this.#unsubscribe = document.watch(this.#context, value => { + this.applySnapshot(value); + }); + } catch (error) { + this.#onError(error); + } + } + } + + public get current(): TDocument { + return this.#current; + } + + public get disposed(): boolean { + return this.#disposed; + } + + public async refresh(): Promise { + const latest = await this.#document.latest(this.#context); + this.applySnapshot(latest); + return this.#current; + } + + public dispose(): void { + this.#disposed = true; + if (this.#flushTimer !== undefined) { + clearTimeout(this.#flushTimer); + this.#flushTimer = undefined; + } + this.#flushScheduled = false; + this.#unsubscribe?.(); + this.#unsubscribe = undefined; + } + + private applySnapshot(value: TDocument): void { + this.#applyingRemote = true; + try { + for (const key of Reflect.ownKeys(this.#current)) { + if (!Reflect.has(value, key)) { + Reflect.deleteProperty(this.#current, key); + } + } + Object.assign(this.#current, cloneCultMeshDocument(value)); + } finally { + this.#applyingRemote = false; + } + } + + private createProxy(seed: TDocument): TDocument { + return new Proxy(seed, { + set: (target, property, value) => { + const changed = !Object.is(Reflect.get(target, property), value); + const ok = Reflect.set(target, property, value); + if (ok && changed && !this.#applyingRemote) { + this.queueSet(); + } + return ok; + }, + deleteProperty: (target, property) => { + const hadProperty = Reflect.has(target, property); + const ok = Reflect.deleteProperty(target, property); + if (ok && hadProperty && !this.#applyingRemote) { + this.queueSet(); + } + return ok; + }, + }); + } + + private queueSet(): void { + if (this.#disposed) { + return; + } + if (this.#flushScheduled) { + return; + } + + this.#flushScheduled = true; + const flush = () => { + this.#flushScheduled = false; + this.#flushTimer = undefined; + if (this.#disposed) { + return; + } + + const snapshot = cloneCultMeshDocument(this.#current); + this.#pendingSet = this.#pendingSet + .catch(() => undefined) + .then(() => this.#document.set(this.#context, snapshot)) + .catch(error => this.#onError(error)); + }; + + const debounceMs = this.#debounceMs; + if (debounceMs > 0) { + this.#flushTimer = setTimeout(flush, debounceMs); + return; + } + + const requestFrame = (globalThis as { + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + }).requestAnimationFrame; + if (typeof requestFrame === "function") { + requestFrame(() => flush()); + return; + } + + this.#flushTimer = setTimeout(flush, 0); + } +} + +function cloneCultMeshDocument(value: TDocument): TDocument { + if (typeof structuredClone === "function") { + try { + return structuredClone(value); + } catch (error) { + if ((error as { name?: string }).name !== "DataCloneError") { + throw error; + } + } + } + return JSON.parse(JSON.stringify(value)) as TDocument; +} + export class CultMeshBoundDocumentHandle { public constructor( public readonly verse: CultMeshVerseContext, @@ -1030,6 +1210,15 @@ export class CultMeshBoundDocumentHandle { return this.document.update(cultMeshQueryContextFromVerse(this.verse), update); } + public reactive( + options: Omit = {}, + ): CultMeshReactiveDocument { + return this.document.reactive({ + ...options, + context: cultMeshQueryContextFromVerse(this.verse), + }); + } + public asSchemaAlias( schema: CultMeshDocumentSchemaDescriptor, options: { parse?: (value: unknown) => TAlias } = {}, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index b44b1770..82c096e2 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -202,6 +202,44 @@ test("CultMesh TS document handles submit predictions through configured authori ); }); +test("CultMesh TS reactive documents submit predictions from member writes", async () => { + let current = { + noteId: "note:reactive", + body: "initial", + }; + const predictions: string[] = []; + const document = CultMesh.document( + "cultmesh.note:reactive", + noteDocument, + async () => current, + { + routeHint: CultMesh.routeHint("network", "CultNet prediction"), + submitPrediction: async (_context, value) => { + predictions.push(value.body); + current = value; + }, + }, + ); + + const reactive = document.reactive({ + watch: false, + }); + await reactive.ready; + reactive.current.body = "member-write-prediction"; + reactive.current.body = "member-write-prediction-final"; + await waitFor( + () => predictions.includes("member-write-prediction-final"), + "reactive member write prediction", + ); + + assert.equal(current.body, "member-write-prediction-final"); + assert.deepEqual(predictions, ["member-write-prediction-final"]); + reactive.dispose(); + reactive.current.body = "after-dispose"; + await new Promise(resolve => setTimeout(resolve, 20)); + assert.deepEqual(predictions, ["member-write-prediction-final"]); +}); + test("CultMesh TS document handles read schema publications from single-file stores", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-")), "publication.ccmp"); const node = await CultMesh.startNode(filePath, { From 0af9493ead5b894acefb3964361c65b821b6b331 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 19:06:08 +0100 Subject: [PATCH 045/498] Track reactive CultMesh reconciliation deltas --- packages/cultmesh-ts/src/index.ts | 94 +++++++++++++++++++++- packages/cultmesh-ts/test/cultmesh.test.ts | 62 ++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index aba1b530..d8edb3aa 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -208,10 +208,24 @@ export interface CultMeshReactiveDocumentOptions { readonly onError?: (error: unknown) => void; } +export type CultMeshReactiveDocumentDelta = Partial<{ + readonly [K in keyof TDocument]: TDocument[K] extends number ? number : TDocument[K]; +}>; + +export interface CultMeshReactiveDocumentReconciliation { + readonly version: number; + readonly canonical: TDocument; + readonly predicted: TDocument; + readonly delta: CultMeshReactiveDocumentDelta; + readonly receivedAtMs: number; +} + export interface CultMeshReactiveDocument { readonly current: TDocument; + readonly reconciliation: CultMeshReactiveDocumentReconciliation | undefined; readonly ready: Promise; readonly disposed: boolean; + clearReconciliation(): void; refresh(): Promise; dispose(): void; } @@ -1013,6 +1027,9 @@ class CultMeshReactiveDocumentHandle #flushScheduled = false; #flushTimer: ReturnType | undefined; #unsubscribe: CultMeshUnsubscribe | undefined; + #lastPredicted: TDocument | undefined; + #reconciliation: CultMeshReactiveDocumentReconciliation | undefined; + #reconciliationVersion = 0; public readonly ready: Promise; @@ -1046,6 +1063,10 @@ class CultMeshReactiveDocumentHandle return this.#current; } + public get reconciliation(): CultMeshReactiveDocumentReconciliation | undefined { + return this.#reconciliation; + } + public get disposed(): boolean { return this.#disposed; } @@ -1056,6 +1077,10 @@ class CultMeshReactiveDocumentHandle return this.#current; } + public clearReconciliation(): void { + this.#reconciliation = undefined; + } + public dispose(): void { this.#disposed = true; if (this.#flushTimer !== undefined) { @@ -1068,19 +1093,44 @@ class CultMeshReactiveDocumentHandle } private applySnapshot(value: TDocument): void { + const canonical = cloneCultMeshDocument(value); + this.captureReconciliation(canonical); this.#applyingRemote = true; try { for (const key of Reflect.ownKeys(this.#current)) { - if (!Reflect.has(value, key)) { + if (!Reflect.has(canonical, key)) { Reflect.deleteProperty(this.#current, key); } } - Object.assign(this.#current, cloneCultMeshDocument(value)); + Object.assign(this.#current, canonical); } finally { this.#applyingRemote = false; } } + private captureReconciliation(canonical: TDocument): void { + if (!this.#lastPredicted) { + return; + } + + const predicted = this.#lastPredicted; + const delta = createCultMeshReconciliationDelta(predicted, canonical) as + CultMeshReactiveDocumentDelta; + if (Reflect.ownKeys(delta).length === 0) { + this.#lastPredicted = undefined; + return; + } + + this.#reconciliation = { + version: ++this.#reconciliationVersion, + canonical: cloneCultMeshDocument(canonical), + predicted: cloneCultMeshDocument(predicted), + delta, + receivedAtMs: Date.now(), + }; + this.#lastPredicted = undefined; + } + private createProxy(seed: TDocument): TDocument { return new Proxy(seed, { set: (target, property, value) => { @@ -1119,6 +1169,7 @@ class CultMeshReactiveDocumentHandle } const snapshot = cloneCultMeshDocument(this.#current); + this.#lastPredicted = snapshot; this.#pendingSet = this.#pendingSet .catch(() => undefined) .then(() => this.#document.set(this.#context, snapshot)) @@ -1156,6 +1207,45 @@ function cloneCultMeshDocument(value: TDocument): TDocument { return JSON.parse(JSON.stringify(value)) as TDocument; } +function createCultMeshReconciliationDelta( + predicted: TDocument, + canonical: TDocument, +): Partial> { + const delta: Partial> = {}; + const keys = new Set([ + ...Reflect.ownKeys(predicted), + ...Reflect.ownKeys(canonical), + ]); + + for (const key of keys) { + const predictedValue = Reflect.get(predicted, key); + const canonicalValue = Reflect.get(canonical, key); + if (cultMeshValuesEqual(predictedValue, canonicalValue)) { + continue; + } + + const deltaKey = key as keyof TDocument; + delta[deltaKey] = + typeof predictedValue === "number" && typeof canonicalValue === "number" + ? predictedValue - canonicalValue + : predictedValue; + } + + return delta; +} + +function cultMeshValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + if (typeof left !== "object" || left === null || + typeof right !== "object" || right === null) { + return false; + } + + return JSON.stringify(left) === JSON.stringify(right); +} + export class CultMeshBoundDocumentHandle { public constructor( public readonly verse: CultMeshVerseContext, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 82c096e2..82ec5047 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -240,6 +240,68 @@ test("CultMesh TS reactive documents submit predictions from member writes", asy assert.deepEqual(predictions, ["member-write-prediction-final"]); }); +test("CultMesh TS reactive documents store reconciliation deltas after misprediction", async () => { + let current = { + noteId: "note:reconcile", + body: "initial", + revision: 1, + }; + let watcher: ((value: typeof current) => void) | undefined; + const predictions: typeof current[] = []; + const document = CultMesh.document( + "cultmesh.note:reconcile", + { schemaId: "cultmesh.note.reconcile.v0" }, + async () => current, + { + routeHint: CultMesh.routeHint("network", "CultNet prediction"), + submitPrediction: async (_context, value) => { + predictions.push(value); + current = value; + }, + watchDocument: (_context, callback) => { + watcher = callback; + return () => { + watcher = undefined; + }; + }, + }, + ); + + const reactive = document.reactive(); + await reactive.ready; + reactive.current.body = "predicted"; + reactive.current.revision = 7; + await waitFor(() => predictions.length === 1, "reactive reconciliation prediction"); + + watcher?.({ + noteId: "note:reconcile", + body: "authoritative", + revision: 5, + }); + + assert.equal(reactive.current.body, "authoritative"); + assert.equal(reactive.current.revision, 5); + assert.equal(reactive.reconciliation?.version, 1); + assert.deepEqual(reactive.reconciliation?.canonical, { + noteId: "note:reconcile", + body: "authoritative", + revision: 5, + }); + assert.deepEqual(reactive.reconciliation?.predicted, { + noteId: "note:reconcile", + body: "predicted", + revision: 7, + }); + assert.deepEqual(reactive.reconciliation?.delta, { + body: "predicted", + revision: 2, + }); + + reactive.clearReconciliation(); + assert.equal(reactive.reconciliation, undefined); + reactive.dispose(); +}); + test("CultMesh TS document handles read schema publications from single-file stores", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-")), "publication.ccmp"); const node = await CultMesh.startNode(filePath, { From ccd5a0e9cfde57599b4d9bb8716ff613eb02e751 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 19:19:09 +0100 Subject: [PATCH 046/498] Add managed CultMesh reactive documents --- src/GameCult.Mesh/CultMeshPrimitives.cs | 298 +++++++++++++++++- .../CultMeshStreamingTests.cs | 106 +++++++ 2 files changed, 403 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 267b330a..789a0b24 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1052,6 +1052,26 @@ namespace GameCult.Mesh return next; } + /// + /// Creates a managed reactive document mirror whose local edits can be coalesced into one prediction or replacement. + /// + public async Task> ReactiveAsync( + CultMeshReactiveDocumentOptions? options = null) + { + var current = CloneDocument(await LatestAsync().ConfigureAwait(false)); + var reactive = new CultMeshReactiveDocument(this, current, options); + reactive.Start(); + return reactive; + } + + /// + /// Creates a managed reactive document mirror synchronously for host APIs that cannot be async. + /// + public CultMeshReactiveDocument Reactive(CultMeshReactiveDocumentOptions? options = null) + { + return ReactiveAsync(options).ConfigureAwait(false).GetAwaiter().GetResult(); + } + /// Creates a same-schema alias presentation for another CLR document type. public CultMeshDocumentHandle AsSchemaAlias() where TAlias : class { @@ -1085,7 +1105,17 @@ namespace GameCult.Mesh return new CultMeshDocumentHandle(aliasFeed.Bind(Context), replace, submitPrediction); } - private static TTarget ConvertDocument(TSource document) + internal static TDocumentValue CloneDocument(TDocumentValue document) + where TDocumentValue : class + { + if (document == null) throw new ArgumentNullException(nameof(document)); + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocumentValue)); + return (TDocumentValue)CultDocumentMessagePackSerialization.DeserializeUntyped( + typeof(TDocumentValue), + payload); + } + + internal static TTarget ConvertDocument(TSource document) where TSource : class where TTarget : class { @@ -1100,6 +1130,256 @@ namespace GameCult.Mesh } } + /// + /// Configures a managed CultMesh reactive document mirror. + /// + public sealed class CultMeshReactiveDocumentOptions + { + /// + /// Gets or sets the write coalescing window. The default approximates a frame boundary for non-Unity hosts. + /// + public TimeSpan FlushDelay { get; set; } = TimeSpan.FromMilliseconds(16); + + /// Gets or sets whether canonical snapshots replace dirty local predictions immediately. + public bool ReplaceDirtyCurrentOnCanonicalSnapshot { get; set; } + } + + /// + /// Captures a canonical snapshot that arrived while the local reactive document had an outstanding prediction. + /// + public sealed class CultMeshReactiveDocumentReconciliation + where TDocument : class + { + internal CultMeshReactiveDocumentReconciliation( + TDocument canonical, + TDocument predicted, + DateTimeOffset receivedAt) + { + Canonical = canonical; + Predicted = predicted; + ReceivedAt = receivedAt; + } + + /// Gets the authoritative canonical document snapshot. + public TDocument Canonical { get; } + + /// Gets the locally predicted document snapshot that was active when the canonical value arrived. + public TDocument Predicted { get; } + + /// Gets when the canonical snapshot was received. + public DateTimeOffset ReceivedAt { get; } + } + + /// + /// Managed typed document mirror that keeps an editable current value and coalesces local edits into CultMesh mutations. + /// + public sealed class CultMeshReactiveDocument : IDisposable + where TDocument : class + { + private readonly CultMeshDocumentHandle _document; + private readonly CultMeshReactiveDocumentOptions _options; + private readonly object _gate = new(); + private IDisposable? _subscription; + private Timer? _timer; + private bool _dirty; + private bool _flushQueued; + private bool _flushing; + private bool _disposed; + + internal CultMeshReactiveDocument( + CultMeshDocumentHandle document, + TDocument current, + CultMeshReactiveDocumentOptions? options) + { + _document = document ?? throw new ArgumentNullException(nameof(document)); + Current = current ?? throw new ArgumentNullException(nameof(current)); + _options = options ?? new CultMeshReactiveDocumentOptions(); + } + + /// Gets the underlying document handle. + public CultMeshDocumentHandle Document => _document; + + /// Gets the editable local document value. + public TDocument Current { get; private set; } + + /// Gets whether local edits are waiting to be sent. + public bool IsDirty + { + get + { + lock (_gate) + return _dirty; + } + } + + /// Gets the most recent reconciliation snapshot, when a canonical value arrived during local prediction. + public CultMeshReactiveDocumentReconciliation? Reconciliation { get; private set; } + + internal void Start() + { + _subscription = _document.Watch(ApplyCanonicalSnapshot); + } + + /// Marks the current value dirty and schedules a coalesced prediction or replacement. + public void MarkDirty() + { + ThrowIfDisposed(); + lock (_gate) + { + _dirty = true; + ScheduleFlushLocked(); + } + } + + /// Mutates the current value and schedules a coalesced prediction or replacement. + public TDocument Update(Action update) + { + if (update == null) throw new ArgumentNullException(nameof(update)); + ThrowIfDisposed(); + lock (_gate) + { + update(Current); + _dirty = true; + ScheduleFlushLocked(); + return Current; + } + } + + /// Replaces the current local value and schedules a coalesced prediction or replacement. + public TDocument SetCurrent(TDocument value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + ThrowIfDisposed(); + lock (_gate) + { + Current = CultMeshDocumentHandle.CloneDocument(value); + _dirty = true; + ScheduleFlushLocked(); + return Current; + } + } + + /// Reads a fresh canonical snapshot and adopts it as the current value. + public async Task RefreshAsync() + { + ThrowIfDisposed(); + var latest = CultMeshDocumentHandle.CloneDocument( + await _document.LatestAsync().ConfigureAwait(false)); + lock (_gate) + { + Current = latest; + _dirty = false; + Reconciliation = null; + return Current; + } + } + + /// Immediately sends the latest local dirty value, if any, through the document authority shape. + public async Task FlushAsync() + { + TDocument predicted; + lock (_gate) + { + ThrowIfDisposed(); + if (!_dirty) + return; + if (_flushing) + { + _flushQueued = true; + return; + } + + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _flushing = true; + _dirty = false; + predicted = CultMeshDocumentHandle.CloneDocument(Current); + } + + try + { + await _document.SetAsync(predicted).ConfigureAwait(false); + } + finally + { + var shouldFlushAgain = false; + lock (_gate) + { + _flushing = false; + shouldFlushAgain = _flushQueued || _dirty; + _flushQueued = false; + } + + if (shouldFlushAgain) + await FlushAsync().ConfigureAwait(false); + } + } + + /// Clears the most recent reconciliation metadata. + public void ClearReconciliation() + { + ThrowIfDisposed(); + Reconciliation = null; + } + + /// + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + } + + _subscription?.Dispose(); + _timer?.Dispose(); + } + + private void ApplyCanonicalSnapshot(TDocument canonical) + { + if (canonical == null) + return; + + lock (_gate) + { + if (_disposed) + return; + + var nextCanonical = CultMeshDocumentHandle.CloneDocument(canonical); + if (_dirty || _flushing) + { + Reconciliation = new CultMeshReactiveDocumentReconciliation( + nextCanonical, + CultMeshDocumentHandle.CloneDocument(Current), + DateTimeOffset.UtcNow); + if (!_options.ReplaceDirtyCurrentOnCanonicalSnapshot) + return; + } + + Current = nextCanonical; + Reconciliation = null; + } + } + + private void ScheduleFlushLocked() + { + if (_options.FlushDelay <= TimeSpan.Zero) + { + _ = Task.Run(FlushAsync); + return; + } + + _timer ??= new Timer(_ => _ = Task.Run(FlushAsync)); + _timer.Change(_options.FlushDelay, Timeout.InfiniteTimeSpan); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(CultMeshReactiveDocument)); + } + } + /// /// Schema-aware lookup table for typed CultMesh document handles. /// @@ -1285,6 +1565,22 @@ namespace GameCult.Mesh { return Document().Watch(onNext); } + + /// Creates a managed reactive document mirror by CLR type or same-schema alias. + public Task> ReactiveAsync( + CultMeshReactiveDocumentOptions? options = null) + where TDocument : class + { + return Document().ReactiveAsync(options); + } + + /// Creates a managed reactive document mirror synchronously by CLR type or same-schema alias. + public CultMeshReactiveDocument Reactive( + CultMeshReactiveDocumentOptions? options = null) + where TDocument : class + { + return Document().Reactive(options); + } } /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index d228788a..48812792 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -892,6 +892,112 @@ public sealed class CultMeshStreamingTests cache.Get(key)!.Revision.Should().Be(6); } + [Test] + public async Task ReactiveDocument_CoalescesLocalEditsIntoOnePrediction() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + predictions.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + var catalog = CultMesh.Documents(handle); + + using var reactive = await catalog.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + reactive.Current.Text.Should().Be("initial"); + reactive.Update(document => + { + document.Text = "first-local-edit"; + document.Revision++; + }); + reactive.Update(document => + { + document.Text = "second-local-edit"; + document.Revision++; + }); + + predictions.Should().BeEmpty(); + + await reactive.FlushAsync(); + + predictions.Should().ContainSingle(); + predictions[0].Text.Should().Be("second-local-edit"); + predictions[0].Revision.Should().Be(3); + current.Text.Should().Be("second-local-edit"); + } + + [Test] + public async Task ReactiveDocument_TracksCanonicalSnapshotDuringDirtyPrediction() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var handle = CultMesh.Document( + "mesh.note.reconciliation", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + reactive.Update(document => + { + document.Text = "local-prediction"; + document.Revision = 2; + }); + + subject.OnNext(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "canonical-correction", + Revision = 7 + }); + + reactive.Current.Text.Should().Be("local-prediction"); + reactive.Reconciliation.Should().NotBeNull(); + reactive.Reconciliation!.Canonical.Text.Should().Be("canonical-correction"); + reactive.Reconciliation.Predicted.Text.Should().Be("local-prediction"); + } + [Test] public async Task DocumentHandle_ReadsSchemaPublicationsFromSingleFileStores() { From ce7a5b21589ac7d74f277833b286a97c82298d80 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 19:25:10 +0100 Subject: [PATCH 047/498] Expand CultMesh reactive document tests --- .../CultMeshStreamingTests.cs | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 48812792..f97eeda0 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -998,6 +998,465 @@ public sealed class CultMeshStreamingTests reactive.Reconciliation.Predicted.Text.Should().Be("local-prediction"); } + [Test] + public async Task ReactiveDocument_AutoFlushesOnceAfterDebounceWindow() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.debounce", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + predictions.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMilliseconds(25) + }); + + reactive.Update(document => + { + document.Text = "coalesced-one"; + document.Revision++; + }); + reactive.Update(document => + { + document.Text = "coalesced-two"; + document.Revision++; + }); + + await WaitForAsync(() => predictions.Count == 1); + + predictions[0].Text.Should().Be("coalesced-two"); + predictions[0].Revision.Should().Be(3); + reactive.IsDirty.Should().BeFalse(); + } + + [Test] + public async Task ReactiveDocument_UsesReplacementWhenPredictionIsUnavailable() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var replacements = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.replace", + CultMesh.Verse("starbridge", "daemon"), + _ => Task.FromResult(current), + _ => subject, + value => + { + replacements.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + reactive.Update(document => + { + document.Text = "authoritative-replace"; + document.Revision = 10; + }); + await reactive.FlushAsync(); + + replacements.Should().ContainSingle(); + replacements[0].Text.Should().Be("authoritative-replace"); + current.Revision.Should().Be(10); + } + + [Test] + public async Task ReactiveDocument_ReadOnlyHandleRejectsFlush() + { + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var handle = CultMesh.Document( + "mesh.note.reactive.readonly", + CultMesh.Verse("starbridge", "observer"), + _ => Task.FromResult(current), + _ => new Subject()); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + reactive.Update(document => document.Text = "not-allowed"); + + Func act = () => reactive.FlushAsync(); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task ReactiveDocument_DisposeSuppressesScheduledFlush() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.dispose", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + predictions.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMilliseconds(25) + }); + reactive.Update(document => document.Text = "disposed-before-flush"); + reactive.Dispose(); + + await Task.Delay(100); + + predictions.Should().BeEmpty(); + } + + [Test] + public async Task ReactiveDocument_CanAdoptCanonicalSnapshotWhileDirty() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var handle = CultMesh.Document( + "mesh.note.reactive.adopt-canonical", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1), + ReplaceDirtyCurrentOnCanonicalSnapshot = true + }); + reactive.Update(document => document.Text = "local-prediction"); + + subject.OnNext(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "canonical-wins", + Revision = 9 + }); + + reactive.Current.Text.Should().Be("canonical-wins"); + reactive.Current.Revision.Should().Be(9); + reactive.Reconciliation.Should().BeNull(); + } + + [Test] + public async Task ReactiveDocument_RefreshClearsDirtyLocalState() + { + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var replacements = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.refresh", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => new Subject(), + value => + { + replacements.Add(value); + current = value; + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + reactive.Update(document => document.Text = "local-dirty"); + current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "fresh-canonical", + Revision = 5 + }; + + await reactive.RefreshAsync(); + await reactive.FlushAsync(); + + reactive.Current.Text.Should().Be("fresh-canonical"); + reactive.IsDirty.Should().BeFalse(); + replacements.Should().BeEmpty(); + } + + [Test] + public async Task ReactiveDocument_SyncsSameSchemaAliasesAcrossRuntimeHandles() + { + var cache = new CultCache(); + var schemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId; + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + RuntimeId = "pilot-a", + Shards = new[] + { + new CultNetShardDescriptor( + "pilot-inputs", + "server", + epoch: 1, + isPrimary: false, + schemaIds: new[] { schemaId }, + keyPrefix: "input:") + }, + ClientAuthorityScopes = new[] + { + new CultNetClientAuthorityScope( + "pilot-a", + schemaIds: new[] { schemaId }, + keyPrefix: "input:pilot-a") + } + }); + var key = new CultRecordKey("input:pilot-a:reactive"); + await cache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }, new CultRecordHandle(key)); + + var pilotHandle = CultMesh.Document( + database, + key, + CultMesh.Verse("starbridge", "pilot-a")); + var commanderHandle = CultMesh.Document( + database, + key, + CultMesh.Verse("starbridge", "commander-rts")); + var observedByCommander = new List(); + using var commanderSubscription = commanderHandle + .AsSchemaAlias() + .Watch(observedByCommander.Add); + using var pilotReactive = await CultMesh + .Documents(pilotHandle) + .ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + pilotReactive.Update(document => + { + document.Text = "pilot-predicted"; + document.Revision = 2; + }); + await pilotReactive.FlushAsync(); + + await WaitForAsync(() => observedByCommander.Any(document => document.Text == "pilot-predicted")); + + commanderHandle.Context.RuntimeId.Should().Be("commander-rts"); + observedByCommander.Last().Revision.Should().Be(2); + (await commanderHandle.AsSchemaAlias().LatestAsync()).Text.Should().Be("pilot-predicted"); + } + + [Test] + public async Task ReactiveDocument_MarkDirtyFlushesDirectMemberEdits() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.mark-dirty", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + predictions.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + reactive.Current.Text = "direct-member-edit"; + reactive.Current.Revision = 12; + reactive.MarkDirty(); + await reactive.FlushAsync(); + + predictions.Should().ContainSingle(); + predictions[0].Text.Should().Be("direct-member-edit"); + predictions[0].Revision.Should().Be(12); + } + + [Test] + public async Task ReactiveDocument_CleanFlushDoesNotWrite() + { + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var writes = 0; + var handle = CultMesh.Document( + "mesh.note.reactive.clean-flush", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => new Subject(), + value => + { + writes++; + current = value; + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync(); + + await reactive.FlushAsync(); + + writes.Should().Be(0); + reactive.IsDirty.Should().BeFalse(); + } + + [Test] + public async Task ReactiveDocument_FlushesAgainWhenEditedDuringInFlightFlush() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var allowFirstWrite = new TaskCompletionSource(); + var firstWriteStarted = new TaskCompletionSource(); + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.in-flight", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + async value => + { + predictions.Add(value); + if (predictions.Count == 1) + { + firstWriteStarted.SetResult(true); + await allowFirstWrite.Task.ConfigureAwait(false); + } + + current = value; + subject.OnNext(value); + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + reactive.Update(document => + { + document.Text = "first"; + document.Revision = 2; + }); + var flush = reactive.FlushAsync(); + await firstWriteStarted.Task; + + reactive.Update(document => + { + document.Text = "second"; + document.Revision = 3; + }); + allowFirstWrite.SetResult(true); + await flush; + + predictions.Should().HaveCount(2); + predictions[0].Text.Should().Be("first"); + predictions[1].Text.Should().Be("second"); + current.Revision.Should().Be(3); + } + [Test] public async Task DocumentHandle_ReadsSchemaPublicationsFromSingleFileStores() { From 58871341933f686ea145a16042af1b2d4ac1a026 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 19:31:59 +0100 Subject: [PATCH 048/498] Expand CultMesh cross-runtime reactive tests --- .../CultMeshStreamingTests.cs | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index f97eeda0..647b6fda 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1317,6 +1317,299 @@ public sealed class CultMeshStreamingTests (await commanderHandle.AsSchemaAlias().LatestAsync()).Text.Should().Be("pilot-predicted"); } + [Test] + public async Task ReactiveDocument_TwoRuntimeManagedDocumentsSyncThroughDatabase() + { + var cache = new CultCache(); + var schemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId; + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + RuntimeId = "pilot-a", + Shards = new[] + { + new CultNetShardDescriptor( + "pilot-inputs", + "server", + epoch: 1, + isPrimary: false, + schemaIds: new[] { schemaId }, + keyPrefix: "input:") + }, + ClientAuthorityScopes = new[] + { + new CultNetClientAuthorityScope( + "pilot-a", + schemaIds: new[] { schemaId }, + keyPrefix: "input:pilot-a") + } + }); + var key = new CultRecordKey("input:pilot-a:co-op"); + await cache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }, new CultRecordHandle(key)); + + var pilotHandle = CultMesh.Document( + database, + key, + CultMesh.Verse("starbridge", "pilot-a")); + var commanderHandle = CultMesh.Document( + database, + key, + CultMesh.Verse("starbridge", "commander-rts")); + using var commanderReactive = await commanderHandle + .AsSchemaAlias() + .ReactiveAsync(new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + using var pilotReactive = await pilotHandle + .AsSchemaAlias() + .ReactiveAsync(new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + pilotReactive.Update(document => + { + document.Text = "pilot-first-touch"; + document.Revision++; + }); + pilotReactive.Update(document => + { + document.Text = "pilot-coalesced-order"; + document.Revision++; + }); + await pilotReactive.FlushAsync(); + + await WaitForAsync(() => commanderReactive.Current.Text == "pilot-coalesced-order"); + + pilotReactive.IsDirty.Should().BeFalse(); + commanderReactive.Document.Context.RuntimeId.Should().Be("commander-rts"); + commanderReactive.Current.Revision.Should().Be(3); + cache.Get(key)!.Text.Should().Be("pilot-coalesced-order"); + } + + [Test] + public async Task ReactiveDocument_SyncedSnapshotEndpointCapturesCanonicalCorrectionAcrossRuntimeBoundary() + { + var sourceCache = new CultCache(); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); + registry.Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); + var key = new CultRecordKey("mesh-note:remote-correction"); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }, new CultRecordHandle(key)); + var endpoint = "cultnet://remote-correction.test:3080"; + var surface = CultMesh.SnapshotEndpoint( + endpoint, + new CultMeshSnapshotEndpointOptions + { + Context = CultMesh.Verse("starbridge", "unity-pilot").Context, + DocumentRegistry = registry, + PollInterval = TimeSpan.FromMilliseconds(10), + Request = new CultMeshSnapshotRequestOptions + { + CreateClient = () => new MeshSnapshotSchemaClient(request => + registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request)) + } + }); + + using var temp = new TemporaryDirectory(); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(temp.Path, "pilot-runtime.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = sourceCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + var handle = surface.SyncTo(node).Document(key.Value); + using var reactive = await handle.ReactiveAsync(new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + reactive.Update(document => + { + document.Text = "local-prediction"; + document.Revision = 2; + }); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "remote-authority-correction", + Revision = 7 + }, new CultRecordHandle(key)); + + await WaitForAsync(() => reactive.Reconciliation?.Canonical.Text == "remote-authority-correction"); + + reactive.Current.Text.Should().Be("local-prediction"); + reactive.Reconciliation!.Predicted.Text.Should().Be("local-prediction"); + reactive.Reconciliation.Canonical.Revision.Should().Be(7); + node.Cache.Get(key)!.Text.Should().Be("remote-authority-correction"); + } + + [Test] + public async Task ReactiveDocument_FreshRuntimeReconstructsFromSyncedSnapshotEndpoint() + { + var sourceCache = new CultCache(); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); + registry.Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); + var key = new CultRecordKey("mesh-note:runtime-reconnect"); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "before-crash", + Revision = 1 + }, new CultRecordHandle(key)); + var endpoint = "cultnet://runtime-reconnect.test:3081"; + var surface = CultMesh.SnapshotEndpoint( + endpoint, + new CultMeshSnapshotEndpointOptions + { + Context = CultMesh.Verse("starbridge", "unity-reconnected").Context, + DocumentRegistry = registry, + Request = new CultMeshSnapshotRequestOptions + { + CreateClient = () => new MeshSnapshotSchemaClient(request => + registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request)) + } + }); + + using var temp = new TemporaryDirectory(); + using (var crashedRuntime = await CultMesh.CreateNodeAsync( + Path.Combine(temp.Path, "crashed-runtime.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = sourceCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + })) + { + var stale = await surface.SyncTo(crashedRuntime).Document(key.Value).LatestAsync(); + stale.Text.Should().Be("before-crash"); + } + + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "after-reboot-authority", + Revision = 4 + }, new CultRecordHandle(key)); + using var freshRuntime = await CultMesh.CreateNodeAsync( + Path.Combine(temp.Path, "fresh-runtime.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = sourceCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + using var reactive = await surface + .SyncTo(freshRuntime) + .Documents(CultMesh.SnapshotDocument(key.Value)) + .ReactiveAsync(); + + reactive.Current.Text.Should().Be("after-reboot-authority"); + reactive.Current.Revision.Should().Be(4); + freshRuntime.Cache.Get(key)!.Text.Should().Be("after-reboot-authority"); + } + + [Test] + public async Task ReactiveDocument_ReadOnlySnapshotEndpointRefreshRecoversAfterRejectedLocalEdit() + { + var sourceCache = new CultCache(); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); + registry.Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); + var key = new CultRecordKey("mesh-note:readonly-refresh"); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }, new CultRecordHandle(key)); + var endpoint = "cultnet://readonly-refresh.test:3082"; + var surface = CultMesh.SnapshotEndpoint( + endpoint, + new CultMeshSnapshotEndpointOptions + { + Context = CultMesh.Verse("starbridge", "observer").Context, + DocumentRegistry = registry, + Request = new CultMeshSnapshotRequestOptions + { + CreateClient = () => new MeshSnapshotSchemaClient(request => + registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request)) + } + }); + + using var temp = new TemporaryDirectory(); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(temp.Path, "observer-runtime.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = sourceCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + using var reactive = await surface + .SyncTo(node) + .Document(key.Value) + .ReactiveAsync(new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + reactive.Update(document => document.Text = "observer-should-not-write"); + + Func flush = () => reactive.FlushAsync(); + await flush.Should().ThrowAsync(); + + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "fresh-authority", + Revision = 5 + }, new CultRecordHandle(key)); + await reactive.RefreshAsync(); + await reactive.FlushAsync(); + + reactive.Current.Text.Should().Be("fresh-authority"); + reactive.IsDirty.Should().BeFalse(); + node.Cache.Get(key)!.Revision.Should().Be(5); + } + [Test] public async Task ReactiveDocument_MarkDirtyFlushesDirectMemberEdits() { From 8cdfdb656ceaa9a57daf255670e49ca27f6811b0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 19:50:30 +0100 Subject: [PATCH 049/498] Detect direct edits in CultMesh reactive documents --- src/GameCult.Mesh/CultMeshPrimitives.cs | 57 +++++++++++- .../CultMeshStreamingTests.cs | 93 +++++++++++++++++++ 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 789a0b24..b38abd05 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1140,6 +1140,9 @@ namespace GameCult.Mesh /// public TimeSpan FlushDelay { get; set; } = TimeSpan.FromMilliseconds(16); + /// Gets or sets whether direct edits to Current should be detected and flushed automatically. + public bool DetectLocalChanges { get; set; } = true; + /// Gets or sets whether canonical snapshots replace dirty local predictions immediately. public bool ReplaceDirtyCurrentOnCanonicalSnapshot { get; set; } } @@ -1180,7 +1183,9 @@ namespace GameCult.Mesh private readonly CultMeshReactiveDocumentOptions _options; private readonly object _gate = new(); private IDisposable? _subscription; - private Timer? _timer; + private Timer? _flushTimer; + private Timer? _changeDetectionTimer; + private byte[] _lastCleanSnapshot; private bool _dirty; private bool _flushQueued; private bool _flushing; @@ -1194,6 +1199,7 @@ namespace GameCult.Mesh _document = document ?? throw new ArgumentNullException(nameof(document)); Current = current ?? throw new ArgumentNullException(nameof(current)); _options = options ?? new CultMeshReactiveDocumentOptions(); + _lastCleanSnapshot = SerializeDocument(Current); } /// Gets the underlying document handle. @@ -1218,6 +1224,14 @@ namespace GameCult.Mesh internal void Start() { _subscription = _document.Watch(ApplyCanonicalSnapshot); + if (_options.DetectLocalChanges && _document.CanSet) + { + var interval = _options.FlushDelay <= TimeSpan.Zero + ? TimeSpan.FromMilliseconds(1) + : _options.FlushDelay; + _changeDetectionTimer = new Timer(_ => _ = Task.Run(DetectLocalChangesAsync)); + _changeDetectionTimer.Change(interval, interval); + } } /// Marks the current value dirty and schedules a coalesced prediction or replacement. @@ -1268,6 +1282,7 @@ namespace GameCult.Mesh lock (_gate) { Current = latest; + _lastCleanSnapshot = SerializeDocument(Current); _dirty = false; Reconciliation = null; return Current; @@ -1281,6 +1296,8 @@ namespace GameCult.Mesh lock (_gate) { ThrowIfDisposed(); + if (!_dirty && _document.CanSet) + DetectLocalChangesLocked(); if (!_dirty) return; if (_flushing) @@ -1289,10 +1306,11 @@ namespace GameCult.Mesh return; } - _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _flushTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); _flushing = true; _dirty = false; predicted = CultMeshDocumentHandle.CloneDocument(Current); + _lastCleanSnapshot = SerializeDocument(predicted); } try @@ -1332,7 +1350,8 @@ namespace GameCult.Mesh } _subscription?.Dispose(); - _timer?.Dispose(); + _flushTimer?.Dispose(); + _changeDetectionTimer?.Dispose(); } private void ApplyCanonicalSnapshot(TDocument canonical) @@ -1357,10 +1376,33 @@ namespace GameCult.Mesh } Current = nextCanonical; + _lastCleanSnapshot = SerializeDocument(Current); Reconciliation = null; } } + private Task DetectLocalChangesAsync() + { + lock (_gate) + { + if (_disposed || !_document.CanSet || _dirty || _flushing) + return Task.CompletedTask; + DetectLocalChangesLocked(); + if (_dirty) + ScheduleFlushLocked(); + } + + return Task.CompletedTask; + } + + private void DetectLocalChangesLocked() + { + var currentSnapshot = SerializeDocument(Current); + if (_lastCleanSnapshot.SequenceEqual(currentSnapshot)) + return; + _dirty = true; + } + private void ScheduleFlushLocked() { if (_options.FlushDelay <= TimeSpan.Zero) @@ -1369,8 +1411,13 @@ namespace GameCult.Mesh return; } - _timer ??= new Timer(_ => _ = Task.Run(FlushAsync)); - _timer.Change(_options.FlushDelay, Timeout.InfiniteTimeSpan); + _flushTimer ??= new Timer(_ => _ = Task.Run(FlushAsync)); + _flushTimer.Change(_options.FlushDelay, Timeout.InfiniteTimeSpan); + } + + private static byte[] SerializeDocument(TDocument document) + { + return CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)); } private void ThrowIfDisposed() diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 647b6fda..7585ce8f 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1656,6 +1656,99 @@ public sealed class CultMeshStreamingTests predictions[0].Revision.Should().Be(12); } + [Test] + public async Task ReactiveDocument_DetectsDirectMemberEditsWithoutMarkDirty() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.direct-member-auto", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + predictions.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMilliseconds(10) + }); + + reactive.Current.Text = "direct-member-edit"; + reactive.Current.Revision = 2; + reactive.Current.Text = "direct-member-edit-final"; + reactive.Current.Revision = 3; + + await WaitForAsync(() => predictions.Count == 1); + + predictions[0].Text.Should().Be("direct-member-edit-final"); + predictions[0].Revision.Should().Be(3); + reactive.IsDirty.Should().BeFalse(); + } + + [Test] + public async Task ReactiveDocument_FlushAsyncCapturesDirectMemberEdit() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.direct-member-flush", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + predictions.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + reactive.Current.Text = "direct-member-flush"; + reactive.Current.Revision = 4; + await reactive.FlushAsync(); + + predictions.Should().ContainSingle(); + predictions[0].Text.Should().Be("direct-member-flush"); + predictions[0].Revision.Should().Be(4); + } + [Test] public async Task ReactiveDocument_CleanFlushDoesNotWrite() { From e3e07dfc58b692d3362bc3de6782814226cee4b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 19:56:08 +0100 Subject: [PATCH 050/498] Add CultMesh reactive reconciliation deltas --- src/GameCult.Mesh/CultMeshPrimitives.cs | 134 +++++++++++++++++- .../CultMeshStreamingTests.cs | 5 + 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index b38abd05..fb34c000 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -1156,19 +1157,32 @@ namespace GameCult.Mesh internal CultMeshReactiveDocumentReconciliation( TDocument canonical, TDocument predicted, + IReadOnlyDictionary delta, + int version, DateTimeOffset receivedAt) { Canonical = canonical; Predicted = predicted; + Delta = delta ?? throw new ArgumentNullException(nameof(delta)); + Version = version; ReceivedAt = receivedAt; } + /// Gets the monotonically increasing reconciliation version for this reactive document. + public int Version { get; } + /// Gets the authoritative canonical document snapshot. public TDocument Canonical { get; } /// Gets the locally predicted document snapshot that was active when the canonical value arrived. public TDocument Predicted { get; } + /// + /// Gets the predicted-vs-canonical member delta. Numeric members store predicted minus canonical; + /// non-numeric members store the predicted value. + /// + public IReadOnlyDictionary Delta { get; } + /// Gets when the canonical snapshot was received. public DateTimeOffset ReceivedAt { get; } } @@ -1190,6 +1204,7 @@ namespace GameCult.Mesh private bool _flushQueued; private bool _flushing; private bool _disposed; + private int _reconciliationVersion; internal CultMeshReactiveDocument( CultMeshDocumentHandle document, @@ -1367,10 +1382,22 @@ namespace GameCult.Mesh var nextCanonical = CultMeshDocumentHandle.CloneDocument(canonical); if (_dirty || _flushing) { - Reconciliation = new CultMeshReactiveDocumentReconciliation( - nextCanonical, - CultMeshDocumentHandle.CloneDocument(Current), - DateTimeOffset.UtcNow); + var predicted = CultMeshDocumentHandle.CloneDocument(Current); + var delta = CreateReconciliationDelta(predicted, nextCanonical); + if (delta.Count == 0) + { + Reconciliation = null; + } + else + { + Reconciliation = new CultMeshReactiveDocumentReconciliation( + nextCanonical, + predicted, + delta, + ++_reconciliationVersion, + DateTimeOffset.UtcNow); + } + if (!_options.ReplaceDirtyCurrentOnCanonicalSnapshot) return; } @@ -1420,11 +1447,110 @@ namespace GameCult.Mesh return CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)); } + private static IReadOnlyDictionary CreateReconciliationDelta( + TDocument predicted, + TDocument canonical) + { + var delta = new Dictionary(StringComparer.Ordinal); + foreach (var member in GetComparableMembers(typeof(TDocument))) + { + var predictedValue = member.GetValue(predicted); + var canonicalValue = member.GetValue(canonical); + if (CultMeshValuesEqual(predictedValue, canonicalValue)) + continue; + + delta[member.Name] = TryCreateNumericDelta(predictedValue, canonicalValue, out var numericDelta) + ? numericDelta + : predictedValue; + } + + return delta; + } + + private static IEnumerable GetComparableMembers(Type documentType) + { + foreach (var property in documentType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.GetMethod == null || property.GetIndexParameters().Length != 0) + continue; + yield return new CultMeshComparableMember(property.Name, property.GetValue); + } + + foreach (var field in documentType.GetFields(BindingFlags.Instance | BindingFlags.Public)) + { + yield return new CultMeshComparableMember(field.Name, field.GetValue); + } + } + + private static bool TryCreateNumericDelta(object? predicted, object? canonical, out object? delta) + { + delta = null; + if (predicted == null || canonical == null) + return false; + var predictedType = Nullable.GetUnderlyingType(predicted.GetType()) ?? predicted.GetType(); + var canonicalType = Nullable.GetUnderlyingType(canonical.GetType()) ?? canonical.GetType(); + if (!IsNumericType(predictedType) || !IsNumericType(canonicalType)) + return false; + + delta = Convert.ToDouble(predicted, CultureInfo.InvariantCulture) - + Convert.ToDouble(canonical, CultureInfo.InvariantCulture); + return true; + } + + private static bool IsNumericType(Type type) + { + if (type.IsEnum) + return false; + return Type.GetTypeCode(type) switch + { + TypeCode.Byte or + TypeCode.SByte or + TypeCode.UInt16 or + TypeCode.UInt32 or + TypeCode.UInt64 or + TypeCode.Int16 or + TypeCode.Int32 or + TypeCode.Int64 or + TypeCode.Decimal or + TypeCode.Double or + TypeCode.Single => true, + _ => false + }; + } + + private static bool CultMeshValuesEqual(object? left, object? right) + { + if (ReferenceEquals(left, right)) + return true; + if (left == null || right == null) + return false; + if (left.Equals(right)) + return true; + if (left is string || right is string) + return false; + if (left is IEnumerable leftEnumerable && right is IEnumerable rightEnumerable) + return leftEnumerable.Cast().SequenceEqual(rightEnumerable.Cast()); + return false; + } + private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(CultMeshReactiveDocument)); } + + private readonly struct CultMeshComparableMember + { + public CultMeshComparableMember(string name, Func getValue) + { + Name = name; + GetValue = getValue; + } + + public string Name { get; } + + public Func GetValue { get; } + } } /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 7585ce8f..4a062e75 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -994,8 +994,13 @@ public sealed class CultMeshStreamingTests reactive.Current.Text.Should().Be("local-prediction"); reactive.Reconciliation.Should().NotBeNull(); + reactive.Reconciliation!.Version.Should().Be(1); reactive.Reconciliation!.Canonical.Text.Should().Be("canonical-correction"); reactive.Reconciliation.Predicted.Text.Should().Be("local-prediction"); + reactive.Reconciliation.Delta.Should().ContainKey(nameof(MeshNoteDocument.Text)); + reactive.Reconciliation.Delta[nameof(MeshNoteDocument.Text)].Should().Be("local-prediction"); + reactive.Reconciliation.Delta.Should().ContainKey(nameof(MeshNoteDocument.Revision)); + reactive.Reconciliation.Delta[nameof(MeshNoteDocument.Revision)].Should().Be(-5.0); } [Test] From 8a88ae547dbcf68c52c4dc05a44aa2da86773ab9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 20:05:56 +0100 Subject: [PATCH 051/498] Expand CultMesh reactive sync edge tests --- packages/cultmesh-ts/test/cultmesh.test.ts | 135 +++++++++++++++ src/GameCult.Mesh/CultMeshPrimitives.cs | 2 + .../CultMeshStreamingTests.cs | 156 ++++++++++++++++++ 3 files changed, 293 insertions(+) diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 82ec5047..8266bc24 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -302,6 +302,141 @@ test("CultMesh TS reactive documents store reconciliation deltas after mispredic reactive.dispose(); }); +test("CultMesh TS reactive documents coalesce same-frame member writes", async () => { + let current = { + noteId: "note:reactive-frame", + body: "initial", + }; + const predictions: string[] = []; + const previousRequestAnimationFrame = globalThis.requestAnimationFrame; + const frameCallbacks: FrameRequestCallback[] = []; + globalThis.requestAnimationFrame = callback => { + frameCallbacks.push(callback); + return frameCallbacks.length; + }; + const document = CultMesh.document( + "cultmesh.note:reactive-frame", + noteDocument, + async () => current, + { + submitPrediction: async (_context, value) => { + predictions.push(value.body); + current = value; + }, + }, + ); + + const reactive = document.reactive({ + watch: false, + }); + try { + await reactive.ready; + reactive.current.body = "frame-1"; + reactive.current.body = "frame-2"; + reactive.current.body = "frame-3"; + + assert.equal(frameCallbacks.length, 1); + frameCallbacks[0](performance.now()); + await waitFor(() => predictions.length === 1, "same-frame prediction"); + + assert.deepEqual(predictions, ["frame-3"]); + assert.equal(current.body, "frame-3"); + } finally { + reactive.dispose(); + if (previousRequestAnimationFrame) { + globalThis.requestAnimationFrame = previousRequestAnimationFrame; + } else { + delete (globalThis as unknown as { + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + }).requestAnimationFrame; + } + } +}); + +test("CultMesh TS reactive documents queue edits made while a prediction is in flight", async () => { + let current = { + noteId: "note:reactive-in-flight", + body: "initial", + }; + let releaseFirstPrediction!: () => void; + let resolveFirstPredictionStarted!: () => void; + const firstPredictionStarted = new Promise(resolve => { + resolveFirstPredictionStarted = resolve; + }); + const predictions: typeof current[] = []; + const document = CultMesh.document( + "cultmesh.note:reactive-in-flight", + noteDocument, + async () => current, + { + submitPrediction: async (_context, value) => { + predictions.push(value); + if (predictions.length === 1) { + resolveFirstPredictionStarted(); + await new Promise(release => { + releaseFirstPrediction = release; + }); + } + current = value; + }, + }, + ); + + const reactive = document.reactive({ + watch: false, + debounceMs: 0, + }); + await reactive.ready; + reactive.current.body = "first"; + await firstPredictionStarted; + reactive.current.body = "second"; + releaseFirstPrediction(); + await waitFor(() => predictions.length === 2, "in-flight queued prediction"); + + assert.deepEqual(predictions.map(value => value.body), ["first", "second"]); + assert.equal(current.body, "second"); + reactive.dispose(); +}); + +test("CultMesh TS reactive read-only documents surface mutation failures and keep watching", async () => { + let current = { + noteId: "note:reactive-readonly", + body: "initial", + }; + let watcher: ((value: typeof current) => void) | undefined; + const errors: string[] = []; + const document = CultMesh.document( + "cultmesh.note:reactive-readonly", + noteDocument, + async () => current, + { + watchDocument: (_context, callback) => { + watcher = callback; + return () => { + watcher = undefined; + }; + }, + }, + ); + + const reactive = document.reactive({ + onError: error => errors.push(String((error as Error).message ?? error)), + }); + await reactive.ready; + reactive.current.body = "local-readonly-edit"; + await waitFor(() => errors.length === 1, "read-only mutation error"); + + current = { + noteId: "note:reactive-readonly", + body: "canonical-after-error", + }; + watcher?.(current); + + assert.match(errors[0], /does not support mutation/); + assert.equal(reactive.current.body, "canonical-after-error"); + reactive.dispose(); +}); + test("CultMesh TS document handles read schema publications from single-file stores", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-")), "publication.ccmp"); const node = await CultMesh.startNode(filePath, { diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index fb34c000..70ab2737 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1338,6 +1338,8 @@ namespace GameCult.Mesh lock (_gate) { _flushing = false; + if (_document.CanSet) + DetectLocalChangesLocked(); shouldFlushAgain = _flushQueued || _dirty; _flushQueued = false; } diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 4a062e75..2cfa18b8 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1848,6 +1848,162 @@ public sealed class CultMeshStreamingTests current.Revision.Should().Be(3); } + [Test] + public async Task ReactiveDocument_DebouncesBurstUpdatesIntoOnePrediction() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.burst", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + predictions.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMilliseconds(35) + }); + + for (var i = 0; i < 5; i++) + { + reactive.Update(document => + { + document.Text = $"burst-{i}"; + document.Revision++; + }); + } + + await WaitForAsync(() => predictions.Count == 1); + + predictions[0].Text.Should().Be("burst-4"); + predictions[0].Revision.Should().Be(6); + reactive.IsDirty.Should().BeFalse(); + } + + [Test] + public async Task ReactiveDocument_DirectEditsDuringInFlightFlushQueueLatestPrediction() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var allowFirstWrite = new TaskCompletionSource(); + var firstWriteStarted = new TaskCompletionSource(); + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.direct-in-flight", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + async value => + { + predictions.Add(value); + if (predictions.Count == 1) + { + firstWriteStarted.SetResult(true); + await allowFirstWrite.Task.ConfigureAwait(false); + } + + current = value; + subject.OnNext(value); + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + reactive.Current.Text = "first-direct"; + reactive.Current.Revision = 2; + var flush = reactive.FlushAsync(); + await firstWriteStarted.Task; + + reactive.Current.Text = "second-direct"; + reactive.Current.Revision = 3; + allowFirstWrite.SetResult(true); + await flush; + + predictions.Should().HaveCount(2); + predictions[0].Text.Should().Be("first-direct"); + predictions[1].Text.Should().Be("second-direct"); + reactive.IsDirty.Should().BeFalse(); + } + + [Test] + public async Task ReactiveDocument_CanonicalEchoClearsOutstandingPredictionWithoutDelta() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var handle = CultMesh.Document( + "mesh.note.reactive.canonical-echo", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + reactive.Update(document => + { + document.Text = "accepted"; + document.Revision = 2; + }); + + await reactive.FlushAsync(); + + reactive.Current.Text.Should().Be("accepted"); + reactive.Reconciliation.Should().BeNull(); + reactive.IsDirty.Should().BeFalse(); + } + [Test] public async Task DocumentHandle_ReadsSchemaPublicationsFromSingleFileStores() { From 8cc147d367d7c94fdd7610cb0aab88a8124ec40d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 20:35:59 +0100 Subject: [PATCH 052/498] Add node-level reactive document shortcuts --- src/GameCult.Mesh/CultMesh.cs | 372 +++++++++++++++--- .../CultMeshStreamingTests.cs | 67 ++++ 2 files changed, 377 insertions(+), 62 deletions(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 45694e6a..3875e251 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Reflection; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -133,35 +134,35 @@ namespace GameCult.Mesh /// Gets or sets the message id prefix used for snapshot requests. public string? MessageIdPrefix { get; set; } - /// Gets or sets schema ids to request. Empty means the document type schema is requested. - public IReadOnlyList? SchemaIds { get; set; } - - /// Gets or sets the target shard id, when the endpoint is shard-aware. - public string? ShardId { get; set; } - - /// Gets or sets the target shard epoch, when the endpoint is shard-aware. - public long? ShardEpoch { get; set; } - + /// Gets or sets schema ids to request. Empty means the document type schema is requested. + public IReadOnlyList? SchemaIds { get; set; } + + /// Gets or sets the target shard id, when the endpoint is shard-aware. + public string? ShardId { get; set; } + + /// Gets or sets the target shard epoch, when the endpoint is shard-aware. + public long? ShardEpoch { get; set; } + /// Gets or sets client security options for endpoint-created clients. public ClientSecurityOptions? Security { get; set; } /// Gets or sets a callback used to configure endpoint-created LiteNetLib clients. public Action? ConfigureClient { get; set; } - - /// Gets or sets the runtime id used by endpoint-created RUDP clients. - public string? RudpRuntimeId { get; set; } - - /// Gets or sets the connection id used by endpoint-created RUDP clients. - public uint RudpConnectionId { get; set; } = 0x43554c54; - - /// Gets or sets the connect payload used by endpoint-created RUDP clients. - public string RudpConnectPayload { get; set; } = "cultnet-schema-rudp"; - - /// Gets or sets the maximum fragment size used by endpoint-created RUDP clients. - public int RudpMaxFragmentBytes { get; set; } = 1024; - - /// Gets or sets the resend delay used by endpoint-created RUDP clients. - public long RudpResendDelayMs { get; set; } = 25; + + /// Gets or sets the runtime id used by endpoint-created RUDP clients. + public string? RudpRuntimeId { get; set; } + + /// Gets or sets the connection id used by endpoint-created RUDP clients. + public uint RudpConnectionId { get; set; } = 0x43554c54; + + /// Gets or sets the connect payload used by endpoint-created RUDP clients. + public string RudpConnectPayload { get; set; } = "cultnet-schema-rudp"; + + /// Gets or sets the maximum fragment size used by endpoint-created RUDP clients. + public int RudpMaxFragmentBytes { get; set; } = 1024; + + /// Gets or sets the resend delay used by endpoint-created RUDP clients. + public long RudpResendDelayMs { get; set; } = 25; } /// @@ -337,6 +338,114 @@ namespace GameCult.Mesh return _host.FlushAsync(soft); } + /// + /// Creates a typed document handle over one distributed record hosted by this node. + /// + public CultMeshDocumentHandle Document( + CultRecordKey key, + CultMeshVerseContext context, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + return CultMesh.Document(Database, key, context, documentId, sources, routeHint); + } + + /// + /// Creates a typed document handle over one distributed record hosted by this node. + /// + public CultMeshDocumentHandle Document( + CultRecordKey key, + CultMeshVerse verse, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Document(key, verse.Context, documentId, sources, routeHint); + } + + /// + /// Creates a typed document handle over one distributed record hosted by this node. + /// + public CultMeshDocumentHandle Document( + string recordKey, + CultMeshVerseContext context, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + return Document(new CultRecordKey(recordKey), context, documentId, sources, routeHint); + } + + /// + /// Creates a typed document handle over one distributed record hosted by this node. + /// + public CultMeshDocumentHandle Document( + string recordKey, + CultMeshVerse verse, + string? documentId = null, + IEnumerable? sources = null, + CultMeshRouteHint? routeHint = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return Document(recordKey, verse.Context, documentId, sources, routeHint); + } + + /// + /// Creates a managed reactive document mirror over one distributed record hosted by this node. + /// + public Task> ReactiveDocumentAsync( + CultRecordKey key, + CultMeshVerseContext context, + CultMeshReactiveDocumentOptions? options = null) + where TDocument : class + { + return Document(key, context).ReactiveAsync(options); + } + + /// + /// Creates a managed reactive document mirror over one distributed record hosted by this node. + /// + public Task> ReactiveDocumentAsync( + CultRecordKey key, + CultMeshVerse verse, + CultMeshReactiveDocumentOptions? options = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return ReactiveDocumentAsync(key, verse.Context, options); + } + + /// + /// Creates a managed reactive document mirror over one distributed record hosted by this node. + /// + public Task> ReactiveDocumentAsync( + string recordKey, + CultMeshVerseContext context, + CultMeshReactiveDocumentOptions? options = null) + where TDocument : class + { + return ReactiveDocumentAsync(new CultRecordKey(recordKey), context, options); + } + + /// + /// Creates a managed reactive document mirror over one distributed record hosted by this node. + /// + public Task> ReactiveDocumentAsync( + string recordKey, + CultMeshVerse verse, + CultMeshReactiveDocumentOptions? options = null) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return ReactiveDocumentAsync(recordKey, verse.Context, options); + } + /// public void Dispose() { @@ -1457,17 +1566,15 @@ namespace GameCult.Mesh return Document( ResolveDocumentId(documentId, key), context, - async _ => await ReadRequiredAsync(database, key).ConfigureAwait(false), - _ => database.WatchRecord(key) - .Where(change => change.Document != null) - .Select(change => change.Document!), + async _ => await ReadDatabaseDocumentRequiredAsync(database, key).ConfigureAwait(false), + _ => WatchDatabaseRecordAs(database, key), async value => { - await database.PutAsync(key, value).ConfigureAwait(false); + await PutDatabaseDocumentAsync(database, key, value, predicted: false).ConfigureAwait(false); }, async value => { - await database.PutPredictedAsync(key, value).ConfigureAwait(false); + await PutDatabaseDocumentAsync(database, key, value, predicted: true).ConfigureAwait(false); }, sourceList, route); @@ -2159,44 +2266,44 @@ namespace GameCult.Mesh where TDocument : class { var descriptor = CultDocumentRegistry.Shared.GetRequired(); - return await FetchSnapshotAsync( - createClient, - endpoint, - ToSnapshotRequestOptions( - options, - context, - options.SchemaIds ?? new[] { descriptor.SchemaId }, - new[] { recordKey })) + return await FetchSnapshotAsync( + createClient, + endpoint, + ToSnapshotRequestOptions( + options, + context, + options.SchemaIds ?? new[] { descriptor.SchemaId }, + new[] { recordKey })) .ConfigureAwait(false); } - private static CultMeshSnapshotRequestOptions ToSnapshotRequestOptions( + private static CultMeshSnapshotRequestOptions ToSnapshotRequestOptions( CultMeshPeerSnapshotDocumentOptions options, - CultMeshQueryContext context, - IReadOnlyList? schemaIds, - IReadOnlyList? recordKeys) + CultMeshQueryContext context, + IReadOnlyList? schemaIds, + IReadOnlyList? recordKeys) { - return new CultMeshSnapshotRequestOptions + return new CultMeshSnapshotRequestOptions { - SchemaIds = schemaIds, - RecordKeys = recordKeys, - ShardId = options.ShardId, - ShardEpoch = options.ShardEpoch, - ResponseTimeout = options.ResponseTimeout, - ConnectTimeout = options.ConnectTimeout, - MessageIdPrefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) - ? $"cultmesh:{context.RuntimeId}:snapshot" - : options.MessageIdPrefix, - Security = options.Security, - ConfigureClient = options.ConfigureClient, - RudpRuntimeId = string.IsNullOrWhiteSpace(options.RudpRuntimeId) - ? context.RuntimeId - : options.RudpRuntimeId, - RudpConnectionId = options.RudpConnectionId, - RudpConnectPayload = options.RudpConnectPayload, - RudpMaxFragmentBytes = options.RudpMaxFragmentBytes, - RudpResendDelayMs = options.RudpResendDelayMs - }; + SchemaIds = schemaIds, + RecordKeys = recordKeys, + ShardId = options.ShardId, + ShardEpoch = options.ShardEpoch, + ResponseTimeout = options.ResponseTimeout, + ConnectTimeout = options.ConnectTimeout, + MessageIdPrefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) + ? $"cultmesh:{context.RuntimeId}:snapshot" + : options.MessageIdPrefix, + Security = options.Security, + ConfigureClient = options.ConfigureClient, + RudpRuntimeId = string.IsNullOrWhiteSpace(options.RudpRuntimeId) + ? context.RuntimeId + : options.RudpRuntimeId, + RudpConnectionId = options.RudpConnectionId, + RudpConnectPayload = options.RudpConnectPayload, + RudpMaxFragmentBytes = options.RudpMaxFragmentBytes, + RudpResendDelayMs = options.RudpResendDelayMs + }; } private static TDocument ReadDocumentFromSnapshotResponse( @@ -2643,5 +2750,146 @@ namespace GameCult.Mesh ?? throw new KeyNotFoundException( $"CultMesh document '{key.Value}' was not found as {typeof(TDocument).FullName}."); } + + private static async Task ReadDatabaseDocumentRequiredAsync( + CultNetDatabase database, + CultRecordKey key) + where TDocument : class + { + var document = await database.GetAsync(key).ConfigureAwait(false); + if (document != null) + return document; + + var untyped = database.Cache.Get(key); + if (untyped != null && IsSameCultDocumentSchema(untyped.GetType())) + return ConvertUntypedDocument(untyped); + + throw new KeyNotFoundException( + $"CultMesh document '{key.Value}' was not found as {typeof(TDocument).FullName}."); + } + + private static R3.Observable WatchDatabaseRecordAs( + CultNetDatabase database, + CultRecordKey key) + where TDocument : class + { + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + return database.WatchAllChanges() + .Where(change => TryConvertDatabaseChange(change, key, descriptor, out TDocument? _)) + .Select(change => + { + TryConvertDatabaseChange(change, key, descriptor, out TDocument? document); + return document!; + }); + } + + private static bool TryConvertDatabaseChange( + object change, + CultRecordKey key, + CultDocumentDescriptor descriptor, + out TDocument? document) + where TDocument : class + { + document = null; + if (change == null) return false; + + var changeType = change.GetType(); + if (changeType.GetProperty("Key")?.GetValue(change) is not CultRecordKey changedKey || + !changedKey.Equals(key)) + { + return false; + } + + var current = changeType.GetProperty("Document")?.GetValue(change); + if (current == null) + return false; + + var schemaId = changeType.GetProperty("SchemaId")?.GetValue(change) as string; + if (!IsSameCultDocumentSchema(current.GetType(), descriptor, schemaId)) + return false; + + document = current as TDocument ?? ConvertUntypedDocument(current); + return true; + } + + private static bool IsSameCultDocumentSchema(Type documentType) + where TDocument : class + { + return IsSameCultDocumentSchema( + documentType, + CultDocumentRegistry.Shared.GetRequired()); + } + + private static bool IsSameCultDocumentSchema( + Type documentType, + CultDocumentDescriptor descriptor, + string? schemaId = null) + { + if (!string.IsNullOrWhiteSpace(schemaId) && + string.Equals(schemaId, descriptor.SchemaId, StringComparison.Ordinal)) + { + return true; + } + + CultDocumentDescriptor storedDescriptor; + try + { + storedDescriptor = CultDocumentRegistry.Shared.GetRequired(documentType); + } + catch (Exception) + { + return false; + } + + return string.Equals(storedDescriptor.SchemaName, descriptor.SchemaName, StringComparison.Ordinal) && + string.Equals(storedDescriptor.SchemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal); + } + + private static TDocument ConvertUntypedDocument(object document) + where TDocument : class + { + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType()); + return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), payload); + } + + private static object ConvertUntypedDocument(object document, Type targetType) + { + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType()); + return CultDocumentMessagePackSerialization.DeserializeUntyped(targetType, payload); + } + + private static async Task PutDatabaseDocumentAsync( + CultNetDatabase database, + CultRecordKey key, + TDocument value, + bool predicted) + where TDocument : class + { + var stored = database.Cache.Get(key); + object outgoing = value; + var outgoingType = typeof(TDocument); + if (stored != null && + !outgoingType.IsInstanceOfType(stored) && + IsSameCultDocumentSchema(stored.GetType(), CultDocumentRegistry.Shared.GetRequired())) + { + outgoing = ConvertUntypedDocument(value, stored.GetType()); + outgoingType = stored.GetType(); + } + + var method = typeof(CultNetDatabase) + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(candidate => candidate.Name == (predicted + ? nameof(CultNetDatabase.PutPredictedAsync) + : nameof(CultNetDatabase.PutAsync))) + .Single(candidate => + candidate.IsGenericMethodDefinition && + candidate.GetParameters() is { Length: 2 } parameters && + parameters[0].ParameterType == typeof(CultRecordKey)); + + var task = (Task)method + .MakeGenericMethod(outgoingType) + .Invoke(database, new object[] { key, outgoing })!; + await task.ConfigureAwait(false); + } } } diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 2cfa18b8..a13cd9b7 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1397,6 +1397,73 @@ public sealed class CultMeshStreamingTests cache.Get(key)!.Text.Should().Be("pilot-coalesced-order"); } + [Test] + public async Task NodeReactiveDocument_OpensSameSchemaAliasWithOneCall() + { + var schemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId; + using var temp = new TemporaryDirectory(); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(temp.Path, "node-reactive-alias.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + DatabaseOptions = new CultNetDatabaseOptions + { + RuntimeId = "pilot-a", + Shards = new[] + { + new CultNetShardDescriptor( + "pilot-inputs", + "server", + epoch: 1, + isPrimary: false, + schemaIds: new[] { schemaId }, + keyPrefix: "input:") + }, + ClientAuthorityScopes = new[] + { + new CultNetClientAuthorityScope( + "pilot-a", + schemaIds: new[] { schemaId }, + keyPrefix: "input:pilot-a") + } + } + }); + var key = new CultRecordKey("input:pilot-a:node-reactive"); + await node.Cache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "node-initial", + Revision = 1 + }, new CultRecordHandle(key)); + + using var pilotReactive = await node.ReactiveDocumentAsync( + key, + CultMesh.Verse("starbridge", "pilot-a"), + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + var commanderHandle = node.Document( + key.Value, + CultMesh.Verse("starbridge", "commander-rts")); + var observedByCommander = new List(); + using var commanderSubscription = commanderHandle.Watch(observedByCommander.Add); + + pilotReactive.Update(document => + { + document.Text = "node-one-call-prediction"; + document.Revision = 2; + }); + await pilotReactive.FlushAsync(); + + await WaitForAsync(() => observedByCommander.Any(document => document.Text == "node-one-call-prediction")); + pilotReactive.Document.DocumentId.Should().Be(key.Value); + pilotReactive.Document.Context.RuntimeId.Should().Be("pilot-a"); + node.Cache.Get(key)!.Text.Should().Be("node-one-call-prediction"); + (await commanderHandle.AsSchemaAlias().LatestAsync()).Revision.Should().Be(2); + } + [Test] public async Task ReactiveDocument_SyncedSnapshotEndpointCapturesCanonicalCorrectionAcrossRuntimeBoundary() { From a2b2f8ea8cca9a4f426818eb1ccdca7a7c5be09b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 20:42:09 +0100 Subject: [PATCH 053/498] Add TS node reactive document shortcut --- packages/cultmesh-ts/src/index.ts | 50 ++++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 37 ++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index d8edb3aa..dab2089e 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -3673,6 +3673,39 @@ export class CultMeshNode { return cultMeshDocumentFromCache(this.cache, definition, key, options); } + public reactiveDocument( + definition: TDefinition, + key: string, + options: { + context?: CultMeshQueryContext | string; + watch?: boolean; + debounceMs?: number; + onError?: (error: unknown) => void; + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshReactiveDocument & object> { + const { + documentId, + routeHint, + pollMs, + ...reactiveOptions + } = options; + const schema = cultMeshSchemaFromDefinition(definition); + const binding = + this.documents.get(definition.type) ?? + (schema.schemaId ? this.documents.getBySchemaId(schema.schemaId) : undefined); + const document = binding + ? this.document(binding.definition, key, { documentId, routeHint, pollMs }) + .asSchemaAlias>(schema, { + parse: value => definition.schema.parse(value), + }) + : this.document(definition, key, { documentId, routeHint, pollMs }); + + return document.reactive & object>(reactiveOptions); + } + public globalDocument( definition: TDefinition, options: { @@ -4590,6 +4623,23 @@ export class CultMesh { return cultMeshGlobalDocumentFromCache(cache, definition, options); } + public static reactiveDocument( + node: CultMeshNode, + definition: TDefinition, + key: string, + options: { + context?: CultMeshQueryContext | string; + watch?: boolean; + debounceMs?: number; + onError?: (error: unknown) => void; + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshReactiveDocument & object> { + return node.reactiveDocument(definition, key, options); + } + public static documents( ...documents: readonly CultMeshDocumentHandle[] ): CultMeshDocumentCatalog { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 8266bc24..d917d5a4 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -240,6 +240,43 @@ test("CultMesh TS reactive documents submit predictions from member writes", asy assert.deepEqual(predictions, ["member-write-prediction-final"]); }); +test("CultMesh TS node opens reactive same-schema aliases with one call", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-node-reactive-")), "node.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:node-reactive", { + noteId: "note:node-reactive", + body: "initial", + }); + + const reactive = node.reactiveDocument(noteAliasDocument, "note:node-reactive", { + context: CultMesh.queryContext("browser-client"), + watch: false, + }); + await reactive.ready; + reactive.current.body = "edited-through-node-helper"; + await waitFor( + () => node.getRequired(noteDocument, "note:node-reactive").body === "edited-through-node-helper", + "node-level reactive alias write", + ); + + assert.equal(reactive.current.noteId, "note:node-reactive"); + assert.equal( + node.document(noteDocument, "note:node-reactive").asSchemaAlias(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).canSet, + true, + ); + assert.equal( + (await CultMesh.reactiveDocument(node, noteAliasDocument, "note:node-reactive", { + watch: false, + }).ready).body, + "edited-through-node-helper", + ); + reactive.dispose(); +}); + test("CultMesh TS reactive documents store reconciliation deltas after misprediction", async () => { let current = { noteId: "note:reconcile", From d62f35fbaec531025fac4e94a7bcc6790d258d9e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 20:46:39 +0100 Subject: [PATCH 054/498] Resolve TS node document aliases transparently --- packages/cultmesh-ts/src/index.ts | 92 +++++++++++++++++----- packages/cultmesh-ts/test/cultmesh.test.ts | 29 ++++++- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index dab2089e..e968d7e0 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -3636,14 +3636,23 @@ export class CultMeshNode { definition: TDefinition, key: string, ): CultCacheDocumentValue | undefined { - return this.cache.get(definition, key); + const registered = this.resolveCacheDefinition(definition); + const value = this.cache.get(registered, key); + return value === undefined + ? undefined + : this.parseDocumentValue(definition, value); } public getRequired( definition: TDefinition, key: string, ): CultCacheDocumentValue { - return this.cache.getRequired(definition, key); + const value = this.get(definition, key); + if (value === undefined) { + throw new Error(`CultMesh node has no "${definition.type}" document at key "${key}".`); + } + + return value; } public put( @@ -3651,14 +3660,17 @@ export class CultMeshNode { key: string, value: CultCacheDocumentValue, ): Promise> { - return this.cache.put(definition, key, value); + const registered = this.resolveCacheDefinition(definition); + return this.cache + .put(registered, key, this.parseDocumentValue(definition, value) as never) + .then(stored => this.parseDocumentValue(definition, stored)); } public delete( definition: TDefinition, key: string, ): Promise { - return this.cache.delete(definition, key); + return this.cache.delete(this.resolveCacheDefinition(definition), key); } public document( @@ -3670,7 +3682,16 @@ export class CultMeshNode { pollMs?: number; } = {}, ): CultMeshDocumentHandle> { - return cultMeshDocumentFromCache(this.cache, definition, key, options); + const documentId = options.documentId ?? `${definition.type}:${key}`; + return this.asRequestedDocument( + cultMeshDocumentFromCache( + this.cache, + this.resolveCacheDefinition(definition), + key, + { ...options, documentId }, + ), + definition, + ); } public reactiveDocument( @@ -3692,18 +3713,8 @@ export class CultMeshNode { pollMs, ...reactiveOptions } = options; - const schema = cultMeshSchemaFromDefinition(definition); - const binding = - this.documents.get(definition.type) ?? - (schema.schemaId ? this.documents.getBySchemaId(schema.schemaId) : undefined); - const document = binding - ? this.document(binding.definition, key, { documentId, routeHint, pollMs }) - .asSchemaAlias>(schema, { - parse: value => definition.schema.parse(value), - }) - : this.document(definition, key, { documentId, routeHint, pollMs }); - - return document.reactive & object>(reactiveOptions); + return this.document(definition, key, { documentId, routeHint, pollMs }) + .reactive & object>(reactiveOptions); } public globalDocument( @@ -3714,7 +3725,15 @@ export class CultMeshNode { pollMs?: number; } = {}, ): CultMeshDocumentHandle> { - return cultMeshGlobalDocumentFromCache(this.cache, definition, options); + const documentId = options.documentId ?? `${definition.type}:global`; + return this.asRequestedDocument( + cultMeshGlobalDocumentFromCache( + this.cache, + this.resolveCacheDefinition(definition), + { ...options, documentId }, + ), + definition, + ); } public collection( @@ -3725,12 +3744,47 @@ export class CultMeshNode { pollMs?: number; } = {}, ): CultMeshCollectionHandle> { - return cultMeshCollectionFromCache(this.cache, definition, options); + const collectionId = options.collectionId ?? definition.type; + return cultMeshCollectionFromCache( + this.cache, + this.resolveCacheDefinition(definition), + { ...options, collectionId }, + ).asSchemaAlias>( + cultMeshSchemaFromDefinition(definition), + { parse: value => this.parseDocumentValue(definition, value) }, + ); } public async flush(soft = false): Promise { await this.store.pushAll?.(this.cache.snapshot(), { soft }); } + + private resolveCacheDefinition( + definition: TDefinition, + ): AnyCultCacheDocumentDefinition { + const schema = cultMeshSchemaFromDefinition(definition); + return ( + this.documents.get(definition.type) ?? + (schema.schemaId ? this.documents.getBySchemaId(schema.schemaId) : undefined) + )?.definition ?? definition; + } + + private asRequestedDocument( + document: CultMeshDocumentHandle, + definition: TDefinition, + ): CultMeshDocumentHandle> { + return document.asSchemaAlias>( + cultMeshSchemaFromDefinition(definition), + { parse: value => this.parseDocumentValue(definition, value) }, + ); + } + + private parseDocumentValue( + definition: TDefinition, + value: unknown, + ): CultCacheDocumentValue { + return definition.schema.parse(value) as CultCacheDocumentValue; + } } export class CultMeshVerseCatalog { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index d917d5a4..28484033 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -240,7 +240,7 @@ test("CultMesh TS reactive documents submit predictions from member writes", asy assert.deepEqual(predictions, ["member-write-prediction-final"]); }); -test("CultMesh TS node opens reactive same-schema aliases with one call", async () => { +test("CultMesh TS node opens same-schema aliases with one call", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-node-reactive-")), "node.ccmp"); const node = await CultMesh.startNode(filePath, { documents: [noteDocument], @@ -250,6 +250,33 @@ test("CultMesh TS node opens reactive same-schema aliases with one call", async body: "initial", }); + const aliasHandle = node.document(noteAliasDocument, "note:node-reactive", { + pollMs: 5, + }); + assert.equal(aliasHandle.documentId, "cultmesh.note.ui:note:node-reactive"); + assert.equal((await aliasHandle.latest()).body, "initial"); + await aliasHandle.set({ + noteId: "note:node-reactive", + body: "edited-through-alias-handle", + }); + assert.equal( + node.getRequired(noteAliasDocument, "note:node-reactive").body, + "edited-through-alias-handle", + ); + + await node.put(noteAliasDocument, "note:node-reactive", { + noteId: "note:node-reactive", + body: "edited-through-alias-put", + }); + assert.equal( + node.getRequired(noteDocument, "note:node-reactive").body, + "edited-through-alias-put", + ); + assert.deepEqual( + (await node.collection(noteAliasDocument).latest()).map(note => note.body), + ["edited-through-alias-put"], + ); + const reactive = node.reactiveDocument(noteAliasDocument, "note:node-reactive", { context: CultMesh.queryContext("browser-client"), watch: false, From a2ec06f90e3645a82a414fd65581167505eb72be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 20:52:26 +0100 Subject: [PATCH 055/498] Resolve Python node document aliases transparently --- packages/cultcache-py/README.md | 14 ++++ packages/cultcache-py/src/cultmesh_py/node.py | 73 ++++++++++++++----- packages/cultcache-py/tests/test_cultcache.py | 40 ++++++++++ packages/cultmesh-ts/README.md | 19 +++++ src/GameCult.Mesh/docs/public-api.md | 39 +++++----- 5 files changed, 147 insertions(+), 38 deletions(-) diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 265eecb7..cd1427d6 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -300,6 +300,20 @@ delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id=" shard_log_response = node.database.build_shard_log_response(shard_id="interop", shard_epoch=1) shard_log_wire = node.database.create_shard_log_response(shard_id="interop", shard_epoch=1) +station_stock_doc = define_database_entry_type( + "aetheria.station_stock", + [("missiles", 0), ("coolant", 1)], + schema_id="gamecult.aetheria.station_stock.v1", +) +station_stock_ui_doc = define_database_entry_type( + "aetheria.station_stock.ui", + [("missiles", 0), ("coolant", 1)], + schema_id="gamecult.aetheria.station_stock.v1", +) +node.database.register_document(station_stock_doc) +node.database.put(station_stock_ui_doc, "station:starbridge:stock", {"missiles": 24, "coolant": 80}) +stock = node.database.get_required(station_stock_ui_doc, "station:starbridge:stock") + peers = CultMesh.create_peer_catalog() response = peers.create_response(peer_exchange_request("pex-1", verse_id="local")) lease_verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"shared-lease-key"}) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index b567ca82..b6c3495a 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any, Callable from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore +from cultcache_py.cache import CultCacheError from cultcache_py.documents import DocumentDefinition, extract_value from cultnet_py import ( CultNetAppliedRecord, @@ -268,6 +269,7 @@ class CultMeshDatabase: key: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: + document = self._resolve_document_alias(document) return self.watch(callback, document=document, key=key) def watch_global( @@ -283,6 +285,7 @@ class CultMeshDatabase: name: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: + document = self._resolve_document_alias(document) if not name: raise ValueError("Name watch values must be non-empty") if document.name is None: @@ -301,6 +304,7 @@ class CultMeshDatabase: value: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: + document = self._resolve_document_alias(document) if not index: raise ValueError("Index watch aliases must be non-empty") if not value: @@ -329,9 +333,11 @@ class CultMeshDatabase: self._shard_log_store = store def put(self, document: DocumentDefinition[Any], key: str, value: Any) -> None: - previous = self.cache.get(document, key) - self.cache.put(document, key, value) - self._publish_local_change(document, key, "added" if previous is None else "updated", value, previous) + registered = self._resolve_document_alias(document) + parsed = document.decode_payload(document.encode_payload(value)) + previous = self.cache.get(registered, key) + self.cache.put(registered, key, parsed) + self._publish_local_change(registered, key, "added" if previous is None else "updated", parsed, previous) def put_global(self, document: DocumentDefinition[Any], value: Any) -> None: self.put(document, self.cache.GLOBAL_KEY, value) @@ -346,17 +352,19 @@ class CultMeshDatabase: shard_id: str | None = None, shard_epoch: int | None = None, ) -> CultNetMessage: - catalog_entry = document.catalog_entry() - previous = self.cache.get(document, key) + registered = self._resolve_document_alias(document) + parsed = document.decode_payload(document.encode_payload(value)) + catalog_entry = registered.catalog_entry() + previous = self.cache.get(registered, key) envelope = CultCacheEnvelope.create( key=key, - type=document.type, - payload=document.encode_payload(value), + type=registered.type, + payload=registered.encode_payload(parsed), schema_id=catalog_entry.schema_id, catalog_entry=catalog_entry, ) - value = self.cache.put_envelope(document, envelope) - self._publish_local_change(document, key, "added" if previous is None else "updated", value, previous) + value = self.cache.put_envelope(registered, envelope) + self._publish_local_change(registered, key, "added" if previous is None else "updated", value, previous) message = document_put_raw( message_id=message_id, key=key, @@ -371,22 +379,27 @@ class CultMeshDatabase: return message def get(self, document: DocumentDefinition[Any], key: str) -> Any: - return self.cache.get(document, key) + value = self.cache.get(self._resolve_document_alias(document), key) + return None if value is None else document.decode_payload(document.encode_payload(value)) def get_required(self, document: DocumentDefinition[Any], key: str) -> Any: - return self.cache.get_required(document, key) + value = self.get(document, key) + if value is None: + raise CultCacheError(f"Missing {document.type}:{key}") + return value def get_global(self, document: DocumentDefinition[Any]) -> Any: - return self.cache.get_global(document) + return self.get(document, self.cache.GLOBAL_KEY) def get_required_global(self, document: DocumentDefinition[Any]) -> Any: - return self.cache.get_required_global(document) + return self.get_required(document, self.cache.GLOBAL_KEY) def delete(self, document: DocumentDefinition[Any], key: str) -> None: - previous = self.cache.get(document, key) - self.cache.delete(document, key) + registered = self._resolve_document_alias(document) + previous = self.cache.get(registered, key) + self.cache.delete(registered, key) if previous is not None: - self._publish_local_change(document, key, "removed", None, previous) + self._publish_local_change(registered, key, "removed", None, previous) def delete_global(self, document: DocumentDefinition[Any]) -> None: self.delete(document, self.cache.GLOBAL_KEY) @@ -400,7 +413,8 @@ class CultMeshDatabase: shard_id: str | None = None, shard_epoch: int | None = None, ) -> CultNetMessage: - schema_id = document.catalog_entry().schema_id + registered = self._resolve_document_alias(document) + schema_id = registered.catalog_entry().schema_id self.delete(document, key) message = document_delete( message_id=message_id, @@ -716,7 +730,7 @@ class CultMeshDatabase: def _publish(self, change: CultMeshDatabaseChange) -> None: for document, key, callback in list(self._subscribers): - if document is not None and document.type != change.document.type: + if document is not None and not self._documents_alias(document, change.document): continue if key is not None and key != change.record_key: continue @@ -828,6 +842,29 @@ class CultMeshDatabase: return document return None + def _resolve_document_alias(self, document: DocumentDefinition[Any]) -> DocumentDefinition[Any]: + for registered in self.documents: + if self._documents_alias(document, registered): + return registered + return document + + def _documents_alias( + self, + left: DocumentDefinition[Any], + right: DocumentDefinition[Any], + ) -> bool: + if left.type == right.type: + return True + left_entry = left.catalog_entry() + right_entry = right.catalog_entry() + if left_entry.schema_id == right_entry.schema_id: + return True + if left_entry.schema_name == right_entry.schema_name and left_entry.schema_version == right_entry.schema_version: + return True + if left_entry.schema_id in right_entry.compatible_schema_ids: + return True + return right_entry.schema_id in left_entry.compatible_schema_ids + def _change_matches_extractor( self, change: CultMeshDatabaseChange, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 1f5660ec..c0fc2c49 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2019,6 +2019,46 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(delete["shardId"], "mesh") self.assertEqual(delete["shardEpoch"], 1) + def test_cultmesh_node_resolves_same_schema_aliases_for_local_state(self) -> None: + document = define_database_entry_type( + "mesh.alias_note", + [("body", 0)], + schema_id="mesh.alias_note.v1", + schema_name="mesh.alias_note", + schema_version="mesh.alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.alias_note.ui", + [("body", 0)], + schema_id="mesh.alias_note.v1", + schema_name="mesh.alias_note", + schema_version="mesh.alias_note.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + seen: list[CultMeshDatabaseChange] = [] + node.database.watch_record(alias, "note:alias", seen.append) + + node.put(alias, "note:alias", {"body": "from-alias-put"}) + self.assertEqual(node.get_required(document, "note:alias")["body"], "from-alias-put") + self.assertEqual(node.get_required(alias, "note:alias")["body"], "from-alias-put") + self.assertEqual(seen[0].document.type, document.type) + self.assertEqual(seen[0].value["body"], "from-alias-put") + + put = node.put_raw_message( + alias, + "note:alias", + {"body": "from-alias-raw-put"}, + message_id="alias-put", + ).to_wire() + self.assertEqual(put["document"]["schemaId"], "mesh.alias_note.v1") + self.assertEqual(put["document"]["recordKey"], "note:alias") + self.assertEqual(node.get_required(alias, "note:alias")["body"], "from-alias-raw-put") + + delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire() + self.assertEqual(delete["schemaId"], "mesh.alias_note.v1") + self.assertIsNone(node.get(alias, "note:alias")) + def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None: message = document_delete( message_id="delete-1", diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index a6dbf6c8..5ffc1033 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -87,6 +87,25 @@ await input.submitPrediction("pilot-a", predictedInput); const stop = input.watch("pilot-a", latest => reconcileInput(latest)); ``` +Durable nodes resolve same-schema aliases at the boundary. Register the +canonical document that owns storage and replication, then ask the node for the +typed view the current runtime wants: + +```ts +const station = await CultMesh.startNode(statePath, { + documents: [stationStockDocument], +}); + +const stock = station.document(stationStockUiDocument, "station:starbridge:stock"); +const current = await stock.latest(); + +await stock.set(updatedStockFromUi); + +const reactive = station.reactiveDocument(stationStockUiDocument, "station:starbridge:stock"); +await reactive.ready; +reactive.current.availableMissiles -= 4; +``` + State pointers are the same kind of managed surface for UI and tools. They can advertise source documents, inherit a Verse route when bound, and resolve through that Verse without caller-side context plumbing: diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 52df0845..1b592748 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -170,9 +170,11 @@ portable machinery that makes it feel native. `CultCache` record, a distributed `CultNetDatabase` record, or a `CultMeshNode` record through the same surface instead of walking transport and projection layers themselves. Same-schema CLR aliases can be requested - with `AsSchemaAlias()`; CultMesh verifies the shared - `[CultDocument(schemaName, schemaVersion)]` identity and uses the shared - CultCache MessagePack codec for read/watch/replace/prediction conversion. + directly from node/database/snapshot surfaces; CultMesh resolves the canonical + registered document by shared `[CultDocument(schemaName, schemaVersion)]` + identity and uses the shared CultCache MessagePack codec for + read/watch/replace/prediction conversion. `AsSchemaAlias()` remains + available when a caller already has a handle and wants to project it. - `CultMeshDocumentCatalog` is the schema-aware lookup edge for a set of document handles. It indexes handles by CLR document type, schema name, and schema version, and resolves same-schema CLR aliases by delegating to @@ -236,19 +238,19 @@ var objects = await visibleObjects.ExecuteAsync(viewportRequest); await movePilot.InvokeAsync(moveRequest, idempotencyKey); ``` -A single typed document can be surfaced the same way: +A single typed document can be surfaced the same way. The caller asks for the +typed view it wants; if that type is a same-schema alias of the registered +canonical document, CultMesh still opens the correct backing record: ```csharp -var stationStock = CultMesh.Document( +var stockForUi = CultMesh.Document( node, new CultRecordKey("station:starbridge:stock"), verse); -var stock = await stationStock.LatestAsync(); -using var watch = stationStock.Watch(RenderStock); - -var uiAlias = stationStock.AsSchemaAlias(); -await uiAlias.ReplaceAsync(updatedStockFromUi); +var stock = await stockForUi.LatestAsync(); +using var watch = stockForUi.Watch(RenderStock); +await stockForUi.SetAsync(updatedStockFromUi); ``` Projected or derived documents can use the same handle shape with explicit @@ -293,7 +295,8 @@ read when another runtime generated a different schema id for a compatible schema alias. The decode path prefers exact schema id matches when present and falls back to the requested record key. -A domain facade can collect handles into one schema-aware catalog: +A domain facade can still collect existing handles into one schema-aware +catalog when it is composing pre-built surfaces: ```csharp var documents = CultMesh.Documents( @@ -340,20 +343,16 @@ const station = await CultMesh.startNode(statePath, { documents: [stationStockDocument, currentDockingDocument], }); -const stock = station.document(stationStockDocument, "station:starbridge:stock"); +const stock = station.document(stationStockUiDocument, "station:starbridge:stock"); const docking = station.document(currentDockingDocument, "player:raven:docking"); const catalog = CultMesh.documents(stock, docking); -const stockUi = catalog.document(stationStockUiDocument, { - parse: value => stationStockUiDocument.schema.parse(value), -}); - -const currentStock = await stockUi.latest(); -const unsubscribe = stockUi.watch(next => renderStock(next)); -await stockUi.replace(updatedStock); +const currentStock = await stock.latest(); +const unsubscribe = stock.watch(next => renderStock(next)); +await stock.set(updatedStock); unsubscribe(); -const pilots = station.collection(playerShipDocument); +const pilots = station.collection(playerShipUiDocument); const currentPilots = await pilots.latest(); const stopRoster = pilots.watchChanges(change => refreshRoster(change)); From defc416f9f000b70e13e058d3126caa43a9b29e9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 20:55:17 +0100 Subject: [PATCH 056/498] Convert Python node aliases through payload schema --- packages/cultcache-py/src/cultmesh_py/node.py | 17 ++++++++++--- packages/cultcache-py/tests/test_cultcache.py | 25 ++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index b6c3495a..c67d1be5 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -334,7 +334,7 @@ class CultMeshDatabase: def put(self, document: DocumentDefinition[Any], key: str, value: Any) -> None: registered = self._resolve_document_alias(document) - parsed = document.decode_payload(document.encode_payload(value)) + parsed = self._convert_document_value(value, document, registered) previous = self.cache.get(registered, key) self.cache.put(registered, key, parsed) self._publish_local_change(registered, key, "added" if previous is None else "updated", parsed, previous) @@ -353,7 +353,7 @@ class CultMeshDatabase: shard_epoch: int | None = None, ) -> CultNetMessage: registered = self._resolve_document_alias(document) - parsed = document.decode_payload(document.encode_payload(value)) + parsed = self._convert_document_value(value, document, registered) catalog_entry = registered.catalog_entry() previous = self.cache.get(registered, key) envelope = CultCacheEnvelope.create( @@ -379,8 +379,9 @@ class CultMeshDatabase: return message def get(self, document: DocumentDefinition[Any], key: str) -> Any: - value = self.cache.get(self._resolve_document_alias(document), key) - return None if value is None else document.decode_payload(document.encode_payload(value)) + registered = self._resolve_document_alias(document) + value = self.cache.get(registered, key) + return None if value is None else self._convert_document_value(value, registered, document) def get_required(self, document: DocumentDefinition[Any], key: str) -> Any: value = self.get(document, key) @@ -848,6 +849,14 @@ class CultMeshDatabase: return registered return document + def _convert_document_value( + self, + value: Any, + source: DocumentDefinition[Any], + target: DocumentDefinition[Any], + ) -> Any: + return target.decode_payload(source.encode_payload(value)) + def _documents_alias( self, left: DocumentDefinition[Any], diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index c0fc2c49..94dcb0a7 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2020,9 +2020,18 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(delete["shardEpoch"], 1) def test_cultmesh_node_resolves_same_schema_aliases_for_local_state(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + document = define_database_entry_type( "mesh.alias_note", [("body", 0)], + cls=CanonicalNote, schema_id="mesh.alias_note.v1", schema_name="mesh.alias_note", schema_version="mesh.alias_note.v1", @@ -2030,6 +2039,7 @@ class CultCacheTests(unittest.TestCase): alias = define_database_entry_type( "mesh.alias_note.ui", [("body", 0)], + cls=UiNote, schema_id="mesh.alias_note.v1", schema_name="mesh.alias_note", schema_version="mesh.alias_note.v1", @@ -2039,21 +2049,24 @@ class CultCacheTests(unittest.TestCase): seen: list[CultMeshDatabaseChange] = [] node.database.watch_record(alias, "note:alias", seen.append) - node.put(alias, "note:alias", {"body": "from-alias-put"}) - self.assertEqual(node.get_required(document, "note:alias")["body"], "from-alias-put") - self.assertEqual(node.get_required(alias, "note:alias")["body"], "from-alias-put") + node.put(alias, "note:alias", UiNote("from-alias-put")) + self.assertEqual(node.get_required(document, "note:alias").body, "from-alias-put") + alias_value = node.get_required(alias, "note:alias") + self.assertIsInstance(alias_value, UiNote) + self.assertEqual(alias_value.body, "from-alias-put") self.assertEqual(seen[0].document.type, document.type) - self.assertEqual(seen[0].value["body"], "from-alias-put") + self.assertIsInstance(seen[0].value, CanonicalNote) + self.assertEqual(seen[0].value.body, "from-alias-put") put = node.put_raw_message( alias, "note:alias", - {"body": "from-alias-raw-put"}, + UiNote("from-alias-raw-put"), message_id="alias-put", ).to_wire() self.assertEqual(put["document"]["schemaId"], "mesh.alias_note.v1") self.assertEqual(put["document"]["recordKey"], "note:alias") - self.assertEqual(node.get_required(alias, "note:alias")["body"], "from-alias-raw-put") + self.assertEqual(node.get_required(alias, "note:alias").body, "from-alias-raw-put") delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire() self.assertEqual(delete["schemaId"], "mesh.alias_note.v1") From b16f49665a1314a7177abc7948e585ffe2f18a45 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:00:39 +0100 Subject: [PATCH 057/498] Decode snapshot aliases from compatible payloads --- src/GameCult.Mesh/CultMeshSnapshots.cs | 76 ++++++++++++++++++- .../CultMeshStreamingTests.cs | 43 +++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 0b17e6bb..1a2f21a2 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Networking; +using MessagePack; namespace GameCult.Mesh { @@ -679,7 +680,9 @@ namespace GameCult.Mesh binding != null && typeof(TDocument).IsAssignableFrom(binding.DocumentType); var canDeserializeAsSchemaAlias = - string.Equals(record.SchemaId, descriptor.SchemaId, StringComparison.Ordinal); + string.Equals(record.SchemaId, descriptor.SchemaId, StringComparison.Ordinal) || + (binding != null && IsSameCultDocumentSchema(binding.DocumentType, descriptor)) || + RawSnapshotPayloadMatchesSchema(record.Payload, descriptor); if (!canDeserializeWithBinding && !canDeserializeAsSchemaAlias) continue; @@ -689,12 +692,77 @@ namespace GameCult.Mesh $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{record.PayloadEncoding}\"."); } - documents.Add(canDeserializeWithBinding - ? (TDocument)binding!.PayloadDeserializer(record.Payload) - : CultDocumentMessagePackSerialization.Deserialize(record.Payload)); + if (binding != null) + { + var decoded = binding.PayloadDeserializer(record.Payload); + documents.Add(decoded is TDocument typed + ? typed + : ConvertUntypedDocument(decoded)); + } + else + { + documents.Add(CultDocumentMessagePackSerialization.Deserialize(record.Payload)); + } } return documents; } + + private static bool RawSnapshotPayloadMatchesSchema( + byte[] payload, + CultDocumentDescriptor descriptor) + { + var schemaVersion = TryReadSchemaVersion(payload); + if (string.IsNullOrWhiteSpace(schemaVersion)) + return false; + + if (string.Equals(schemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal)) + return true; + + var schemaName = InferSchemaName(schemaVersion!); + return !string.IsNullOrWhiteSpace(schemaName) && + string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal); + } + + private static string? TryReadSchemaVersion(byte[] payload) + { + try + { + var array = MessagePackSerializer.Deserialize(payload, CultNetSchemaMessageSerialization.Options); + if (array.Length > 0 && array[0] is string schemaVersion) + return schemaVersion; + } + catch (Exception) + { + // Fall through to map decoding; different runtimes may encode object-like payloads. + } + + try + { + var map = MessagePackSerializer.Deserialize>(payload, CultNetSchemaMessageSerialization.Options); + if (map.TryGetValue("schemaVersion", out var schemaVersion) && schemaVersion is string schemaVersionText) + return schemaVersionText; + if (map.TryGetValue("schema_version", out var snakeSchemaVersion) && snakeSchemaVersion is string snakeSchemaVersionText) + return snakeSchemaVersionText; + } + catch (Exception) + { + return null; + } + + return null; + } + + private static string? InferSchemaName(string schemaVersion) + { + var marker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal); + if (marker <= 0 || marker + 2 >= schemaVersion.Length) + return null; + + var version = schemaVersion.Substring(marker + 2); + return version.All(char.IsDigit) + ? schemaVersion.Substring(0, marker) + : null; + } } } diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index a13cd9b7..5b507649 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2322,6 +2322,49 @@ public sealed class CultMeshStreamingTests request.MessageId.StartsWith("mesh-test-scoped-snapshot:", StringComparison.Ordinal)); } + [Test] + public async Task SnapshotHelpers_DecodeSameSchemaAliasFromForeignSchemaId() + { + var key = new CultRecordKey("mesh-note:snapshot-alias-foreign-schema"); + var snapshot = new CultNetSnapshotResponseRawMessage + { + MessageId = "foreign-schema", + Documents = new[] + { + new CultNetRawDocumentRecord + { + SchemaId = "runtime.generated.mesh-note.ui.42", + RecordKey = key.Value, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "foreign-schema-alias", + Revision = 44 + }, + typeof(MeshNoteDocument)) + } + } + }; + + var documents = await CultMesh.FetchSnapshotDocumentsAsync( + "cultnet://foreign-schema.test:3075", + new CultMeshSnapshotRequestOptions + { + RecordKeys = new[] { key.Value }, + CreateClient = () => new MeshSnapshotSchemaClient(request => + { + snapshot.MessageId = request.MessageId; + return snapshot; + }) + }, + new CultNetDocumentRegistry()); + + documents.Should().ContainSingle().Which.Text.Should().Be("foreign-schema-alias"); + } + [Test] public async Task DocumentRegistryHelpers_CreateTypedNetworkRegistriesForAliases() { From d5f9d2d7525754b1beaf6df0c91bbcac8b29adfc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:03:35 +0100 Subject: [PATCH 058/498] Request snapshot aliases by record key --- src/GameCult.Mesh/CultMeshSnapshots.cs | 17 +++++++++++++++-- .../CultMeshStreamingTests.cs | 5 +---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 1a2f21a2..82828fbd 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -291,7 +291,7 @@ namespace GameCult.Mesh var descriptor = CultDocumentRegistry.Shared.GetRequired(); return CultMesh.FetchSnapshotDocumentsAsync( Endpoint, - CreateRequest(schemaIds ?? new[] { descriptor.SchemaId }, recordKeys), + CreateRequest(ResolveDefaultSchemaFilter(schemaIds, recordKeys, descriptor), recordKeys), DocumentRegistry); } @@ -308,7 +308,7 @@ namespace GameCult.Mesh var descriptor = CultDocumentRegistry.Shared.GetRequired(); var result = await SyncSnapshotAsync( node, - schemaIds ?? new[] { descriptor.SchemaId }, + ResolveDefaultSchemaFilter(schemaIds, recordKeys, descriptor), recordKeys, flush) .ConfigureAwait(false); @@ -448,6 +448,19 @@ namespace GameCult.Mesh return request; } + private static IReadOnlyList? ResolveDefaultSchemaFilter( + IReadOnlyList? schemaIds, + IReadOnlyList? recordKeys, + CultDocumentDescriptor descriptor) + { + if (schemaIds != null) + return schemaIds; + + return recordKeys is { Count: > 0 } + ? null + : new[] { descriptor.SchemaId }; + } + private static CultMeshSnapshotRequestOptions CloneSnapshotRequestOptions(CultMeshSnapshotRequestOptions? source) { if (source == null) diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 5b507649..c146e7d4 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2486,10 +2486,7 @@ public sealed class CultMeshStreamingTests syncedCatalogAlias.Text.Should().Be("snapshot-endpoint-catalog-synced"); requests.Should().HaveCount(6); requests.Should().OnlyContain(request => - request.SchemaIds!.SequenceEqual(new[] - { - CultDocumentRegistry.Shared.GetRequired().SchemaId - }) && + request.SchemaIds == null && request.RecordKeys!.SequenceEqual(new[] { key.Value }) && request.ShardId == "primary" && request.ShardEpoch == 9 && From e430a725ad5e85f6d6e777645b8cbf8ff5143870 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:12:05 +0100 Subject: [PATCH 059/498] Apply snapshot aliases by payload schema --- .../CultNetDocumentRegistry.cs | 93 ++++++++++++++++++- .../CultMeshStreamingTests.cs | 69 ++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index fe039dfa..0e949d9f 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; +using MessagePack; namespace GameCult.Networking { @@ -344,7 +345,7 @@ namespace GameCult.Networking var binding = GetBySchemaId(message.Document.SchemaId); var descriptor = binding != null ? _documents.GetRequired(binding.DocumentType) - : _documents.GetRequiredBySchemaId(message.Document.SchemaId); + : ResolveDescriptorForRawDocument(message.Document); binding ??= new CultNetDocumentBinding( descriptor.DocumentType, descriptor.SchemaId, @@ -394,6 +395,96 @@ namespace GameCult.Networking return applied; } + private CultDocumentDescriptor ResolveDescriptorForRawDocument(CultNetRawDocumentRecord document) + { + try + { + return _documents.GetRequiredBySchemaId(document.SchemaId); + } + catch (InvalidOperationException) when (TryResolveDescriptorByPayloadSchema(document.Payload) is { } descriptor) + { + return descriptor; + } + } + + private CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload) + { + var schemaVersion = TryReadSchemaVersion(payload); + if (string.IsNullOrWhiteSpace(schemaVersion)) + return null; + + var candidates = ResolvePayloadSchemaCandidates().ToArray(); + var descriptor = candidates.FirstOrDefault(candidate => + string.Equals(candidate.SchemaVersion, schemaVersion, StringComparison.Ordinal)); + if (descriptor != null) + return descriptor; + + var schemaName = InferSchemaName(schemaVersion!); + return string.IsNullOrWhiteSpace(schemaName) + ? null + : candidates.FirstOrDefault(candidate => + string.Equals(candidate.SchemaName, schemaName, StringComparison.Ordinal)); + } + + private IEnumerable ResolvePayloadSchemaCandidates() + { + if (_bindingsBySchemaId.Count == 0 && _bindingsByType.Count == 0) + return _documents.AllDescriptors; + + return _bindingsBySchemaId.Values + .Concat(_bindingsByType.Values) + .Select(binding => _documents.GetRequired(binding.DocumentType)) + .GroupBy(descriptor => descriptor.DocumentType) + .Select(group => group.First()); + } + + private static string? TryReadSchemaVersion(byte[] payload) + { + try + { + var array = MessagePackSerializer.Deserialize( + payload, + CultNetSchemaMessageSerialization.Options); + if (array.Length > 0 && array[0] is string schemaVersion) + return schemaVersion; + } + catch (Exception) + { + // Fall through to map decoding; different runtimes may encode object-like payloads. + } + + try + { + var map = MessagePackSerializer.Deserialize>( + payload, + CultNetSchemaMessageSerialization.Options); + if (map.TryGetValue("schemaVersion", out var schemaVersion) && + schemaVersion is string schemaVersionText) + return schemaVersionText; + if (map.TryGetValue("schema_version", out var snakeSchemaVersion) && + snakeSchemaVersion is string snakeSchemaVersionText) + return snakeSchemaVersionText; + } + catch (Exception) + { + return null; + } + + return null; + } + + private static string? InferSchemaName(string schemaVersion) + { + var marker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal); + if (marker <= 0 || marker + 2 >= schemaVersion.Length) + return null; + + var version = schemaVersion.Substring(marker + 2); + return version.All(char.IsDigit) + ? schemaVersion.Substring(0, marker) + : null; + } + /// /// Applies all typed documents from a raw snapshot response. /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index c146e7d4..205789a9 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2365,6 +2365,75 @@ public sealed class CultMeshStreamingTests documents.Should().ContainSingle().Which.Text.Should().Be("foreign-schema-alias"); } + [Test] + public async Task SnapshotEndpoint_SyncsCompatibleForeignSchemaIdIntoLocalCache() + { + var key = new CultRecordKey("mesh-note:snapshot-sync-foreign-schema"); + var snapshot = new CultNetSnapshotResponseRawMessage + { + MessageId = "foreign-schema-sync", + Documents = new[] + { + new CultNetRawDocumentRecord + { + SchemaId = "runtime.generated.mesh-note.ui.84", + RecordKey = key.Value, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "foreign-schema-synced", + Revision = 45 + }, + typeof(MeshNoteDocument)) + } + } + }; + var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry( + typeof(MeshNoteDocument), + typeof(MeshNoteAliasDocument)); + var registry = CultMesh.CreateCultNetDocumentRegistry( + new[] { typeof(MeshNoteDocument), typeof(MeshNoteAliasDocument) }, + cacheRegistry); + var endpoint = CultMesh.SnapshotEndpoint( + "cultnet://foreign-schema.test:3075", + new CultMeshSnapshotEndpointOptions + { + DocumentRegistry = registry, + Request = new CultMeshSnapshotRequestOptions + { + CreateClient = () => new MeshSnapshotSchemaClient(request => + { + snapshot.MessageId = request.MessageId; + return snapshot; + }) + } + }); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(Path.GetTempPath(), $"cultmesh-foreign-schema-sync-{Guid.NewGuid():N}.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = cacheRegistry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + + var synced = await endpoint.SyncDocumentAsync(node, key.Value); + + synced.Text.Should().Be("foreign-schema-synced"); + node.Cache.Get(key)!.Text.Should().Be("foreign-schema-synced"); + node.Cache.Get(key)!.Revision.Should().Be(45); + } + [Test] public async Task DocumentRegistryHelpers_CreateTypedNetworkRegistriesForAliases() { From af2bd4a82d763ee3b50e1b1da885674bbddce675 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:17:07 +0100 Subject: [PATCH 060/498] Resolve TS snapshot aliases by payload shape --- packages/cultmesh-ts/src/index.ts | 16 +++- packages/cultmesh-ts/test/cultmesh.test.ts | 88 ++++++++++++++++++++++ packages/cultnet-ts/src/replication.ts | 36 ++++++++- packages/cultnet-ts/test/cultnet.test.ts | 51 +++++++++++++ 4 files changed, 188 insertions(+), 3 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index e968d7e0..37b6b554 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -2549,6 +2549,16 @@ export function cultMeshDocumentFromPeerSnapshot( { timeoutMs: options.timeoutMs, messageIdPrefix: options.messageIdPrefix ?? documentId, + accepts: typeof schemaOrDefinition === "string" + ? undefined + : record => { + try { + schemaOrDefinition.schema.parse(decodeCultNetRawDocumentPayload(record)); + return true; + } catch { + return false; + } + }, }, ), ), @@ -5718,6 +5728,7 @@ async function requestCultNetRawSnapshotDocument( options: { timeoutMs?: number; messageIdPrefix?: string; + accepts?: (document: CultNetRawDocumentRecord) => boolean; } = {}, ): Promise { const peer = await resolveCultNetPeer(peerOrProvider); @@ -5727,7 +5738,10 @@ async function requestCultNetRawSnapshotDocument( timeoutMs: options.timeoutMs, }); const candidates = response.documents.filter(candidate => candidate.recordKey === recordKey); - const document = candidates.find(candidate => candidate.schemaId === schemaId) ?? candidates[0]; + const document = + candidates.find(candidate => candidate.schemaId === schemaId) ?? + (options.accepts ? candidates.find(options.accepts) : undefined) ?? + candidates[0]; if (!document) { throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${recordKey}.`); } diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 28484033..2924a81c 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -34,6 +34,24 @@ const noteAliasDocument = defineDocumentType({ name: "noteId", }); +const foreignNoteDocument = defineDocumentType({ + type: "runtime.generated.cultmesh.note", + schemaId: "runtime.generated.cultmesh.note.ui.42", + schema: z.object({ + noteId: z.string(), + body: z.string(), + }), + name: "noteId", +}); + +const incompatibleNoteDocument = defineDocumentType({ + type: "runtime.generated.incompatible-note", + schemaId: "runtime.generated.incompatible-note.v1", + schema: z.object({ + nope: z.string(), + }), +}); + async function waitFor(predicate: () => boolean, description: string): Promise { const startedAt = Date.now(); while (!predicate()) { @@ -2370,6 +2388,76 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async ( } }); +test("CultMesh TS peer snapshot handles choose compatible foreign schema payloads", async () => { + const connectionId = 0x10203049; + const node = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-foreign-snapshot-")), "node.ccmp"), + { + documents: [incompatibleNoteDocument, foreignNoteDocument], + }, + ); + await node.put(incompatibleNoteDocument, "note:foreign", { + nope: "not the requested shape", + }); + await node.put(foreignNoteDocument, "note:foreign", { + noteId: "note:foreign", + body: "foreign schema id, local shape", + }); + + const server = CultMesh.createRudpDocumentServer( + "cultmesh-ts-rudp-foreign-snapshot-server", + connectionId, + { + documents: new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }), + defineCultNetDocumentBinding({ definition: foreignNoteDocument }), + ]), + getCache: () => node.cache, + bindHost: "127.0.0.1", + bindPort: 0, + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + }, + ); + + let peer: CultNetPeer | undefined; + try { + await server.start(); + peer = await CultMesh.createRudpPeer( + "cultmesh-ts-rudp-foreign-snapshot-client", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + }, + ); + + const alias = CultMesh.documentFromPeerSnapshot( + peer, + noteAliasDocument, + "note:foreign", + { + documentId: "note:foreign.alias", + timeoutMs: 1_000, + }, + ); + + assert.deepEqual(await alias.latest(), { + noteId: "note:foreign", + body: "foreign schema id, local shape", + }); + } finally { + peer?.close(); + server.close(); + } +}); + test("CultMesh TS negotiates streaming frame body transports explicitly", () => { const streams = CultMesh.createStreamCatalog(); const streamUpdates: string[] = []; diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 600a21e3..9925fd1d 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -238,11 +238,14 @@ export class CultNetDocumentRegistry { cache: CultCache, message: CultNetDocumentPutRawMessage, ): Promise { - const binding = this.#requireSchemaBinding(message.document.schemaId); + const resolution = this.#resolveRawDocumentBinding(message.document); + const { binding } = resolution; return cache.putEnvelope(binding.definition, { key: message.document.recordKey, type: binding.definition.type, - schemaId: message.document.schemaId, + schemaId: resolution.preserveIncomingSchemaId + ? message.document.schemaId + : schemaIdForBinding(binding), payload: new Uint8Array(message.document.payload), storedAt: message.document.storedAt, }); @@ -292,6 +295,35 @@ export class CultNetDocumentRegistry { return binding; } + #resolveRawDocumentBinding(document: CultNetRawDocumentRecord): { + binding: CultNetDocumentBinding; + preserveIncomingSchemaId: boolean; + } { + const exact = this.getBySchemaId(document.schemaId); + if (exact) { + return { binding: exact, preserveIncomingSchemaId: true }; + } + + const payload = new Uint8Array(document.payload); + for (const binding of this.#uniqueBindings()) { + try { + decodeDocumentValue(binding.definition, payload); + return { binding, preserveIncomingSchemaId: false }; + } catch { + // Keep looking; a foreign schema id is acceptable only when the payload validates locally. + } + } + + throw new Error(`No CultNet document binding is registered for schema "${document.schemaId}".`); + } + + #uniqueBindings(): CultNetDocumentBinding[] { + return [...new Set([ + ...this.#schemaBindings.values(), + ...this.#bindings.values(), + ])]; + } + #createRawDocumentRecord(envelope: CultCacheEnvelope): CultNetRawDocumentRecord { const binding = this.#requireBinding(envelope.type); return { diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index c2a8a8d8..838dfbb2 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1215,6 +1215,57 @@ test("CultNet raw replication preserves CultCache payload bytes for bit-compatib } }); +test("CultNet raw replication applies compatible foreign-schema snapshots", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-foreign-schema-raw-")); + + try { + const documentDefinition = defineDocumentType({ + type: "cultnet.note", + schemaId: "cultnet.note.v1", + schemaName: "cultnet.note", + schemaVersion: "cultnet.note.v1", + schema: z.object({ + schema_version: z.string(), + noteId: z.string(), + body: z.string(), + }), + name: "noteId", + }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: documentDefinition }), + ]); + const originCache = CultCache.builder() + .withDocumentType(documentDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "origin.msgpack"))) + .build(); + const targetCache = CultCache.builder() + .withDocumentType(documentDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack"))) + .build(); + + await originCache.put(documentDefinition, "note:foreign-schema", { + schema_version: "cultnet.note.v1", + noteId: "note:foreign-schema", + body: "accepted by payload shape", + }); + const rawSnapshot = registry.createRawSnapshotResponse(originCache, "foreign-schema-raw"); + rawSnapshot.documents[0]!.schemaId = "runtime.generated.cultnet.note.ui.99"; + + await registry.applyRawSnapshotResponse(targetCache, rawSnapshot); + + assert.equal( + targetCache.getRequired(documentDefinition, "note:foreign-schema").body, + "accepted by payload shape", + ); + assert.equal( + targetCache.getRequiredEnvelope(documentDefinition, "note:foreign-schema").schemaId, + "cultnet.note.v1", + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("CultNet interop slot compatibility defaults missing trailing fields and rejects mismatched slots", () => { const note: InteropNote = { schemaVersion: INTEROP_SCHEMA_VERSION, From 8b58208dfff4ed3bf1d8dfaaa594655b2faa95f3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:22:25 +0100 Subject: [PATCH 061/498] Resolve database raw puts by payload schema --- src/GameCult.Networking/CultNetDatabase.cs | 32 ++++++--- .../CultNetDocumentRegistry.cs | 2 +- .../NetworkingTests.cs | 67 +++++++++++++++++++ 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 560f80ab..7e1657f5 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -593,7 +593,7 @@ namespace GameCult.Networking foreach (var document in response.Documents.Where(document => ShardMatchesRawDocument(shard, document))) { var key = new CultRecordKey(document.RecordKey); - var descriptor = _cache.Registry.GetRequiredBySchemaId(document.SchemaId); + var descriptor = _documents.ResolveDescriptorForRawDocument(document); var previous = _cache.Get(key); var applied = await _documents.ApplyRawDocumentPutMessageAsync( _cache, @@ -608,7 +608,7 @@ namespace GameCult.Networking descriptor.DocumentType, previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated, key, - document.SchemaId, + descriptor.SchemaId, shard, applied, previous); @@ -830,9 +830,9 @@ namespace GameCult.Networking } var key = new CultRecordKey(message.Document.RecordKey); - var shard = ResolveShardInternal(message.Document.SchemaId, key); - EnsurePrimary(shard, message.Document.SchemaId, key, message.ShardEpoch); - var descriptor = _cache.Registry.GetRequiredBySchemaId(message.Document.SchemaId); + var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document); + var shard = ResolveShardInternal(descriptor.SchemaId, key); + EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch); var previous = _cache.Get(key); var document = await _documents.ApplyRawDocumentPutMessageAsync(_cache, message).ConfigureAwait(false); var kind = previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated; @@ -1097,10 +1097,22 @@ namespace GameCult.Networking private bool ShardMatchesRawDocument(CultNetShardDescriptor shard, CultNetRawDocumentRecord document) { - return document != null && - !string.IsNullOrWhiteSpace(document.SchemaId) && - !string.IsNullOrWhiteSpace(document.RecordKey) && - shard.Matches(document.SchemaId, new CultRecordKey(document.RecordKey)); + if (document == null || + string.IsNullOrWhiteSpace(document.SchemaId) || + string.IsNullOrWhiteSpace(document.RecordKey)) + { + return false; + } + + try + { + var descriptor = _documents.ResolveDescriptorForRawDocument(document); + return shard.Matches(descriptor.SchemaId, new CultRecordKey(document.RecordKey)); + } + catch (InvalidOperationException) + { + return false; + } } private void RemoveTrackedDocument(Type documentType, CultRecordKey key) @@ -1249,7 +1261,7 @@ namespace GameCult.Networking } var key = new CultRecordKey(message.Document.RecordKey); - var descriptor = _cache.Registry.GetRequiredBySchemaId(message.Document.SchemaId); + var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document); var previous = _cache.Get(key); var document = await _documents.ApplyRawDocumentPutMessageAsync(_cache, message).ConfigureAwait(false); var kind = ChangeKindFromWire(entry.ChangeKind); diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 0e949d9f..2692cc52 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -395,7 +395,7 @@ namespace GameCult.Networking return applied; } - private CultDocumentDescriptor ResolveDescriptorForRawDocument(CultNetRawDocumentRecord document) + internal CultDocumentDescriptor ResolveDescriptorForRawDocument(CultNetRawDocumentRecord document) { try { diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 6564ee24..f3bfe402 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1750,6 +1750,59 @@ namespace GameCult.Networking.Tests Assert.That(changes[0].Document!.Username, Is.EqualTo("Raw")); } + [Test] + public async Task CultNetDatabase_ApplyRawPut_ResolvesForeignSchemaIdFromPayload() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var descriptor = cache.Registry.GetRequired(); + var shard = new CultNetShardDescriptor( + "network-notes", + "runtime-a", + epoch: 3, + isPrimary: true, + schemaIds: [descriptor.SchemaId], + keyPrefix: "network-note:", + primaryEndpoints: ["cultnet://runtime-a:3075"]); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = [shard] + }); + var key = new CultRecordKey("network-note:foreign-schema-put"); + var changes = new List>(); + using var subscription = database.WatchRecord(key) + .Subscribe(change => changes.Add(change)); + var message = new CultNetDocumentPutRawMessage + { + MessageId = "put-foreign-schema", + ShardId = shard.ShardId, + ShardEpoch = shard.Epoch, + Document = new CultNetRawDocumentRecord + { + SchemaId = "runtime.generated.network-note.ui.42", + RecordKey = key.Value, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.Serialize(new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "payload-routed", + Revision = 5 + }) + } + }; + + var applied = await database.ApplyPutAsync(message); + + Assert.That(applied, Is.TypeOf()); + Assert.That(cache.Get(key)!.Text, Is.EqualTo("payload-routed")); + Assert.That(changes, Has.Count.EqualTo(1)); + Assert.That(changes[0].SchemaId, Is.EqualTo(descriptor.SchemaId)); + Assert.That(changes[0].Document!.Revision, Is.EqualTo(5)); + } + [Test] public async Task CultNetDatabaseServer_Creates_Filtered_SnapshotResponse() { @@ -4126,6 +4179,20 @@ namespace GameCult.Networking.Tests public string Body { get; set; } = string.Empty; } + [CultDocument("tests.networking_note", "tests.networking_note.v1")] + [MessagePack.MessagePackObject] + public sealed class NetworkSchemaNote + { + [MessagePack.Key(0)] + public string Schema { get; set; } = string.Empty; + + [MessagePack.Key(1)] + public string Text { get; set; } = string.Empty; + + [MessagePack.Key(2)] + public int Revision { get; set; } + } + private sealed class EnvironmentVariableScope : IDisposable { private readonly (string Name, string? Value)[] _originalValues; From dcf5f730033f75c28945915a3e493311b689182e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:27:25 +0100 Subject: [PATCH 062/498] Canonicalize Python payload-resolved schema sync --- packages/cultcache-py/src/cultmesh_py/node.py | 24 ++++++-- .../cultcache-py/src/cultnet_py/__init__.py | 2 + .../src/cultnet_py/replication.py | 35 ++++++++--- packages/cultcache-py/tests/test_cultcache.py | 60 ++++++++++++++++++- 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index c67d1be5..cb5b2cf7 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -22,6 +22,7 @@ from cultnet_py import ( apply_shard_log_response as apply_cultnet_shard_log_response, document_delete, document_put_raw, + resolve_document_and_schema_id_for_raw_record, schema_document_map, ) @@ -432,15 +433,21 @@ class CultMeshDatabase: if not isinstance(document_record, dict): return None schema_id = str(document_record.get("schemaId") or "") - document = self._document_for_schema(schema_id) - if document is None: + documents_by_schema_id = schema_document_map(self.documents) + try: + document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + document_record, + ) + except KeyError: return None record_key = str(document_record["recordKey"]) previous = self.cache.get(document, record_key) envelope = CultCacheEnvelope( key=record_key, type=document.type, - schema_id=schema_id, + schema_id=resolved_schema_id, payload=bytes(document_record["payload"]), stored_at=str(document_record.get("storedAt") or ""), catalog_entry=document.catalog_entry(), @@ -931,11 +938,16 @@ class CultMeshDatabase: if not isinstance(record, dict): continue schema_id = str(record.get("schemaId")) - document = documents_by_schema_id.get(schema_id) - if document is None: + try: + document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + record, + ) + except KeyError: continue record_key = str(record.get("recordKey")) - previous[(schema_id, record_key)] = self.cache.get(document, record_key) + previous[(resolved_schema_id, record_key)] = self.cache.get(document, record_key) return previous diff --git a/packages/cultcache-py/src/cultnet_py/__init__.py b/packages/cultcache-py/src/cultnet_py/__init__.py index 5a842dbe..ec54059d 100644 --- a/packages/cultcache-py/src/cultnet_py/__init__.py +++ b/packages/cultcache-py/src/cultnet_py/__init__.py @@ -37,6 +37,7 @@ from .replication import ( apply_raw_document_record, apply_raw_snapshot, apply_shard_log_response, + resolve_document_and_schema_id_for_raw_record, schema_document_map, ) from .schema_catalog import ( @@ -168,6 +169,7 @@ __all__ = [ "apply_raw_document_record", "apply_raw_snapshot", "apply_shard_log_response", + "resolve_document_and_schema_id_for_raw_record", "compute_simulation_claim_hash", "compute_reconnect_delay_ms", "create_reconnect_policy", diff --git a/packages/cultcache-py/src/cultnet_py/replication.py b/packages/cultcache-py/src/cultnet_py/replication.py index 8b18e94f..ca3f2752 100644 --- a/packages/cultcache-py/src/cultnet_py/replication.py +++ b/packages/cultcache-py/src/cultnet_py/replication.py @@ -36,11 +36,15 @@ def apply_raw_document_record( ) -> CultNetAppliedRecord: wire = record.to_wire() if isinstance(record, CultNetRawDocumentRecord) else record schema_id = str(wire["schemaId"]) - document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, wire) - envelope = raw_record_to_envelope(document, schema_id, wire) + document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + wire, + ) + envelope = raw_record_to_envelope(document, resolved_schema_id, wire) value = cache.put_envelope(document, envelope) return CultNetAppliedRecord( - schema_id=schema_id, + schema_id=resolved_schema_id, record_key=envelope.key, change_kind="added", value=value, @@ -77,11 +81,15 @@ def apply_raw_snapshot( if not isinstance(record, dict): continue schema_id = str(record["schemaId"]) - document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, record) - envelope = raw_record_to_envelope(document, schema_id, record) + document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + record, + ) + envelope = raw_record_to_envelope(document, resolved_schema_id, record) batch = batches.setdefault(document.type, (document, [])) - batch[1].append((schema_id, envelope)) - order.append((document, schema_id, envelope)) + batch[1].append((resolved_schema_id, envelope)) + order.append((document, resolved_schema_id, envelope)) values_by_key: dict[tuple[str, str], Any] = {} for document, entries in batches.values(): @@ -154,13 +162,22 @@ def resolve_document_for_raw_record( schema_id: str, record: dict[str, Any], ) -> DocumentDefinition[Any]: + return resolve_document_and_schema_id_for_raw_record(documents_by_schema_id, schema_id, record)[0] + + +def resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id: dict[str, DocumentDefinition[Any]], + schema_id: str, + record: dict[str, Any], +) -> tuple[DocumentDefinition[Any], str]: document = documents_by_schema_id.get(schema_id) if document is not None: - return document + return document, schema_id schema_version = _infer_schema_version_from_payload(bytes(record["payload"])) schema_name = _infer_schema_name(schema_version) if schema_version is not None else None if schema_name is not None and schema_name in documents_by_schema_id: - return documents_by_schema_id[schema_name] + document = documents_by_schema_id[schema_name] + return document, document.catalog_entry().schema_id raise KeyError(schema_id) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 94dcb0a7..c45bafad 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1840,10 +1840,13 @@ class CultCacheTests(unittest.TestCase): } applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) - self.assertEqual(applied_snapshot[0].schema_id, stale_schema_id) + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(applied_snapshot[0].schema_id, local_schema_id) self.assertEqual(cache.get_required(document, "policy:1")["value"], "still synced") + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) direct_applied = apply_raw_document_record(cache, schema_document_map([document]), record) - self.assertEqual(direct_applied.schema_id, stale_schema_id) + self.assertEqual(direct_applied.schema_id, local_schema_id) + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) shard_log = { "schemaVersion": "cultnet.shard_log_response.v0", @@ -1874,8 +1877,59 @@ class CultCacheTests(unittest.TestCase): } applied_log = apply_shard_log_response(cache, [document], shard_log) - self.assertEqual(applied_log[0].schema_id, stale_schema_id) + self.assertEqual(applied_log[0].schema_id, local_schema_id) self.assertEqual(cache.get_required(document, "policy:1")["value"], "updated through log") + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + + def test_cultmesh_node_applies_foreign_schema_snapshot_as_local_update(self) -> None: + document = define_database_entry_type( + "mesh.runtime-policy", + [ + ("schema_version", 0), + ("name", 1), + ("value", 2), + ], + schema_id="mesh.runtime-policy.current", + schema_name="mesh.runtime_policy", + schema_version="mesh.runtime_policy.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + node.put(document, "policy:1", { + "schema_version": "mesh.runtime_policy.v1", + "name": "policy", + "value": "old", + }) + observed: list[CultMeshDatabaseChange] = [] + unsubscribe = node.database.watch_record(document, "policy:1", observed.append) + try: + applied = node.database.apply_snapshot_response({ + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "foreign-schema-snapshot", + "documents": [ + { + "schemaId": "sha256:foreign-mesh-runtime-policy", + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload({ + "schema_version": "mesh.runtime_policy.v1", + "name": "policy", + "value": "new", + }), + }, + ], + }) + finally: + unsubscribe() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(applied[0].schema_id, local_schema_id) + self.assertEqual(observed[0].schema_id, local_schema_id) + self.assertEqual(observed[0].change_kind, "updated") + self.assertEqual(observed[0].previous_value["value"], "old") + self.assertEqual(observed[0].value["value"], "new") + self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None: import msgpack # type: ignore From 12f819bffe58bda9026d457680fd354125f5b0fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:31:48 +0100 Subject: [PATCH 063/498] Resolve TS store document aliases by payload --- packages/cultmesh-ts/src/index.ts | 56 +++++++++++++++++++++- packages/cultmesh-ts/test/cultmesh.test.ts | 53 ++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 37b6b554..6481bcbb 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -6,6 +6,7 @@ import { SingleFileMessagePackBackingStore, type AnyCultCacheDocumentDefinition, type CacheBackingStore, + type CultCacheEnvelope, type CultCacheDocumentValue, } from "cultcache-ts"; import { @@ -2425,7 +2426,7 @@ export function cultMeshDocumentFromStore( const documentId = options.documentId ?? resolvedSchemaId; const read = async () => { const records = await store.pullAll(); - const record = records.find(candidate => candidate.schemaId === resolvedSchemaId); + const record = resolveCultMeshStoreDocumentRecord(records, schema, schemaOrDefinition); if (!record) { throw new Error(`CultMesh store document '${documentId}' did not contain schema ${resolvedSchemaId}.`); } @@ -5681,6 +5682,59 @@ function cultMeshSchemasAreCompatible( return Boolean(left.type && right.type && left.type === right.type); } +function resolveCultMeshStoreDocumentRecord( + records: readonly CultCacheEnvelope[], + schema: CultMeshDocumentSchemaDescriptor, + schemaOrDefinition: string | AnyCultCacheDocumentDefinition, +): CultCacheEnvelope | undefined { + const schemaId = schema.schemaId ?? schema.type; + return ( + records.find(candidate => schemaId !== undefined && candidate.schemaId === schemaId) ?? + records.find(candidate => + cultMeshEnvelopeMatchesSchema(candidate, schema) && + (typeof schemaOrDefinition === "string" || + cultMeshEnvelopeParsesAsDefinition(candidate, schemaOrDefinition))) ?? + (typeof schemaOrDefinition === "string" + ? undefined + : records.find(candidate => cultMeshEnvelopeParsesAsDefinition(candidate, schemaOrDefinition))) + ); +} + +function cultMeshEnvelopeMatchesSchema( + envelope: CultCacheEnvelope, + schema: CultMeshDocumentSchemaDescriptor, +): boolean { + const entry = envelope.catalogEntry; + if (!entry) { + return false; + } + if ( + schema.schemaId && + (entry.schemaId === schema.schemaId || + envelope.schemaId === schema.schemaId || + entry.compatibleSchemaIds?.includes(schema.schemaId)) + ) { + return true; + } + return Boolean( + schema.schemaName && + entry.schemaName === schema.schemaName && + (schema.schemaVersion ?? "") === (entry.schemaVersion ?? ""), + ); +} + +function cultMeshEnvelopeParsesAsDefinition( + envelope: CultCacheEnvelope, + definition: AnyCultCacheDocumentDefinition, +): boolean { + try { + definition.schema.parse(decode(envelope.payload)); + return true; + } catch { + return false; + } +} + function cultMeshSchemaLabel(schema: CultMeshDocumentSchemaDescriptor): string { return schema.schemaId ?? cultMeshSchemaNameVersionKey(schema) ?? diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 2924a81c..86f58b77 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -220,6 +220,59 @@ test("CultMesh TS document handles submit predictions through configured authori ); }); +test("CultMesh TS store document handles choose compatible foreign schema payloads", async () => { + const store = { + async pullAll() { + return [ + { + key: "note:foreign", + type: "runtime.generated.incompatible-note", + schemaId: "runtime.generated.incompatible-note.v1", + storedAt: "2026-06-27T00:00:00Z", + payload: encode({ nope: "not the requested shape" }), + catalogEntry: { + schemaId: "runtime.generated.incompatible-note.v1", + schemaName: "runtime.generated.incompatible-note", + schemaVersion: "runtime.generated.incompatible-note.v1", + contentHash: "runtime.generated.incompatible-note.v1", + canonicalSchemaJson: "{}", + compatibleSchemaIds: ["runtime.generated.incompatible-note.v1"], + }, + }, + { + key: "note:foreign", + type: "runtime.generated.cultmesh.note", + schemaId: "runtime.generated.cultmesh.note.ui.42", + storedAt: "2026-06-27T00:00:01Z", + payload: encode({ + noteId: "note:foreign", + body: "foreign schema id, local store shape", + }), + catalogEntry: { + schemaId: "runtime.generated.cultmesh.note.ui.42", + schemaName: "cultmesh.note", + schemaVersion: "cultmesh.note.v1", + contentHash: "runtime.generated.cultmesh.note.ui.42", + canonicalSchemaJson: "{}", + compatibleSchemaIds: ["runtime.generated.cultmesh.note.ui.42"], + }, + }, + ]; + }, + async push() {}, + async delete() {}, + }; + + const document = CultMesh.documentFromStore(store, noteAliasDocument, { + documentId: "note:foreign.store", + }); + + assert.deepEqual(await document.latest(), { + noteId: "note:foreign", + body: "foreign schema id, local store shape", + }); +}); + test("CultMesh TS reactive documents submit predictions from member writes", async () => { let current = { noteId: "note:reactive", From 4dfa9a6162d9e2d195c2a8d78a4369a7aff5a9f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:37:03 +0100 Subject: [PATCH 064/498] Resolve C# snapshot filters by schema alias --- src/GameCult.Networking/CultNetDatabase.cs | 33 ++++++++- .../CultNetDocumentRegistry.cs | 28 +++++-- .../NetworkingTests.cs | 73 +++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 7e1657f5..0c4351a3 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -529,7 +529,7 @@ namespace GameCult.Networking var key = GetTrackedKey(document, documentType); if (string.IsNullOrWhiteSpace(key.Value) || !shard.Matches(descriptor.SchemaId, key) || - (requestedSchemaIds != null && !requestedSchemaIds.Contains(descriptor.SchemaId)) || + (requestedSchemaIds != null && !MatchesRequestedSchema(descriptor, requestedSchemaIds)) || (requestedRecordKeys != null && !requestedRecordKeys.Contains(key.Value))) { continue; @@ -1525,6 +1525,37 @@ namespace GameCult.Networking return schemaMatches && keyMatches; } + private static bool MatchesRequestedSchema( + CultDocumentDescriptor descriptor, + ISet requestedSchemaIds) + { + if (requestedSchemaIds.Count == 0) + return true; + + if (requestedSchemaIds.Contains(descriptor.SchemaId)) + return true; + + if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(requestedSchemaIds.Contains)) + return true; + + return requestedSchemaIds + .Select(InferSchemaName) + .Where(schemaName => !string.IsNullOrWhiteSpace(schemaName)) + .Any(schemaName => string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal)); + } + + private static string? InferSchemaName(string schemaVersion) + { + var marker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal); + if (marker <= 0 || marker + 2 >= schemaVersion.Length) + return null; + + var version = schemaVersion.Substring(marker + 2); + return version.All(char.IsDigit) + ? schemaVersion.Substring(0, marker) + : null; + } + internal static CultNetShardDescriptorMessage ToMessage(CultNetShardDescriptor shard) { return new CultNetShardDescriptorMessage diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 2692cc52..1da69fae 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -280,12 +280,9 @@ namespace GameCult.Networking value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType()), payload => CultDocumentMessagePackSerialization.DeserializeUntyped(document.GetType(), payload)); - if (requestedSchemaIds != null && !requestedSchemaIds.Contains(descriptor.SchemaId)) + if (requestedSchemaIds != null && !MatchesRequestedSchema(descriptor, binding, requestedSchemaIds)) { - if (!requestedSchemaIds.Contains(binding.SchemaId)) - { - continue; - } + continue; } var handleMethod = typeof(CultCache) @@ -438,6 +435,27 @@ namespace GameCult.Networking .Select(group => group.First()); } + private static bool MatchesRequestedSchema( + CultDocumentDescriptor descriptor, + CultNetDocumentBinding binding, + ISet requestedSchemaIds) + { + if (requestedSchemaIds.Count == 0) + return true; + + if (requestedSchemaIds.Contains(descriptor.SchemaId) || + requestedSchemaIds.Contains(binding.SchemaId)) + return true; + + if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(requestedSchemaIds.Contains)) + return true; + + return requestedSchemaIds + .Select(InferSchemaName) + .Where(schemaName => !string.IsNullOrWhiteSpace(schemaName)) + .Any(schemaName => string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal)); + } + private static string? TryReadSchemaVersion(byte[] payload) { try diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index f3bfe402..1edab188 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1837,6 +1837,79 @@ namespace GameCult.Networking.Tests Assert.That(response.Documents[0].Payload, Is.EqualTo(SerializePlayerDataPayload(player))); } + [Test] + public async Task CultNetDatabaseServer_Creates_SnapshotResponse_ForCompatibleSchemaAlias() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + var key = new CultRecordKey("network-note:alias-snapshot"); + var note = new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "alias snapshot", + Revision = 7 + }; + await database.PutAsync(key, note); + + var response = databaseServer.CreateSnapshotResponse(registry.CreateSnapshotRequest( + "snapshot-alias-request", + schemaIds: ["tests.networking_note.v1"], + recordKeys: [key.Value])); + + Assert.That(response.Documents, Has.Length.EqualTo(1)); + Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value)); + Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note))); + } + + [Test] + public async Task CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var descriptor = cache.Registry.GetRequired(); + var shard = new CultNetShardDescriptor( + "network-notes", + "runtime-a", + epoch: 4, + isPrimary: true, + schemaIds: [descriptor.SchemaId], + keyPrefix: "network-note:", + primaryEndpoints: ["cultnet://runtime-a:3075"]); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = [shard] + }); + var key = new CultRecordKey("network-note:alias-shard-snapshot"); + var note = new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "alias shard snapshot", + Revision = 8 + }; + await database.PutAsync(key, note); + + var response = database.CreateShardSnapshotResponse( + shard, + "snapshot-shard-alias-request", + registry.CreateSnapshotRequest( + "snapshot-shard-alias-request", + schemaIds: ["tests.networking_note.v1"], + recordKeys: [key.Value])); + + Assert.That(response.Documents, Has.Length.EqualTo(1)); + Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value)); + Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note))); + } + [Test] public void CultNetDatabaseServer_Creates_Filtered_ShardCatalogResponse() { From cfed4ea7ef7b9ea355de50c3056b2e53f8392ad5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:39:27 +0100 Subject: [PATCH 065/498] Resolve C# shard catalog filters by schema alias --- src/GameCult.Networking/CultNetDatabase.cs | 34 ++++++++++++++++--- .../NetworkingTests.cs | 10 ++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 0c4351a3..4db79307 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -1509,15 +1509,12 @@ namespace GameCult.Networking "not_primary"); } - private static bool MatchesCatalogFilter( + private bool MatchesCatalogFilter( CultNetShardDescriptor shard, IReadOnlyList? schemaIds, IReadOnlyList? recordKeys) { - var schemaMatches = schemaIds == null || - schemaIds.Count == 0 || - shard.SchemaIds.Count == 0 || - schemaIds.Any(schemaId => shard.SchemaIds.Contains(schemaId, StringComparer.Ordinal)); + var schemaMatches = SchemaMatchesCatalogFilter(shard, schemaIds); var keyMatches = recordKeys == null || recordKeys.Count == 0 || recordKeys.Any(key => string.IsNullOrEmpty(shard.KeyPrefix) || @@ -1525,6 +1522,33 @@ namespace GameCult.Networking return schemaMatches && keyMatches; } + private bool SchemaMatchesCatalogFilter( + CultNetShardDescriptor shard, + IReadOnlyList? schemaIds) + { + if (schemaIds == null || schemaIds.Count == 0 || shard.SchemaIds.Count == 0) + return true; + + var requestedSchemaIds = schemaIds.ToHashSet(StringComparer.Ordinal); + if (shard.SchemaIds.Any(requestedSchemaIds.Contains)) + return true; + + foreach (var shardSchemaId in shard.SchemaIds) + { + try + { + if (MatchesRequestedSchema(_cache.Registry.GetRequiredBySchemaId(shardSchemaId), requestedSchemaIds)) + return true; + } + catch (InvalidOperationException) + { + // Shards can advertise schemas that are not loaded in this process. + } + } + + return false; + } + private static bool MatchesRequestedSchema( CultDocumentDescriptor descriptor, ISet requestedSchemaIds) diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 1edab188..f8b3463a 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1955,6 +1955,16 @@ namespace GameCult.Networking.Tests Assert.That(response.Shards[0].Epoch, Is.EqualTo(3)); Assert.That(response.Shards[0].PrimaryEndpoints, Is.EqualTo(["cultnet://runtime-a:3075"])); Assert.That(response.Shards[0].ReadReplicaEndpoints, Is.EqualTo(["cultnet://edge-a:3075"])); + + var aliasResponse = databaseServer.CreateShardCatalogResponse(new CultNetShardCatalogRequestMessage + { + MessageId = "catalog-players-alias", + SchemaIds = ["gamecult.player_data.v2"], + RecordKeys = ["player:one"] + }); + + Assert.That(aliasResponse.Shards, Has.Length.EqualTo(1)); + Assert.That(aliasResponse.Shards[0].ShardId, Is.EqualTo("players")); } [Test] From bff2e552c867a91269a1ed8752c304e18fe010bf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:42:32 +0100 Subject: [PATCH 066/498] Resolve Python snapshot discovery by schema alias --- packages/cultcache-py/src/cultmesh_py/node.py | 30 +++++++++++- .../cultcache-py/src/cultmesh_py/server.py | 47 ++++++++++++++++++- packages/cultcache-py/tests/test_cultcache.py | 34 ++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index cb5b2cf7..e9150d2d 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -535,6 +535,8 @@ class CultMeshDatabase: ) -> CultNetRawSnapshotResponse: requested_schema_ids = set(schema_ids or []) requested_record_keys = set(record_keys or []) + documents_by_type = {document.type: document for document in self.documents} + documents_by_schema_id = schema_document_map(self.documents) shard_record_keys = ( self._live_shard_record_keys(shard_id) if shard_id is not None and shard_id in self._shard_logs @@ -543,7 +545,8 @@ class CultMeshDatabase: documents: list[CultNetRawDocumentRecord] = [] for envelope in self.cache.snapshot_envelopes(): schema_id = envelope.schema_id or envelope.type - if requested_schema_ids and schema_id not in requested_schema_ids: + document = documents_by_type.get(envelope.type) or documents_by_schema_id.get(schema_id) + if requested_schema_ids and not self._schema_matches_request(schema_id, document, requested_schema_ids): continue if requested_record_keys and envelope.key not in requested_record_keys: continue @@ -950,6 +953,31 @@ class CultMeshDatabase: previous[(resolved_schema_id, record_key)] = self.cache.get(document, record_key) return previous + @staticmethod + def _schema_matches_request( + schema_id: str, + document: DocumentDefinition[Any] | None, + requested_schema_ids: set[str], + ) -> bool: + if not requested_schema_ids or schema_id in requested_schema_ids: + return True + if document is None: + return False + entry = document.catalog_entry() + if entry.schema_id in requested_schema_ids or entry.schema_name in requested_schema_ids: + return True + if any(schema_id in requested_schema_ids for schema_id in entry.compatible_schema_ids): + return True + return any(_infer_schema_name(schema_id) == entry.schema_name for schema_id in requested_schema_ids) + + +def _infer_schema_name(schema_id: str) -> str | None: + marker = schema_id.rfind(".v") + if marker <= 0 or marker + 2 >= len(schema_id): + return None + version = schema_id[marker + 2:] + return schema_id[:marker] if version.isdigit() else None + def create_node( cache_path: str | Path | None = None, diff --git a/packages/cultcache-py/src/cultmesh_py/server.py b/packages/cultcache-py/src/cultmesh_py/server.py index 986460ff..088c335d 100644 --- a/packages/cultcache-py/src/cultmesh_py/server.py +++ b/packages/cultcache-py/src/cultmesh_py/server.py @@ -591,12 +591,21 @@ class CultMeshLocalServer: def _shard_catalog_response(self, request: dict[str, Any]) -> dict[str, Any]: requested_schema_ids = {str(value) for value in request.get("schemaIds") or []} document_schema_ids = [document.catalog_entry().schema_id for document in self.node.documents] + documents_by_schema_id = _schema_document_map(self.node.documents) shard_ids = self.node.database.shard_ids() or ["primary"] shards = [] for shard_id in shard_ids: schema_ids = self.node.database.shard_schema_ids(shard_id) or document_schema_ids if requested_schema_ids: - schema_ids = [schema_id for schema_id in schema_ids if schema_id in requested_schema_ids] + schema_ids = [ + schema_id + for schema_id in schema_ids + if _schema_matches_request( + schema_id, + documents_by_schema_id.get(schema_id), + requested_schema_ids, + ) + ] if not schema_ids: continue shards.append({ @@ -620,3 +629,39 @@ class CultMeshLocalServer: def _now_ms() -> int: return int(time.time() * 1000) + + +def _schema_document_map(documents: list[Any]) -> dict[str, Any]: + mapped: dict[str, Any] = {} + for document in documents: + entry = document.catalog_entry() + mapped[entry.schema_id] = document + mapped[entry.schema_name] = document + for compatible_schema_id in entry.compatible_schema_ids: + mapped[compatible_schema_id] = document + return mapped + + +def _schema_matches_request( + schema_id: str, + document: Any | None, + requested_schema_ids: set[str], +) -> bool: + if not requested_schema_ids or schema_id in requested_schema_ids: + return True + if document is None: + return False + entry = document.catalog_entry() + if entry.schema_id in requested_schema_ids or entry.schema_name in requested_schema_ids: + return True + if any(compatible_schema_id in requested_schema_ids for compatible_schema_id in entry.compatible_schema_ids): + return True + return any(_infer_schema_name(requested_schema_id) == entry.schema_name for requested_schema_id in requested_schema_ids) + + +def _infer_schema_name(schema_id: str) -> str | None: + marker = schema_id.rfind(".v") + if marker <= 0 or marker + 2 >= len(schema_id): + return None + version = schema_id[marker + 2:] + return schema_id[:marker] if version.isdigit() else None diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index c45bafad..cc8c85ac 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -5233,6 +5233,40 @@ class CultCacheTests(unittest.TestCase): self.assertIsNone(subscription_delete.raw_document) self.assertIsNone(node.database.get(document, "note:2")) + def test_cultmesh_local_server_resolves_snapshot_and_shard_catalog_schema_aliases(self) -> None: + document = define_database_entry_type( + "mesh.alias_server_note", + [("body", 0)], + schema_id="sha256:mesh-alias-server-note", + schema_name="mesh.alias_server_note", + schema_version="mesh.alias_server_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-server") + node.database.register_document(document) + node.database.put_raw_message( + document, + "note:alias", + {"body": "served through alias"}, + shard_id="notes", + shard_epoch=1, + ) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + snapshot = raw_client.fetch_snapshot(schema_ids=["mesh.alias_server_note.v1"]) + typed_snapshot = raw_client.fetch_snapshot_response(schema_ids=["mesh.alias_server_note.v1"]) + shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.alias_server_note.v1"]) + finally: + server.stop() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(snapshot["documents"][0]["schemaId"], local_schema_id) + self.assertEqual(snapshot["documents"][0]["recordKey"], "note:alias") + self.assertEqual(typed_snapshot.documents[0].schema_id, local_schema_id) + self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id]) + self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") + def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None: document = define_database_entry_type( "mesh.snapshot_limit_note", From 2738f4c2db9293383c787fe86930dca9d0ae4e10 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:46:05 +0100 Subject: [PATCH 067/498] Resolve TS snapshot filters by schema alias --- packages/cultnet-ts/src/replication.ts | 37 +++++++++++++++++- packages/cultnet-ts/test/cultnet.test.ts | 50 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 9925fd1d..f53f7c26 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -159,7 +159,7 @@ export class CultNetDocumentRegistry { const binding = this.#requireBinding(envelope.type); const schemaId = schemaIdForEnvelope(envelope, binding); - if (requestedSchemaIds && !requestedSchemaIds.has(schemaId)) { + if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) { continue; } @@ -196,7 +196,7 @@ export class CultNetDocumentRegistry { const binding = this.#requireBinding(envelope.type); const schemaId = schemaIdForEnvelope(envelope, binding); - if (requestedSchemaIds && !requestedSchemaIds.has(schemaId)) { + if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) { continue; } @@ -340,6 +340,39 @@ function schemaIdForBinding(binding: CultNetDocumentBinding): string { return binding.definition.schemaId ?? binding.definition.type; } +function schemaMatchesBinding( + schemaId: string, + binding: CultNetDocumentBinding, + requestedSchemaIds: Set, +): boolean { + if (requestedSchemaIds.has(schemaId)) { + return true; + } + + const definition = binding.definition; + if (definition.schemaId && requestedSchemaIds.has(definition.schemaId)) { + return true; + } + if (definition.schemaName && requestedSchemaIds.has(definition.schemaName)) { + return true; + } + if (definition.compatibleSchemaIds?.some(candidate => requestedSchemaIds.has(candidate))) { + return true; + } + + return Array.from(requestedSchemaIds) + .some(candidate => inferSchemaName(candidate) === definition.schemaName); +} + +function inferSchemaName(schemaId: string): string | undefined { + const marker = schemaId.lastIndexOf(".v"); + if (marker <= 0 || marker + 2 >= schemaId.length) { + return undefined; + } + const version = schemaId.slice(marker + 2); + return /^\d+$/u.test(version) ? schemaId.slice(0, marker) : undefined; +} + function schemaIdForEnvelope( envelope: CultCacheEnvelope, binding: CultNetDocumentBinding, diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 838dfbb2..bd9d79f3 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1266,6 +1266,56 @@ test("CultNet raw replication applies compatible foreign-schema snapshots", asyn } }); +test("CultNet document registry filters snapshots by schema aliases", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-snapshot-")); + + try { + const documentDefinition = defineDocumentType({ + type: "cultnet.alias_note", + schemaId: "sha256:cultnet-alias-note", + schemaName: "cultnet.alias_note", + schemaVersion: "cultnet.alias_note.v1", + schema: z.object({ + schema_version: z.string(), + noteId: z.string(), + body: z.string(), + }), + name: "noteId", + }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: documentDefinition }), + ]); + const cache = CultCache.builder() + .withDocumentType(documentDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "alias.msgpack"))) + .build(); + + await cache.put(documentDefinition, "note:alias", { + schema_version: "cultnet.alias_note.v1", + noteId: "note:alias", + body: "filtered by alias", + }); + + const typedSnapshot = registry.createSnapshotResponse(cache, "typed-alias", { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "typed-alias", + schemaIds: ["cultnet.alias_note.v1"], + }); + const rawSnapshot = registry.createRawSnapshotResponse(cache, "raw-alias", { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "raw-alias", + schemaIds: ["cultnet.alias_note.v1"], + }); + + assert.equal(typedSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note"); + assert.equal(typedSnapshot.documents[0]?.recordKey, "note:alias"); + assert.equal(rawSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note"); + assert.equal(rawSnapshot.documents[0]?.recordKey, "note:alias"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("CultNet interop slot compatibility defaults missing trailing fields and rejects mismatched slots", () => { const note: InteropNote = { schemaVersion: INTEROP_SCHEMA_VERSION, From c0eacb885b56950481f98fbe04237eb9f9932289 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:50:00 +0100 Subject: [PATCH 068/498] Resolve Python subscription filters by schema alias --- .../cultcache-py/src/cultmesh_py/server.py | 47 +++++++++++++++++-- packages/cultcache-py/tests/test_cultcache.py | 45 ++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/packages/cultcache-py/src/cultmesh_py/server.py b/packages/cultcache-py/src/cultmesh_py/server.py index 088c335d..ca134c8d 100644 --- a/packages/cultcache-py/src/cultmesh_py/server.py +++ b/packages/cultcache-py/src/cultmesh_py/server.py @@ -354,7 +354,13 @@ class CultMeshLocalServer: message_id=str(message.get("messageId") or ""), code="unregistered_document_put", )] - return self._database_change_notifications(message, document_record, change.change_kind, subscriptions) + return self._database_change_notifications( + message, + document_record, + change.change_kind, + subscriptions, + resolved_schema_id=change.schema_id, + ) def _handle_raw_delete( self, @@ -384,10 +390,16 @@ class CultMeshLocalServer: document: dict[str, Any], change_kind: str, subscriptions: dict[str, _DatabaseSubscription], + *, + resolved_schema_id: str | None = None, ) -> list[dict[str, Any]]: notifications = [] for subscription in subscriptions.values(): - if not subscription.matches(document): + if not self._subscription_matches_document( + subscription, + document, + resolved_schema_id=resolved_schema_id, + ): continue message_id = message.get("messageId") or document.get("recordKey") or "change" notifications.append({ @@ -409,7 +421,7 @@ class CultMeshLocalServer: notifications = [] document = {"schemaId": schema_id, "recordKey": record_key} for subscription in subscriptions.values(): - if not subscription.matches(document): + if not self._subscription_matches_document(subscription, document): continue message_id = message.get("messageId") or record_key or "change" notifications.append({ @@ -422,6 +434,35 @@ class CultMeshLocalServer: }) return notifications + def _subscription_matches_document( + self, + subscription: _DatabaseSubscription, + document: dict[str, Any], + *, + resolved_schema_id: str | None = None, + ) -> bool: + record_key = str(document.get("recordKey") or "") + if subscription.record_keys and record_key not in subscription.record_keys: + return False + if not subscription.schema_ids: + return True + + document_schema_id = str(document.get("schemaId") or "") + documents_by_schema_id = _schema_document_map(self.node.documents) + if _schema_matches_request( + document_schema_id, + documents_by_schema_id.get(document_schema_id), + subscription.schema_ids, + ): + return True + if resolved_schema_id is None: + return False + return _schema_matches_request( + resolved_schema_id, + documents_by_schema_id.get(resolved_schema_id), + subscription.schema_ids, + ) + def _hello_response(self) -> dict[str, Any]: return { "schemaVersion": "cultnet.hello.v0", diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index cc8c85ac..6ed8d3bc 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -5267,6 +5267,51 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id]) self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") + def test_cultmesh_local_server_notifies_schema_alias_subscription_for_raw_put(self) -> None: + document = define_database_entry_type( + "mesh.alias_sub_note", + [ + ("schema_version", 0), + ("body", 1), + ], + schema_id="sha256:mesh-alias-sub-note", + schema_name="mesh.alias_sub_note", + schema_version="mesh.alias_sub_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-sub-server") + node.database.register_document(document) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with raw_client.subscribe_database( + subscription_id="sub-alias", + schema_ids=["mesh.alias_sub_note.v1"], + include_snapshot=False, + ) as subscription: + subscription.send(document_put_raw( + message_id="put-foreign-alias", + key="note:alias-sub", + schema_id="runtime.generated.mesh.alias_sub_note.ui.99", + stored_at="2026-06-27T00:00:00Z", + payload=document.encode_payload({ + "schema_version": "mesh.alias_sub_note.v1", + "body": "notified", + }), + )) + change = subscription.read_next_change() + finally: + server.stop() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(change.subscription_id, "sub-alias") + self.assertEqual(change.change_kind, "added") + self.assertEqual(change.record_key, "note:alias-sub") + self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(change.raw_document.schema_id, "runtime.generated.mesh.alias_sub_note.ui.99") + self.assertEqual(node.database.get_required(document, "note:alias-sub")["body"], "notified") + self.assertEqual(node.cache.get_required_envelope(document, "note:alias-sub").schema_id, local_schema_id) + def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None: document = define_database_entry_type( "mesh.snapshot_limit_note", From 1b9140da3d489369957efce83a7854b2fbc88907 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:53:28 +0100 Subject: [PATCH 069/498] Resolve C# subscription filters by schema alias --- .../CultNetDatabaseServer.cs | 71 +++++++++++++++++-- .../NetworkingTests.cs | 51 +++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index df8dbbbf..a136e1fc 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -344,7 +344,10 @@ namespace GameCult.Networking var changeType = change.GetType(); var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(string.Empty)); var schemaId = (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? string.Empty; - if (!Matches(request, schemaId, key)) + var documentType = changeType.IsGenericType ? changeType.GetGenericArguments()[0] : null; + var descriptor = documentType == null ? null : _database.Cache.Registry.GetRequired(documentType); + var binding = documentType == null ? null : _database.Documents.GetByDocumentType(documentType); + if (!Matches(request, schemaId, key, descriptor, binding)) { return null; } @@ -387,17 +390,75 @@ namespace GameCult.Networking return ((CultNetDocumentPutRawMessage)message!).Document; } - private static bool Matches(CultNetDatabaseSubscribeMessage request, string schemaId, CultRecordKey key) + private static bool Matches( + CultNetDatabaseSubscribeMessage request, + string schemaId, + CultRecordKey key, + CultDocumentDescriptor? descriptor, + CultNetDocumentBinding? binding) { - var schemaMatches = request.SchemaIds == null || - request.SchemaIds.Length == 0 || - request.SchemaIds.Contains(schemaId, StringComparer.Ordinal); + var schemaMatches = MatchesSchema(request.SchemaIds, schemaId, descriptor, binding); var keyMatches = request.RecordKeys == null || request.RecordKeys.Length == 0 || request.RecordKeys.Contains(key.Value, StringComparer.Ordinal); return schemaMatches && keyMatches; } + private static bool MatchesSchema( + string[]? requestedSchemaIds, + string schemaId, + CultDocumentDescriptor? descriptor, + CultNetDocumentBinding? binding) + { + if (requestedSchemaIds == null || requestedSchemaIds.Length == 0) + { + return true; + } + + var requested = requestedSchemaIds.ToHashSet(StringComparer.Ordinal); + if (requested.Contains(schemaId)) + { + return true; + } + + if (descriptor == null) + { + return false; + } + + if (requested.Contains(descriptor.SchemaId) || + requested.Contains(descriptor.SchemaName) || + requested.Contains(descriptor.SchemaVersion) || + (binding != null && requested.Contains(binding.SchemaId))) + { + return true; + } + + if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(requested.Contains)) + { + return true; + } + + return requested + .Select(InferSchemaName) + .Where(schemaName => !string.IsNullOrWhiteSpace(schemaName)) + .Any(schemaName => string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal)); + } + + private static string? InferSchemaName(string schemaVersion) + { + var marker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal); + if (marker <= 0 || marker + 2 >= schemaVersion.Length) + { + return null; + } + + var version = schemaVersion.Substring(marker + 2); + return version.All(char.IsDigit) + ? schemaVersion.Substring(0, marker) + : null; + } + private static string SubscriptionKey(NetPeer peer, string subscriptionId) { return $"{peer.Id}:{subscriptionId}"; diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index f8b3463a..dfd5555d 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -4039,6 +4039,57 @@ namespace GameCult.Networking.Tests Assert.That(message.Document!.Payload, Is.EqualTo(SerializePlayerDataPayload(player))); } + [Test] + public void CultNetDatabaseServer_Creates_Filtered_SubscriptionChange_ForSchemaAlias() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + var key = new CultRecordKey("network-note:alias-change"); + var note = new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "alias change", + Revision = 11 + }; + var descriptor = cache.Registry.GetRequired(); + var change = new CultNetDatabaseChange( + CultNetDatabaseChangeKind.Added, + key, + descriptor.SchemaId, + database.Shards[0], + note, + previousDocument: null); + var method = typeof(CultNetDatabaseServer).GetMethod( + "CreateChangeMessage", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + var message = (CultNetDatabaseChangeRawMessage?)method.Invoke(databaseServer, new object[] + { + change, + "sub-alias", + new CultNetDatabaseSubscribeMessage + { + SubscriptionId = "sub-alias", + SchemaIds = ["tests.networking_note.v1"], + RecordKeys = [key.Value] + } + }); + + Assert.That(message, Is.Not.Null); + Assert.That(message!.SubscriptionId, Is.EqualTo("sub-alias")); + Assert.That(message.ChangeKind, Is.EqualTo("added")); + Assert.That(message.Document, Is.Not.Null); + Assert.That(message.Document!.SchemaId, Is.EqualTo(descriptor.SchemaId)); + Assert.That(message.Document.Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note))); + } + [Test] public void CultNetSchemaRegistry_BuiltInCatalog_AdvertisesRawLane_AndSharedGhostlightContract() { From aeb7a96bd3be822f0af77a7d5d77410ee24e0387 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:56:02 +0100 Subject: [PATCH 070/498] Resolve TS typed document messages by schema alias --- packages/cultnet-ts/src/replication.ts | 13 +++++- packages/cultnet-ts/test/cultnet.test.ts | 57 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index f53f7c26..d5706bea 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -57,7 +57,15 @@ export class CultNetDocumentRegistry { } getBySchemaId(schemaId: string): CultNetDocumentBinding | undefined { - return this.#schemaBindings.get(schemaId); + const exact = this.#schemaBindings.get(schemaId); + if (exact) { + return exact; + } + + const requestedSchemaIds = new Set([schemaId]); + return this.#uniqueBindings().find(binding => + schemaMatchesBinding(schemaIdForBinding(binding), binding, requestedSchemaIds), + ); } createDocumentPutMessage( @@ -356,6 +364,9 @@ function schemaMatchesBinding( if (definition.schemaName && requestedSchemaIds.has(definition.schemaName)) { return true; } + if (definition.schemaVersion && requestedSchemaIds.has(definition.schemaVersion)) { + return true; + } if (definition.compatibleSchemaIds?.some(candidate => requestedSchemaIds.has(candidate))) { return true; } diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index bd9d79f3..85cafcf6 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1316,6 +1316,63 @@ test("CultNet document registry filters snapshots by schema aliases", async () = } }); +test("CultNet document registry applies typed put and delete messages by schema alias", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-typed-")); + + try { + const documentDefinition = defineDocumentType({ + type: "cultnet.typed_alias_note", + schemaId: "sha256:cultnet-typed-alias-note", + schemaName: "cultnet.typed_alias_note", + schemaVersion: "cultnet.typed_alias_note.v1", + schema: z.object({ + schema_version: z.string(), + noteId: z.string(), + body: z.string(), + }), + name: "noteId", + }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: documentDefinition }), + ]); + const cache = CultCache.builder() + .withDocumentType(documentDefinition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "typed-alias.msgpack"))) + .build(); + + await registry.applyDocumentPutMessage(cache, { + schemaVersion: "cultnet.document_put.v0", + messageId: "typed-alias-put", + document: { + schemaId: "cultnet.typed_alias_note.v1", + recordKey: "note:typed-alias", + storedAt: "2026-06-27T00:00:00.000Z", + payload: { + schema_version: "cultnet.typed_alias_note.v1", + noteId: "note:typed-alias", + body: "typed alias applied", + }, + }, + }); + + assert.equal( + cache.getRequired(documentDefinition, "note:typed-alias").body, + "typed alias applied", + ); + assert.equal(registry.getBySchemaId("cultnet.typed_alias_note.v1")?.definition.type, documentDefinition.type); + + assert.equal(await registry.applyDocumentDeleteMessage(cache, { + schemaVersion: "cultnet.document_delete.v0", + messageId: "typed-alias-delete", + schemaId: "cultnet.typed_alias_note.v1", + recordKey: "note:typed-alias", + }), true); + assert.equal(cache.get(documentDefinition, "note:typed-alias"), undefined); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("CultNet interop slot compatibility defaults missing trailing fields and rejects mismatched slots", () => { const note: InteropNote = { schemaVersion: INTEROP_SCHEMA_VERSION, From 94a8c5b10482306a72b0ca536d687683456acdcc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 21:57:54 +0100 Subject: [PATCH 071/498] Resolve TS shard discovery filters by schema alias --- packages/cultnet-ts/src/shard-catalog.ts | 40 ++++++++++++++++++++++-- packages/cultnet-ts/test/cultnet.test.ts | 6 ++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/cultnet-ts/src/shard-catalog.ts b/packages/cultnet-ts/src/shard-catalog.ts index 38a5d1c3..f57e0bee 100644 --- a/packages/cultnet-ts/src/shard-catalog.ts +++ b/packages/cultnet-ts/src/shard-catalog.ts @@ -47,11 +47,11 @@ export class CultNetShardCatalog { } list(options: CultNetShardCatalogOptions = {}): CultNetShardDescriptor[] { - const requestedSchemaIds = options.schemaIds ? new Set(options.schemaIds) : null; + const requestedSchemaIds = options.schemaIds ?? []; const requestedRecordKeys = options.recordKeys ?? []; return this.shards.filter((shard) => { - if (requestedSchemaIds && !shard.schemaIds?.some((schemaId) => requestedSchemaIds.has(schemaId))) { + if (requestedSchemaIds.length > 0 && !schemaIdsMatchAny(shard.schemaIds ?? [], requestedSchemaIds)) { return false; } if (requestedRecordKeys.length > 0 && !requestedRecordKeys.some((recordKey) => shardServes(shard, { recordKey }))) { @@ -86,7 +86,12 @@ export function shardServes( shard: CultNetShardDescriptor, options: { schemaId?: string; recordKey?: string } = {}, ): boolean { - if (options.schemaId !== undefined && shard.schemaIds !== undefined && shard.schemaIds.length > 0 && !shard.schemaIds.includes(options.schemaId)) { + if ( + options.schemaId !== undefined && + shard.schemaIds !== undefined && + shard.schemaIds.length > 0 && + !schemaIdsMatchAny(shard.schemaIds, [options.schemaId]) + ) { return false; } if (options.recordKey !== undefined && shard.keyPrefix !== undefined && !options.recordKey.startsWith(shard.keyPrefix)) { @@ -95,6 +100,35 @@ export function shardServes( return true; } +function schemaIdsMatchAny( + advertisedSchemaIds: readonly string[], + requestedSchemaIds: readonly string[], +): boolean { + return advertisedSchemaIds.some(advertised => + requestedSchemaIds.some(requested => schemaIdsMatch(advertised, requested)), + ); +} + +function schemaIdsMatch(left: string, right: string): boolean { + if (left === right) { + return true; + } + + const leftName = inferSchemaName(left) ?? left; + const rightName = inferSchemaName(right) ?? right; + return leftName === rightName; +} + +function inferSchemaName(schemaId: string): string | undefined { + const marker = schemaId.lastIndexOf(".v"); + if (marker <= 0 || marker + 2 >= schemaId.length) { + return undefined; + } + + const version = schemaId.slice(marker + 2); + return /^\d+$/u.test(version) ? schemaId.slice(0, marker) : undefined; +} + function cloneShardDescriptor(descriptor: CultNetShardDescriptor): CultNetShardDescriptor { return { shardId: descriptor.shardId, diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 85cafcf6..1d78072c 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -936,13 +936,15 @@ test("CultNet shard catalog filters descriptors and answers catalog requests", ( assert.equal(watchedShardId, "profiles-a"); assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "note.v1", recordKey: "note:1" }), true); + assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "note", recordKey: "note:1" }), true); assert.equal(shardServes(catalog.get("notes-a")!, { schemaId: "profile.v1", recordKey: "note:1" }), false); assert.deepEqual(catalog.list({ schemaIds: ["note.v1"], recordKeys: ["note:1"] }).map((shard) => shard.shardId), ["notes-a"]); + assert.deepEqual(catalog.list({ schemaIds: ["note"], recordKeys: ["note:1"] }).map((shard) => shard.shardId), ["notes-a"]); const response = catalog.createCatalogResponse({ schemaVersion: "cultnet.shard_catalog_request.v0", messageId: "shards", - schemaIds: ["note.v1"], + schemaIds: ["note"], recordKeys: ["note:2"], }); assert.equal(response.schemaVersion, "cultnet.shard_catalog_response.v0"); @@ -987,7 +989,7 @@ test("CultNet peer can request and sync shard catalogs by message id", async () const synced = new CultNetShardCatalog(); const applied = await requester.syncShardCatalog(synced, { messageId: "peer-shards", - schemaIds: ["note.v1"], + schemaIds: ["note"], recordKeys: ["note:local"], timeoutMs: 1_000, }); From 54543b5db00f3e05aac0a7f85d5d7544fc57c634 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:00:13 +0100 Subject: [PATCH 072/498] Resolve Python shard discovery filters by schema alias --- .../src/cultnet_py/shard_catalog.py | 29 +++++++++++++++++-- packages/cultcache-py/tests/test_cultcache.py | 4 ++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/cultcache-py/src/cultnet_py/shard_catalog.py b/packages/cultcache-py/src/cultnet_py/shard_catalog.py index 951ad5b7..4335f617 100644 --- a/packages/cultcache-py/src/cultnet_py/shard_catalog.py +++ b/packages/cultcache-py/src/cultnet_py/shard_catalog.py @@ -60,7 +60,7 @@ class CultNetShardDescriptor: return wire def serves(self, *, schema_id: str | None = None, record_key: str | None = None) -> bool: - if schema_id is not None and self.schema_ids and schema_id not in self.schema_ids: + if schema_id is not None and self.schema_ids and not _schema_ids_match_any(self.schema_ids, (schema_id,)): return False if record_key is not None and self.key_prefix and not record_key.startswith(self.key_prefix): return False @@ -122,13 +122,38 @@ def _shard_matches( requested_schema_ids: set[str], requested_record_keys: list[str], ) -> bool: - if requested_schema_ids and not any(schema_id in shard.schema_ids for schema_id in requested_schema_ids): + if requested_schema_ids and not _schema_ids_match_any(shard.schema_ids, requested_schema_ids): return False if requested_record_keys and not any(shard.serves(record_key=record_key) for record_key in requested_record_keys): return False return True +def _schema_ids_match_any( + advertised_schema_ids: tuple[str, ...], + requested_schema_ids: set[str] | tuple[str, ...], +) -> bool: + return any( + _schema_ids_match(advertised, requested) + for advertised in advertised_schema_ids + for requested in requested_schema_ids + ) + + +def _schema_ids_match(left: str, right: str) -> bool: + if left == right: + return True + return (_infer_schema_name(left) or left) == (_infer_schema_name(right) or right) + + +def _infer_schema_name(schema_id: str) -> str | None: + marker = schema_id.rfind(".v") + if marker <= 0 or marker + 2 >= len(schema_id): + return None + version = schema_id[marker + 2:] + return schema_id[:marker] if version.isdigit() else None + + def _string_tuple(value: Any) -> tuple[str, ...]: return tuple(str(item) for item in value or []) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 6ed8d3bc..9138268a 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2339,7 +2339,7 @@ class CultCacheTests(unittest.TestCase): owner_runtime_id="python-runtime", epoch=2, is_primary=True, - schema_ids=("schema-note",), + schema_ids=("schema-note.v1",), key_prefix="note:", primary_endpoints=("cultnet://127.0.0.1:3075",), read_replica_endpoints=("cultnet://127.0.0.1:3075",), @@ -2354,8 +2354,10 @@ class CultCacheTests(unittest.TestCase): "primaryEndpoints": ["cultnet://127.0.0.1:3076"], }) + self.assertTrue(primary.serves(schema_id="schema-note.v1", record_key="note:1")) self.assertTrue(primary.serves(schema_id="schema-note", record_key="note:1")) self.assertFalse(primary.serves(schema_id="schema-note", record_key="fact:1")) + self.assertEqual(catalog.list(schema_ids=["schema-note.v1"]), [primary]) self.assertEqual(catalog.list(schema_ids=["schema-note"]), [primary]) self.assertEqual(catalog.list(record_keys=["note:1"]), [primary]) From 7661cf7af39eb7765f1dce59b80c76e0ac5b270a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:04:16 +0100 Subject: [PATCH 073/498] Resolve C# shard routing by schema alias --- src/GameCult.Networking/CultNetDatabase.cs | 106 ++++++++++++++++-- .../NetworkingTests.cs | 88 +++++++++++++++ 2 files changed, 184 insertions(+), 10 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 4db79307..5fcddecf 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -43,6 +43,58 @@ namespace GameCult.Networking Reconciled } + internal static class CultNetSchemaAliasMatching + { + public static bool MatchesAny(IReadOnlyList candidates, string schemaId) + { + return candidates.Count == 0 || + candidates.Any(candidate => Matches(candidate, schemaId)); + } + + public static bool MatchesAny(IReadOnlyList candidates, CultDocumentDescriptor descriptor) + { + return candidates.Count == 0 || + candidates.Any(candidate => Matches(candidate, descriptor)); + } + + public static bool Matches(string candidate, string schemaId) + { + if (string.Equals(candidate, schemaId, StringComparison.Ordinal)) + return true; + + var candidateName = InferSchemaName(candidate) ?? candidate; + var schemaName = InferSchemaName(schemaId) ?? schemaId; + return string.Equals(candidateName, schemaName, StringComparison.Ordinal); + } + + public static bool Matches(string candidate, CultDocumentDescriptor descriptor) + { + if (string.Equals(candidate, descriptor.SchemaId, StringComparison.Ordinal) || + string.Equals(candidate, descriptor.SchemaName, StringComparison.Ordinal) || + string.Equals(candidate, descriptor.SchemaVersion, StringComparison.Ordinal)) + return true; + + if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(compatible => + string.Equals(candidate, compatible, StringComparison.Ordinal))) + return true; + + var candidateName = InferSchemaName(candidate) ?? candidate; + return string.Equals(candidateName, descriptor.SchemaName, StringComparison.Ordinal); + } + + private static string? InferSchemaName(string schemaId) + { + var marker = schemaId.LastIndexOf(".v", StringComparison.Ordinal); + if (marker <= 0 || marker + 2 >= schemaId.Length) + return null; + + var version = schemaId.Substring(marker + 2); + return version.All(char.IsDigit) + ? schemaId.Substring(0, marker) + : null; + } + } + /// /// Declares which documents this runtime may predict locally before authoritative commit. /// @@ -80,7 +132,14 @@ namespace GameCult.Networking internal bool Matches(string runtimeId, string schemaId, CultRecordKey key) { return string.Equals(OwnerRuntimeId, runtimeId, StringComparison.Ordinal) && - (SchemaIds.Count == 0 || SchemaIds.Contains(schemaId, StringComparer.Ordinal)) && + CultNetSchemaAliasMatching.MatchesAny(SchemaIds, schemaId) && + (string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal)); + } + + internal bool Matches(string runtimeId, CultDocumentDescriptor descriptor, CultRecordKey key) + { + return string.Equals(OwnerRuntimeId, runtimeId, StringComparison.Ordinal) && + CultNetSchemaAliasMatching.MatchesAny(SchemaIds, descriptor) && (string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal)); } } @@ -182,7 +241,15 @@ namespace GameCult.Networking internal bool Matches(string schemaId, CultRecordKey key) { - var schemaMatches = SchemaIds.Count == 0 || SchemaIds.Contains(schemaId, StringComparer.Ordinal); + var schemaMatches = CultNetSchemaAliasMatching.MatchesAny(SchemaIds, schemaId); + var keyMatches = string.IsNullOrEmpty(KeyPrefix) || + key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal); + return schemaMatches && keyMatches; + } + + internal bool Matches(CultDocumentDescriptor descriptor, CultRecordKey key) + { + var schemaMatches = CultNetSchemaAliasMatching.MatchesAny(SchemaIds, descriptor); var keyMatches = string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal); return schemaMatches && keyMatches; @@ -737,7 +804,7 @@ namespace GameCult.Networking if (document == null) throw new ArgumentNullException(nameof(document)); var descriptor = _cache.Registry.GetRequired(); - var shard = ResolveShardInternal(descriptor.SchemaId, key); + var shard = ResolveShardInternal(descriptor, key); EnsurePrimary(shard, descriptor.SchemaId, key); var previous = _cache.Get(key); @@ -768,8 +835,8 @@ namespace GameCult.Networking if (document == null) throw new ArgumentNullException(nameof(document)); var descriptor = _cache.Registry.GetRequired(); - var shard = ResolveShardInternal(descriptor.SchemaId, key); - EnsureClientAuthority(descriptor.SchemaId, key); + var shard = ResolveShardInternal(descriptor, key); + EnsureClientAuthority(descriptor, key); var previous = _cache.Get(key); var handle = await _cache.UpsertAsync(document, new CultRecordHandle(key)).ConfigureAwait(false); @@ -791,7 +858,7 @@ namespace GameCult.Networking { ThrowIfDisposed(); var descriptor = _cache.Registry.GetRequired(); - var shard = ResolveShardInternal(descriptor.SchemaId, key); + var shard = ResolveShardInternal(descriptor, key); EnsurePrimary(shard, descriptor.SchemaId, key); var previous = _cache.Get(key); @@ -831,7 +898,7 @@ namespace GameCult.Networking var key = new CultRecordKey(message.Document.RecordKey); var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document); - var shard = ResolveShardInternal(descriptor.SchemaId, key); + var shard = ResolveShardInternal(descriptor, key); EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch); var previous = _cache.Get(key); var document = await _documents.ApplyRawDocumentPutMessageAsync(_cache, message).ConfigureAwait(false); @@ -1046,7 +1113,7 @@ namespace GameCult.Networking var key = current != null ? GetTrackedKey(current, documentType) : new CultRecordKey(string.Empty); - var shard = ResolveShardInternal(descriptor.SchemaId, key); + var shard = ResolveShardInternal(descriptor, key); var changeType = typeof(CultNetDatabaseChange<>).MakeGenericType(documentType); var change = Activator.CreateInstance( changeType, @@ -1088,7 +1155,7 @@ namespace GameCult.Networking var documentType = document.GetType(); var descriptor = _cache.Registry.GetRequired(documentType); var key = GetTrackedKey(document, documentType); - if (!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key)) + if (!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor, key)) { yield return new TrackedShardDocument(documentType, descriptor.SchemaId, key, document); } @@ -1107,7 +1174,7 @@ namespace GameCult.Networking try { var descriptor = _documents.ResolveDescriptorForRawDocument(document); - return shard.Matches(descriptor.SchemaId, new CultRecordKey(document.RecordKey)); + return shard.Matches(descriptor, new CultRecordKey(document.RecordKey)); } catch (InvalidOperationException) { @@ -1453,6 +1520,20 @@ namespace GameCult.Networking "not_client_authority"); } + private void EnsureClientAuthority(CultDocumentDescriptor descriptor, CultRecordKey key) + { + if (_clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, descriptor, key))) + { + return; + } + + var shard = ResolveShardInternal(descriptor, key); + throw new CultNetShardAuthorityException( + shard, + $"Runtime '{_runtimeId}' does not have client prediction authority for schema '{descriptor.SchemaId}' key '{key.Value}'.", + "not_client_authority"); + } + private static string PredictionKey(string schemaId, CultRecordKey key) { return $"{schemaId}:{key.Value}"; @@ -1488,6 +1569,11 @@ namespace GameCult.Networking return _shards.FirstOrDefault(shard => shard.Matches(schemaId, key)) ?? _shards[0]; } + private CultNetShardDescriptor ResolveShardInternal(CultDocumentDescriptor descriptor, CultRecordKey key) + { + return _shards.FirstOrDefault(shard => shard.Matches(descriptor, key)) ?? _shards[0]; + } + private static void EnsurePrimary(CultNetShardDescriptor shard, string schemaId, CultRecordKey key, long? expectedEpoch = null) { if (expectedEpoch.HasValue && expectedEpoch.Value != shard.Epoch) diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index dfd5555d..a7f0f3b5 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1910,6 +1910,94 @@ namespace GameCult.Networking.Tests Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note))); } + [Test] + public async Task CultNetDatabase_Routes_TypedWrite_ToShardSchemaAlias() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = + [ + new CultNetShardDescriptor( + "wrong-network-notes", + "runtime-b", + epoch: 1, + isPrimary: false, + schemaIds: ["other.note.v1"], + keyPrefix: "network-note:"), + new CultNetShardDescriptor( + "network-notes", + "runtime-a", + epoch: 2, + isPrimary: true, + schemaIds: ["tests.networking_note.v1"], + keyPrefix: "network-note:") + ] + }); + var key = new CultRecordKey("network-note:alias-routed-write"); + + await database.PutAsync(key, new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "typed write routed by alias", + Revision = 12 + }); + + Assert.That(cache.Get(key)!.Text, Is.EqualTo("typed write routed by alias")); + Assert.That(database.GetMutationLog("network-notes"), Has.Count.EqualTo(1)); + Assert.That(database.GetMutationLog("wrong-network-notes"), Is.Empty); + } + + [Test] + public async Task CultNetDatabase_Predicts_ClientOwnedInput_ThroughSchemaAliasScope() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + RuntimeId = "pilot-a", + DocumentRegistry = registry, + Shards = + [ + new CultNetShardDescriptor( + "wrong-network-inputs", + "server", + epoch: 1, + isPrimary: false, + schemaIds: ["other.note.v1"], + keyPrefix: "network-input:"), + new CultNetShardDescriptor( + "network-inputs", + "server", + epoch: 1, + isPrimary: false, + schemaIds: ["tests.networking_note.v1"], + keyPrefix: "network-input:") + ], + ClientAuthorityScopes = + [ + new CultNetClientAuthorityScope( + "pilot-a", + schemaIds: ["tests.networking_note.v1"], + keyPrefix: "network-input:pilot-a") + ] + }); + var key = new CultRecordKey("network-input:pilot-a:alias-prediction"); + + await database.PutPredictedAsync(key, new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "predicted through alias scope", + Revision = 1 + }); + + Assert.That(cache.Get(key)!.Text, Is.EqualTo("predicted through alias scope")); + } + [Test] public void CultNetDatabaseServer_Creates_Filtered_ShardCatalogResponse() { From 19e2b68fec5056acad00c8eede971e90e7cecd91 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:07:31 +0100 Subject: [PATCH 074/498] Resolve C# raw deletes by schema alias --- src/GameCult.Networking/CultNetDatabase.cs | 6 +-- .../CultNetDocumentRegistry.cs | 36 +++++++++++++- .../NetworkingTests.cs | 49 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 5fcddecf..6e1ed41f 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -941,9 +941,9 @@ namespace GameCult.Networking if (message == null) throw new ArgumentNullException(nameof(message)); var key = new CultRecordKey(message.RecordKey); - var shard = ResolveShardInternal(message.SchemaId, key); - EnsurePrimary(shard, message.SchemaId, key, message.ShardEpoch); - var descriptor = _cache.Registry.GetRequiredBySchemaId(message.SchemaId); + var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId); + var shard = ResolveShardInternal(descriptor, key); + EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch); var previous = _cache.Get(key); if (previous == null) { diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 1da69fae..e2633768 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -396,7 +396,7 @@ namespace GameCult.Networking { try { - return _documents.GetRequiredBySchemaId(document.SchemaId); + return ResolveDescriptorForSchemaId(document.SchemaId); } catch (InvalidOperationException) when (TryResolveDescriptorByPayloadSchema(document.Payload) is { } descriptor) { @@ -404,6 +404,40 @@ namespace GameCult.Networking } } + internal CultDocumentDescriptor ResolveDescriptorForSchemaId(string schemaId) + { + try + { + return _documents.GetRequiredBySchemaId(schemaId); + } + catch (InvalidOperationException) when (TryResolveDescriptorBySchemaAlias(schemaId) is { } descriptor) + { + return descriptor; + } + } + + private CultDocumentDescriptor? TryResolveDescriptorBySchemaAlias(string schemaId) + { + if (string.IsNullOrWhiteSpace(schemaId)) + return null; + + var candidates = ResolvePayloadSchemaCandidates().ToArray(); + var descriptor = candidates.FirstOrDefault(candidate => + string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) || + string.Equals(candidate.SchemaName, schemaId, StringComparison.Ordinal) || + string.Equals(candidate.SchemaVersion, schemaId, StringComparison.Ordinal) || + candidate.ToCatalogEntry().CompatibleSchemaIds.Any(compatible => + string.Equals(compatible, schemaId, StringComparison.Ordinal))); + if (descriptor != null) + return descriptor; + + var schemaName = InferSchemaName(schemaId); + return string.IsNullOrWhiteSpace(schemaName) + ? null + : candidates.FirstOrDefault(candidate => + string.Equals(candidate.SchemaName, schemaName, StringComparison.Ordinal)); + } + private CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload) { var schemaVersion = TryReadSchemaVersion(payload); diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index a7f0f3b5..95282196 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1951,6 +1951,55 @@ namespace GameCult.Networking.Tests Assert.That(database.GetMutationLog("wrong-network-notes"), Is.Empty); } + [Test] + public async Task CultNetDatabase_Applies_Delete_BySchemaAlias() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = + [ + new CultNetShardDescriptor( + "wrong-network-notes", + "runtime-b", + epoch: 1, + isPrimary: false, + schemaIds: ["other.note.v1"], + keyPrefix: "network-note:"), + new CultNetShardDescriptor( + "network-notes", + "runtime-a", + epoch: 2, + isPrimary: true, + schemaIds: ["tests.networking_note.v1"], + keyPrefix: "network-note:") + ] + }); + var key = new CultRecordKey("network-note:alias-delete"); + await database.PutAsync(key, new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "delete through alias", + Revision = 13 + }); + + await database.ApplyDeleteAsync(new CultNetDocumentDeleteMessage + { + MessageId = "delete-alias", + SchemaId = "tests.networking_note.v1", + RecordKey = key.Value, + ShardId = "network-notes", + ShardEpoch = 2 + }); + + Assert.That(cache.Get(key), Is.Null); + Assert.That(database.GetMutationLog("network-notes").Last().Kind, Is.EqualTo(CultNetDatabaseChangeKind.Removed)); + Assert.That(database.GetMutationLog("wrong-network-notes"), Is.Empty); + } + [Test] public async Task CultNetDatabase_Predicts_ClientOwnedInput_ThroughSchemaAliasScope() { From d9ffdd77a40236f7366e80029b0b54dfb15dec5e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:13:28 +0100 Subject: [PATCH 075/498] Resolve Python raw deletes by schema alias --- packages/cultcache-py/src/cultmesh_py/node.py | 15 +++- .../cultcache-py/src/cultmesh_py/server.py | 11 ++- .../src/cultnet_py/replication.py | 4 +- packages/cultcache-py/tests/test_cultcache.py | 70 +++++++++++++++++++ 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index e9150d2d..81a50cf1 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -848,8 +848,7 @@ class CultMeshDatabase: def _document_for_schema(self, schema_id: str) -> DocumentDefinition[Any] | None: for document in self.documents: - entry = document.catalog_entry() - if schema_id == entry.schema_id or schema_id in entry.compatible_schema_ids: + if _document_matches_schema_id(document, schema_id): return document return None @@ -931,7 +930,8 @@ class CultMeshDatabase: if document is None: continue record_key = str(delete.get("recordKey")) - previous[(schema_id, record_key)] = self.cache.get(document, record_key) + resolved_schema_id = document.catalog_entry().schema_id + previous[(resolved_schema_id, record_key)] = self.cache.get(document, record_key) return previous def _previous_values_for_raw_records(self, records: Any) -> dict[tuple[str, str], Any]: @@ -979,6 +979,15 @@ def _infer_schema_name(schema_id: str) -> str | None: return schema_id[:marker] if version.isdigit() else None +def _document_matches_schema_id(document: DocumentDefinition[Any], schema_id: str) -> bool: + entry = document.catalog_entry() + if schema_id in (entry.schema_id, entry.schema_name, entry.schema_version): + return True + if schema_id in entry.compatible_schema_ids: + return True + return _infer_schema_name(schema_id) == entry.schema_name + + def create_node( cache_path: str | Path | None = None, *, diff --git a/packages/cultcache-py/src/cultmesh_py/server.py b/packages/cultcache-py/src/cultmesh_py/server.py index ca134c8d..d560cc83 100644 --- a/packages/cultcache-py/src/cultmesh_py/server.py +++ b/packages/cultcache-py/src/cultmesh_py/server.py @@ -382,7 +382,7 @@ class CultMeshLocalServer: message_id=str(message.get("messageId") or ""), code="unregistered_document_delete", )] - return self._database_delete_notifications(message, subscriptions) + return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id) def _database_change_notifications( self, @@ -415,13 +415,19 @@ class CultMeshLocalServer: self, message: dict[str, Any], subscriptions: dict[str, _DatabaseSubscription], + *, + resolved_schema_id: str | None = None, ) -> list[dict[str, Any]]: schema_id = str(message.get("schemaId") or "") record_key = str(message.get("recordKey") or "") notifications = [] document = {"schemaId": schema_id, "recordKey": record_key} for subscription in subscriptions.values(): - if not self._subscription_matches_document(subscription, document): + if not self._subscription_matches_document( + subscription, + document, + resolved_schema_id=resolved_schema_id, + ): continue message_id = message.get("messageId") or record_key or "change" notifications.append({ @@ -678,6 +684,7 @@ def _schema_document_map(documents: list[Any]) -> dict[str, Any]: entry = document.catalog_entry() mapped[entry.schema_id] = document mapped[entry.schema_name] = document + mapped[entry.schema_version] = document for compatible_schema_id in entry.compatible_schema_ids: mapped[compatible_schema_id] = document return mapped diff --git a/packages/cultcache-py/src/cultnet_py/replication.py b/packages/cultcache-py/src/cultnet_py/replication.py index ca3f2752..6450d4f9 100644 --- a/packages/cultcache-py/src/cultnet_py/replication.py +++ b/packages/cultcache-py/src/cultnet_py/replication.py @@ -24,6 +24,7 @@ def schema_document_map(documents: Iterable[DocumentDefinition[Any]]) -> dict[st catalog_entry = document.catalog_entry() mapped[catalog_entry.schema_id] = document mapped[catalog_entry.schema_name] = document + mapped[catalog_entry.schema_version] = document for compatible_schema_id in catalog_entry.compatible_schema_ids: mapped[compatible_schema_id] = document return mapped @@ -140,8 +141,9 @@ def apply_shard_log_response( if schema_id not in documents_by_schema_id: continue document = documents_by_schema_id[schema_id] + resolved_schema_id = document.catalog_entry().schema_id cache.delete(document, record_key) - applied.append(CultNetAppliedRecord(schema_id, record_key, "removed", None)) + applied.append(CultNetAppliedRecord(resolved_schema_id, record_key, "removed", None)) return applied diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 9138268a..8442dfbb 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1881,6 +1881,32 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(cache.get_required(document, "policy:1")["value"], "updated through log") self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + delete_log = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-delete-alias", + "shardId": "interop", + "shardEpoch": 1, + "resyncRequired": False, + "entries": [ + { + "sequence": 1, + "changeKind": "removed", + "delete": { + "schemaVersion": "cultnet.document_delete.v0", + "messageId": "delete-version-alias", + "schemaId": "replica.runtime_policy.v1", + "recordKey": "policy:1", + "shardId": "interop", + "shardEpoch": 1, + }, + } + ], + } + applied_delete = apply_shard_log_response(cache, [document], delete_log) + self.assertEqual(applied_delete[0].schema_id, local_schema_id) + self.assertEqual(applied_delete[0].change_kind, "removed") + self.assertIsNone(cache.get(document, "policy:1")) + def test_cultmesh_node_applies_foreign_schema_snapshot_as_local_update(self) -> None: document = define_database_entry_type( "mesh.runtime-policy", @@ -5314,6 +5340,50 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(node.database.get_required(document, "note:alias-sub")["body"], "notified") self.assertEqual(node.cache.get_required_envelope(document, "note:alias-sub").schema_id, local_schema_id) + def test_cultmesh_local_server_notifies_schema_alias_subscription_for_delete(self) -> None: + document = define_database_entry_type( + "mesh.alias_delete_note", + [("body", 0)], + schema_id="sha256:mesh-alias-delete-note", + schema_name="mesh.alias_delete_note", + schema_version="mesh.alias_delete_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-delete-server") + node.database.register_document(document) + node.database.put_raw_message( + document, + "note:delete-alias", + {"body": "remove me"}, + shard_id="notes", + shard_epoch=1, + ) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with raw_client.subscribe_database( + subscription_id="sub-delete-alias", + schema_ids=[document.catalog_entry().schema_id], + include_snapshot=False, + ) as subscription: + subscription.send(document_delete( + message_id="delete-foreign-alias", + schema_id="mesh.alias_delete_note.v1", + record_key="note:delete-alias", + shard_id="notes", + shard_epoch=1, + )) + change = subscription.read_next_change() + finally: + server.stop() + + self.assertEqual(change.subscription_id, "sub-delete-alias") + self.assertEqual(change.change_kind, "removed") + self.assertEqual(change.schema_id, "mesh.alias_delete_note.v1") + self.assertEqual(change.record_key, "note:delete-alias") + self.assertIsNone(change.raw_document) + self.assertIsNone(node.database.get(document, "note:delete-alias")) + def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None: document = define_database_entry_type( "mesh.snapshot_limit_note", From 26159f9cfa4c3c5b4abd5c7d34076d13a9d1009e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:16:26 +0100 Subject: [PATCH 076/498] Index C# document catalogs by schema id --- src/GameCult.Mesh/CultMeshPrimitives.cs | 9 +++++++-- tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 70ab2737..f45a9ae8 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -892,6 +892,9 @@ namespace GameCult.Mesh /// Gets the stable CultCache schema version. string SchemaVersion { get; } + /// Gets the content-derived schema identifier. + string SchemaId { get; } + /// Gets the preferred or observed route for document access. CultMeshRouteHint RouteHint { get; } @@ -1577,6 +1580,8 @@ namespace GameCult.Mesh foreach (var document in documentList) { byType[document.DocumentType] = document; + if (!string.IsNullOrWhiteSpace(document.SchemaId)) + bySchema[document.SchemaId] = document; if (!string.IsNullOrWhiteSpace(document.SchemaVersion)) bySchema[document.SchemaVersion] = document; if (!string.IsNullOrWhiteSpace(document.SchemaName)) @@ -1591,7 +1596,7 @@ namespace GameCult.Mesh /// Gets the handles in this catalog. public IReadOnlyList Documents => _documents; - /// Looks up a document handle by shared schema name or schema version. + /// Looks up a document handle by schema id, shared schema name, or schema version. public bool TryGetDocumentBySchema( string schema, out ICultMeshDocumentHandle document) @@ -1606,7 +1611,7 @@ namespace GameCult.Mesh return false; } - /// Looks up a document handle by shared schema name or schema version. + /// Looks up a document handle by schema id, shared schema name, or schema version. public ICultMeshDocumentHandle DocumentBySchema(string schema) { if (TryGetDocumentBySchema(schema, out var document)) diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 205789a9..3d4e3c04 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -724,6 +724,9 @@ public sealed class CultMeshStreamingTests catalog.TryGetDocumentBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue(); byVersion.Should().BeSameAs(handle); catalog.DocumentBySchema("tests.mesh_note").Should().BeSameAs(handle); + catalog.DocumentBySchema(CultDocumentRegistry.Shared.GetRequired().SchemaId) + .Should() + .BeSameAs(handle); catalog.TryGetDocument(out var alias).Should().BeTrue(); alias.DocumentId.Should().Be(handle.DocumentId); From a3d957dad2ed35bafad569d55b58947c0582bfbc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:20:18 +0100 Subject: [PATCH 077/498] Infer TS document aliases from schema versions --- packages/cultmesh-ts/src/index.ts | 20 +++++++++++++--- packages/cultmesh-ts/test/cultmesh.test.ts | 28 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 6481bcbb..d2d46991 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -5640,11 +5640,13 @@ function cultMeshSchemaFromDefinition( function normalizeCultMeshDocumentSchema( schema: CultMeshDocumentSchemaDescriptor, ): CultMeshDocumentSchemaDescriptor { + const schemaId = schema.schemaId?.trim() || undefined; + const inferredSchemaName = schema.schemaName?.trim() || inferCultMeshSchemaName(schemaId); const normalized = { type: schema.type?.trim() || undefined, - schemaId: schema.schemaId?.trim() || undefined, - schemaName: schema.schemaName?.trim() || undefined, - schemaVersion: schema.schemaVersion?.trim() || undefined, + schemaId, + schemaName: inferredSchemaName, + schemaVersion: schema.schemaVersion?.trim() || (inferredSchemaName ? schemaId : undefined), }; if (!normalized.type && !normalized.schemaId && !normalized.schemaName) { @@ -5682,6 +5684,18 @@ function cultMeshSchemasAreCompatible( return Boolean(left.type && right.type && left.type === right.type); } +function inferCultMeshSchemaName(schemaId: string | undefined): string | undefined { + if (!schemaId) { + return undefined; + } + const marker = schemaId.lastIndexOf(".v"); + if (marker <= 0 || marker + 2 >= schemaId.length) { + return undefined; + } + const version = schemaId.slice(marker + 2); + return /^\d+$/.test(version) ? schemaId.slice(0, marker) : undefined; +} + function resolveCultMeshStoreDocumentRecord( records: readonly CultCacheEnvelope[], schema: CultMeshDocumentSchemaDescriptor, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 86f58b77..91c346f0 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -679,6 +679,34 @@ test("CultMesh TS binds publication document catalogs from source resolvers", as assert.equal(catalog.document(noteDocument).routeHint.description, "publication catalog"); }); +test("CultMesh TS document catalogs resolve semantic schema versions passed as schema ids", async () => { + const current = { + noteId: "note:semantic", + body: "semantic alias", + }; + const document = CultMesh.document( + "daemon:semantic-note", + { + schemaId: "sha256:runtime-generated-note", + schemaName: "cultmesh.note", + schemaVersion: "cultmesh.note.v1", + }, + async () => current, + { + routeHint: CultMesh.routeHint("shared-memory", "semantic alias catalog"), + }, + ); + const catalog = CultMesh.documents(document); + + const alias = catalog.document({ + schemaId: "cultmesh.note.v1", + }); + + assert.equal(alias.documentId, "daemon:semantic-note"); + assert.equal(alias.routeHint.description, "semantic alias catalog"); + assert.deepEqual(await alias.latest(), current); +}); + test("CultMesh TS collection handles expose typed snapshots and reset watches", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-coll-")), "node.ccmp"); const node = await CultMesh.startNode(filePath, { From 62189007341945b6aab9148988db30fc9fbddf33 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:25:15 +0100 Subject: [PATCH 078/498] Prefer typed peer snapshot aliases by record key --- src/GameCult.Mesh/CultMesh.cs | 29 +++++++++++++ .../CultMeshStreamingTests.cs | 43 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 3875e251..27c0aa0c 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -2316,6 +2316,9 @@ namespace GameCult.Mesh var record = response.Documents.FirstOrDefault(candidate => string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) && string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal)) + ?? response.Documents.FirstOrDefault(candidate => + string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal) && + TryDecodeSnapshotDocument(candidate, out TDocument? _)) ?? response.Documents.FirstOrDefault(candidate => string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal)) ?? response.Documents.FirstOrDefault(candidate => @@ -2332,6 +2335,32 @@ namespace GameCult.Mesh $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{record.PayloadEncoding}\"."); } + return DecodeSnapshotDocument(record); + } + + private static bool TryDecodeSnapshotDocument( + CultNetRawDocumentRecord record, + out TDocument? document) + where TDocument : class + { + document = null; + if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal)) + return false; + + try + { + document = DecodeSnapshotDocument(record); + return true; + } + catch + { + return false; + } + } + + private static TDocument DecodeSnapshotDocument(CultNetRawDocumentRecord record) + where TDocument : class + { return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped( typeof(TDocument), record.Payload); diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 3d4e3c04..1ba030f3 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2260,6 +2260,49 @@ public sealed class CultMeshStreamingTests CultMesh.Verse("starbridge", "unity-pilot")); (await aliasSchemaHandle.LatestAsync()).Text.Should().Be("record-key-fallback"); + + var mixedAliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( + _ => Task.FromResult(new CultNetSnapshotResponseRawMessage + { + MessageId = "alias-schema-with-neighbor", + Documents = new[] + { + new CultNetRawDocumentRecord + { + SchemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId, + RecordKey = "mesh-note:other", + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "wrong-schema-neighbor", + Revision = 11 + }, + typeof(MeshNoteDocument)) + }, + new CultNetRawDocumentRecord + { + SchemaId = "remote.generated.mesh_note_alias.v100", + RecordKey = key.Value, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "compatible-record-key", + Revision = 12 + }, + typeof(MeshNoteDocument)) + } + } + }), + key.Value, + CultMesh.Verse("starbridge", "unity-pilot")); + + (await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key"); } [Test] From a825974e9aab42effafd269d56db72f09b3e1462 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:29:56 +0100 Subject: [PATCH 079/498] Resolve Python shard snapshots by schema alias --- packages/cultcache-py/src/cultmesh_py/node.py | 26 +++++++- packages/cultcache-py/tests/test_cultcache.py | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index 81a50cf1..9a9d433d 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -807,15 +807,37 @@ class CultMeshDatabase: schema_id = document.get("schemaId") record_key = document.get("recordKey") if schema_id and record_key: - records.add((str(schema_id), str(record_key))) + records.add(self._canonical_shard_record_key(str(schema_id), str(record_key), document)) delete = entry.get("delete") if isinstance(delete, dict): schema_id = delete.get("schemaId") record_key = delete.get("recordKey") if schema_id and record_key: - records.discard((str(schema_id), str(record_key))) + records.discard(self._canonical_shard_record_key(str(schema_id), str(record_key))) return records + def _canonical_shard_record_key( + self, + schema_id: str, + record_key: str, + raw_record: dict[str, Any] | None = None, + ) -> tuple[str, str]: + documents_by_schema_id = schema_document_map(self.documents) + if raw_record is not None: + try: + _, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( + documents_by_schema_id, + schema_id, + raw_record, + ) + return resolved_schema_id, record_key + except KeyError: + pass + document = documents_by_schema_id.get(schema_id) + if document is not None: + return document.catalog_entry().schema_id, record_key + return schema_id, record_key + def _shard_log_entries( self, shard_id: str, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 8442dfbb..1635c209 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -5295,6 +5295,71 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id]) self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") + def test_cultmesh_shard_snapshot_keeps_foreign_schema_alias_membership(self) -> None: + document = define_database_entry_type( + "mesh.alias_shard_note", + [ + ("schema_version", 0), + ("body", 1), + ], + schema_id="sha256:mesh-alias-shard-note", + schema_name="mesh.alias_shard_note", + schema_version="mesh.alias_shard_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-shard-server") + node.database.register_document(document) + + node.database.apply_raw_put_message(document_put_raw( + message_id="put-foreign-shard-alias", + key="note:foreign-shard", + schema_id="runtime.generated.mesh.alias_shard_note.ui.99", + stored_at="2026-06-27T00:00:00Z", + payload=document.encode_payload({ + "schema_version": "mesh.alias_shard_note.v1", + "body": "visible through shard snapshot", + }), + shard_id="notes", + shard_epoch=1, + ).to_wire()) + node.database.put_raw_message( + document, + "note:other-shard", + { + "schema_version": "mesh.alias_shard_note.v1", + "body": "hidden by shard filter", + }, + shard_id="other", + shard_epoch=1, + ) + + response = node.database.build_snapshot_response( + schema_ids=["mesh.alias_shard_note.v1"], + shard_id="notes", + shard_epoch=1, + ) + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(response.shard_id, "notes") + self.assertEqual(response.shard_epoch, 1) + self.assertEqual([(record.schema_id, record.record_key) for record in response.documents], [ + (local_schema_id, "note:foreign-shard"), + ]) + self.assertEqual(node.cache.get_required_envelope(document, "note:foreign-shard").schema_id, local_schema_id) + + node.database.apply_raw_delete_message(document_delete( + message_id="delete-foreign-shard-alias", + schema_id="mesh.alias_shard_note.v1", + record_key="note:foreign-shard", + shard_id="notes", + shard_epoch=1, + ).to_wire()) + deleted_response = node.database.build_snapshot_response( + schema_ids=["mesh.alias_shard_note.v1"], + shard_id="notes", + shard_epoch=1, + ) + self.assertEqual(deleted_response.documents, ()) + def test_cultmesh_local_server_notifies_schema_alias_subscription_for_raw_put(self) -> None: document = define_database_entry_type( "mesh.alias_sub_note", From 964a3ff1f833f22dc0bb5860cd97f859690f4e2f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:33:50 +0100 Subject: [PATCH 080/498] Add schema-aware collection catalogs --- src/GameCult.Mesh/CultMesh.cs | 16 ++ src/GameCult.Mesh/CultMeshPrimitives.cs | 159 +++++++++++++++++- src/GameCult.Mesh/docs/public-api.md | 10 ++ .../CultMeshStreamingTests.cs | 28 +++ 4 files changed, 212 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 27c0aa0c..29b47fb6 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1782,6 +1782,22 @@ namespace GameCult.Mesh return new CultMeshDocumentCatalog(documents); } + /// + /// Creates a schema-aware catalog over typed collection handles. + /// + public static CultMeshCollectionCatalog Collections(IEnumerable collections) + { + return new CultMeshCollectionCatalog(collections); + } + + /// + /// Creates a schema-aware catalog over typed collection handles. + /// + public static CultMeshCollectionCatalog Collections(params ICultMeshCollectionHandle[] collections) + { + return new CultMeshCollectionCatalog(collections); + } + /// /// Reads two typed document handles and projects them into one caller-defined view. /// diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index f45a9ae8..b6705f20 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1763,6 +1763,163 @@ namespace GameCult.Mesh } } + /// + /// Inspectable typed collection handle exposed by CultMesh. + /// + public interface ICultMeshCollectionHandle + { + /// Gets the CLR document type presented by this collection. + Type DocumentType { get; } + + /// Gets the semantic collection id. + string CollectionId { get; } + + /// Gets the stable CultCache schema name. + string SchemaName { get; } + + /// Gets the stable CultCache schema version. + string SchemaVersion { get; } + + /// Gets the content-derived schema identifier. + string SchemaId { get; } + + /// Gets the preferred or observed route for collection access. + CultMeshRouteHint RouteHint { get; } + + /// Gets typed state sources this collection handle depends on, when known. + IReadOnlyList Sources { get; } + + /// Creates a same-schema alias presentation for another CLR document type. + CultMeshCollectionHandle AsSchemaAlias() where TAlias : class; + } + + /// + /// Schema-aware lookup table for typed CultMesh collection handles. + /// + public sealed class CultMeshCollectionCatalog + { + private readonly IReadOnlyList _collections; + private readonly IReadOnlyDictionary _collectionsByType; + private readonly IReadOnlyDictionary _collectionsBySchema; + + /// Creates a collection catalog from known handles. + public CultMeshCollectionCatalog(IEnumerable collections) + { + if (collections == null) throw new ArgumentNullException(nameof(collections)); + + var collectionList = collections + .Where(collection => collection != null) + .ToArray(); + var byType = new Dictionary(); + var bySchema = new Dictionary(StringComparer.Ordinal); + foreach (var collection in collectionList) + { + byType[collection.DocumentType] = collection; + if (!string.IsNullOrWhiteSpace(collection.SchemaId)) + bySchema[collection.SchemaId] = collection; + if (!string.IsNullOrWhiteSpace(collection.SchemaVersion)) + bySchema[collection.SchemaVersion] = collection; + if (!string.IsNullOrWhiteSpace(collection.SchemaName)) + bySchema[collection.SchemaName] = collection; + } + + _collections = collectionList; + _collectionsByType = byType; + _collectionsBySchema = bySchema; + } + + /// Gets the handles in this catalog. + public IReadOnlyList Collections => _collections; + + /// Looks up a collection handle by schema id, shared schema name, or schema version. + public bool TryGetCollectionBySchema( + string schema, + out ICultMeshCollectionHandle collection) + { + if (!string.IsNullOrWhiteSpace(schema) && + _collectionsBySchema.TryGetValue(schema, out collection!)) + { + return true; + } + + collection = null!; + return false; + } + + /// Looks up a collection handle by schema id, shared schema name, or schema version. + public ICultMeshCollectionHandle CollectionBySchema(string schema) + { + if (TryGetCollectionBySchema(schema, out var collection)) + return collection; + + throw new NotSupportedException( + $"CultMesh collection catalog does not expose a collection for schema '{schema}'."); + } + + /// Looks up a typed collection handle by CLR type or same-schema alias. + public bool TryGetCollection( + out CultMeshCollectionHandle collection) + where TDocument : class + { + if (_collectionsByType.TryGetValue(typeof(TDocument), out var untypedCollection) && + untypedCollection is CultMeshCollectionHandle typedCollection) + { + collection = typedCollection; + return true; + } + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + if (_collectionsBySchema.TryGetValue(descriptor.SchemaId, out var schemaCollection) || + _collectionsBySchema.TryGetValue(descriptor.SchemaVersion, out schemaCollection!) || + _collectionsBySchema.TryGetValue(descriptor.SchemaName, out schemaCollection!)) + { + if (schemaCollection is CultMeshCollectionHandle schemaTypedCollection) + { + collection = schemaTypedCollection; + return true; + } + + collection = schemaCollection.AsSchemaAlias(); + return true; + } + + collection = null!; + return false; + } + + /// Looks up a typed collection handle by CLR type or same-schema alias. + public CultMeshCollectionHandle Collection() + where TDocument : class + { + if (TryGetCollection(out var collection)) + return collection; + + throw new NotSupportedException( + $"CultMesh collection catalog does not expose a collection for {typeof(TDocument).FullName}."); + } + + /// Reads one typed collection snapshot by CLR type or same-schema alias. + public Task> LatestAsync() + where TDocument : class + { + return Collection().LatestAsync(); + } + + /// Watches one typed collection by CLR type or same-schema alias. + public Observable> WatchChanges() + where TDocument : class + { + return Collection().WatchChanges(); + } + + /// Subscribes to one typed collection by CLR type or same-schema alias. + public IDisposable WatchChanges(Action> onNext) + where TDocument : class + { + return Collection().WatchChanges(onNext); + } + } + /// /// Describes how a CultMesh collection document changed. /// @@ -1829,7 +1986,7 @@ namespace GameCult.Mesh /// /// Typed reactive collection handle with schema-aware alias conversion. /// - public sealed class CultMeshCollectionHandle + public sealed class CultMeshCollectionHandle : ICultMeshCollectionHandle where TDocument : class { private static readonly CultDocumentDescriptor Descriptor = diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 1b592748..3ff86524 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -191,6 +191,12 @@ portable machinery that makes it feel native. Collection handles can represent all records of a document type, a named document view, or an indexed value view, and they support the same same-schema CLR alias conversion as document handles. +- `CultMeshCollectionCatalog` is the collection sibling of + `CultMeshDocumentCatalog`. It indexes typed collection handles by CLR + document type, schema id, schema name, and schema version, then exposes + `Collection()`, `LatestAsync()`, and `WatchChanges()` so domain + facades can publish typed multi-record state without maintaining parallel + schema lookup tables. - `CultMeshProjectionRecipe` names a reusable projection from typed source state into derived state. It records source handles, route hints, and projection execution, and can be exposed as a typed query @@ -321,6 +327,10 @@ var currentAllies = await allies.LatestAsync(); using var changes = allies.WatchChanges(change => UpdateRoster(change)); var uiAllies = allies.AsSchemaAlias(); + +var collections = CultMesh.Collections(allies); +var uiRoster = await collections.LatestAsync(); +using var uiRosterChanges = collections.WatchChanges(RenderRoster); ``` ```ts diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 1ba030f3..bfdd3f0e 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2665,12 +2665,40 @@ public sealed class CultMeshStreamingTests var collection = CultMesh.Collection(cache); var alias = collection.AsSchemaAlias(); + var catalog = CultMesh.Collections(collection); alias.CollectionId.Should().Be(collection.CollectionId); alias.SchemaName.Should().Be(collection.SchemaName); + catalog.Collections.Should().ContainSingle().Which.Should().BeSameAs(collection); + catalog.TryGetCollection(out var catalogAlias).Should().BeTrue(); + catalogAlias.CollectionId.Should().Be(collection.CollectionId); + catalog.TryGetCollectionBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue(); + byVersion.Should().BeSameAs(collection); + catalog.CollectionBySchema("tests.mesh_note").Should().BeSameAs(collection); + catalog.CollectionBySchema(CultDocumentRegistry.Shared.GetRequired().SchemaId) + .Should() + .BeSameAs(collection); var snapshot = await alias.LatestAsync(); snapshot.Should().ContainSingle().Which.Text.Should().Be("collection-alias"); + var catalogSnapshot = await catalog.LatestAsync(); + catalogSnapshot.Should().ContainSingle().Which.Revision.Should().Be(4); + + CultMeshCollectionChange observed = null!; + using var subscription = catalog.WatchChanges(change => observed = change); + await cache.UpsertAsync( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "collection-alias-updated", + Revision = 5 + }, + new CultRecordHandle(new CultRecordKey("mesh-note:collection"))); + + observed.Kind.Should().Be(CultMeshCollectionChangeKind.Updated); + observed.Document!.Text.Should().Be("collection-alias-updated"); + observed.Document.Revision.Should().Be(5); + observed.PreviousDocument!.Text.Should().Be("collection-alias"); } [Test] From 258a7102736f88ff78a03389ba28fe311713b7df Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:38:04 +0100 Subject: [PATCH 081/498] Add TS collection catalogs --- packages/cultmesh-ts/src/index.ts | 93 ++++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 61 ++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index d2d46991..0081ec32 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -1627,6 +1627,87 @@ export class CultMeshCollectionHandle { } } +export class CultMeshCollectionCatalog { + readonly #byCollectionId = new Map>(); + readonly #byType = new Map>(); + readonly #bySchemaId = new Map>(); + readonly #bySchemaNameVersion = new Map>(); + + public constructor(collections: Iterable>) { + for (const collection of collections) { + this.add(collection); + } + } + + public get collections(): readonly CultMeshCollectionHandle[] { + return [...this.#byCollectionId.values()]; + } + + public add(collection: CultMeshCollectionHandle): this { + this.#byCollectionId.set(collection.collectionId, collection as CultMeshCollectionHandle); + if (collection.schema.type) { + this.#byType.set(collection.schema.type, collection as CultMeshCollectionHandle); + } + if (collection.schema.schemaId) { + this.#bySchemaId.set(collection.schema.schemaId, collection as CultMeshCollectionHandle); + } + const key = cultMeshSchemaNameVersionKey(collection.schema); + if (key) { + this.#bySchemaNameVersion.set(key, collection as CultMeshCollectionHandle); + } + return this; + } + + public tryCollection( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument } = {}, + ): CultMeshCollectionHandle | undefined { + const descriptor = normalizeCultMeshDocumentSchema(schema); + const exact = + (descriptor.type ? this.#byType.get(descriptor.type) : undefined) ?? + (descriptor.schemaId ? this.#bySchemaId.get(descriptor.schemaId) : undefined) ?? + (() => { + const key = cultMeshSchemaNameVersionKey(descriptor); + return key ? this.#bySchemaNameVersion.get(key) : undefined; + })(); + + if (!exact) { + return undefined; + } + + return cultMeshSchemasAreCompatible(exact.schema, descriptor) + ? exact.asSchemaAlias(descriptor, options) + : undefined; + } + + public collection( + schema: CultMeshDocumentSchemaDescriptor, + options: { parse?: (value: unknown) => TDocument } = {}, + ): CultMeshCollectionHandle { + const collection = this.tryCollection(schema, options); + if (!collection) { + throw new Error(`Collection catalog has no collection for ${cultMeshSchemaLabel(schema)}.`); + } + return collection; + } + + public latest( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string = "local", + options: { parse?: (value: unknown) => TDocument } = {}, + ): Promise> { + return this.collection(schema, options).latest(context); + } + + public watchChanges( + schema: CultMeshDocumentSchemaDescriptor, + callback: (change: CultMeshCollectionChange) => void, + options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, + ): CultMeshUnsubscribe { + return this.collection(schema, options).watchChanges(options.context ?? "local", callback); + } +} + export class CultMeshBoundCollectionHandle { public constructor( public readonly verse: CultMeshVerseContext, @@ -2797,6 +2878,12 @@ export function cultMeshCollection( return new CultMeshCollectionHandle(collectionId, schema, snapshotCollection, options); } +export function cultMeshCollections( + ...collections: readonly CultMeshCollectionHandle[] +): CultMeshCollectionCatalog { + return new CultMeshCollectionCatalog(collections); +} + export function cultMeshCollectionFromCache( cache: CultCache, definition: TDefinition, @@ -4756,6 +4843,12 @@ export class CultMesh { return cultMeshCollection(collectionId, schema, snapshotCollection, options); } + public static collections( + ...collections: readonly CultMeshCollectionHandle[] + ): CultMeshCollectionCatalog { + return cultMeshCollections(...collections); + } + public static collectionFromCache( cache: CultCache, definition: TDefinition, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 91c346f0..993ef62d 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -740,9 +740,70 @@ test("CultMesh TS collection handles expose typed snapshots and reset watches", }).latest()).map(note => note.body).sort(), ["alpha", "bravo"], ); + const catalog = CultMesh.collections(collection); + assert.deepEqual( + (await catalog.latest(noteAliasDocument, "local", { + parse: value => noteAliasDocument.schema.parse(value), + })).map(note => note.body).sort(), + ["alpha", "bravo"], + ); + assert.equal( + catalog.collection({ schemaId: "cultmesh.note.v0" }).collectionId, + collection.collectionId, + ); + assert.equal(catalog.tryCollection({ schemaId: "cultmesh.missing.v1" }), undefined); assert.ok(changes.every(kind => kind === "reset")); }); +test("CultMesh TS collection catalogs resolve same-schema alias watches", async () => { + const current = [{ + noteId: "note:catalog-watch", + body: "initial", + }]; + let watcher: ((change: { kind: "updated"; value: typeof current[number] }) => void) | undefined; + const collection = CultMesh.collection( + "daemon:catalog-watch", + { + schemaId: "sha256:runtime-generated-note", + schemaName: "cultmesh.note", + schemaVersion: "cultmesh.note.v1", + }, + async () => current, + { + routeHint: CultMesh.routeHint("shared-memory", "collection catalog"), + watchCollection: (_context, callback) => { + watcher = callback as typeof watcher; + return () => { + watcher = undefined; + }; + }, + }, + ); + const catalog = CultMesh.collections(collection); + const observed: string[] = []; + const semanticAlias = { schemaId: "cultmesh.note.v1" }; + const unsubscribe = catalog.watchChanges>(semanticAlias, change => { + if (change.value) { + observed.push(change.value.body); + } + }, { + parse: value => noteAliasDocument.schema.parse(value), + }); + + watcher?.({ + kind: "updated", + value: { + noteId: "note:catalog-watch", + body: "through-catalog-alias", + }, + }); + unsubscribe(); + + assert.deepEqual(observed, ["through-catalog-alias"]); + assert.equal(watcher, undefined); + assert.equal(catalog.collection(semanticAlias).routeHint.description, "collection catalog"); +}); + test("CultMesh TS local authority leases do not trust peer cards by contact alone", () => { const peers = CultMesh.createPeerCatalog(); const leases = CultMesh.createAuthorityLeaseCatalog(); From 4f4a4699e51b70e096aa3995cbf2fb16be546e8d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:40:42 +0100 Subject: [PATCH 082/498] Resolve Python collection reads by schema alias --- packages/cultcache-py/src/cultmesh_py/node.py | 26 +++++++++ packages/cultcache-py/tests/test_cultcache.py | 54 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index 9a9d433d..a11aed2a 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -81,6 +81,15 @@ class CultMeshNode: def get_required(self, document: DocumentDefinition[Any], key: str) -> Any: return self.database.get_required(document, key) + def get_all(self, document: DocumentDefinition[Any]) -> list[Any]: + return self.database.get_all(document) + + def get_by_name(self, document: DocumentDefinition[Any], name: str) -> Any: + return self.database.get_by_name(document, name) + + def get_by_index(self, document: DocumentDefinition[Any], index: str, value: str) -> Any: + return self.database.get_by_index(document, index, value) + def get_global(self, document: DocumentDefinition[Any]) -> Any: return self.database.get_global(document) @@ -390,6 +399,23 @@ class CultMeshDatabase: raise CultCacheError(f"Missing {document.type}:{key}") return value + def get_all(self, document: DocumentDefinition[Any]) -> list[Any]: + registered = self._resolve_document_alias(document) + return [ + self._convert_document_value(value, registered, document) + for value in self.cache.get_all(registered) + ] + + def get_by_name(self, document: DocumentDefinition[Any], name: str) -> Any: + registered = self._resolve_document_alias(document) + value = self.cache.get_by_name(registered, name) + return None if value is None else self._convert_document_value(value, registered, document) + + def get_by_index(self, document: DocumentDefinition[Any], index: str, value: str) -> Any: + registered = self._resolve_document_alias(document) + result = self.cache.get_by_index(registered, index, value) + return None if result is None else self._convert_document_value(result, registered, document) + def get_global(self, document: DocumentDefinition[Any]) -> Any: return self.get(document, self.cache.GLOBAL_KEY) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 1635c209..ce0461e9 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2134,6 +2134,10 @@ class CultCacheTests(unittest.TestCase): alias_value = node.get_required(alias, "note:alias") self.assertIsInstance(alias_value, UiNote) self.assertEqual(alias_value.body, "from-alias-put") + alias_collection = node.get_all(alias) + self.assertEqual(len(alias_collection), 1) + self.assertIsInstance(alias_collection[0], UiNote) + self.assertEqual(alias_collection[0].body, "from-alias-put") self.assertEqual(seen[0].document.type, document.type) self.assertIsInstance(seen[0].value, CanonicalNote) self.assertEqual(seen[0].value.body, "from-alias-put") @@ -2151,6 +2155,7 @@ class CultCacheTests(unittest.TestCase): delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire() self.assertEqual(delete["schemaId"], "mesh.alias_note.v1") self.assertIsNone(node.get(alias, "note:alias")) + self.assertEqual(node.get_all(alias), []) def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None: message = document_delete( @@ -3093,6 +3098,55 @@ class CultCacheTests(unittest.TestCase): self.assertEqual([change.record_key for change in index_changes], ["item:1", "item:1"]) self.assertEqual(index_changes[1].value, {"name": "Elixir", "kind": "rare"}) + def test_cultmesh_database_collection_reads_resolve_same_schema_aliases(self) -> None: + @dataclass + class CanonicalItem: + name: str + kind: str + + @dataclass + class UiItem: + name: str + kind: str + + document = define_database_entry_type( + "mesh.alias_collection_item", + [("name", 0), ("kind", 1)], + cls=CanonicalItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_item.v1", + schema_name="mesh.alias_collection_item", + schema_version="mesh.alias_collection_item.v1", + ) + alias = define_database_entry_type( + "mesh.alias_collection_item.ui", + [("name", 0), ("kind", 1)], + cls=UiItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_item.v1", + schema_name="mesh.alias_collection_item", + schema_version="mesh.alias_collection_item.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-collection") + node.database.register_document(document) + + node.database.put(alias, "item:potion", UiItem("Potion", "consumable")) + node.database.put(document, "item:sword", CanonicalItem("Sword", "weapon")) + + all_items = node.database.get_all(alias) + potion = node.database.get_by_name(alias, "Potion") + sword = node.get_by_index(alias, "kind", "weapon") + + self.assertEqual([item.name for item in all_items], ["Potion", "Sword"]) + self.assertTrue(all(isinstance(item, UiItem) for item in all_items)) + self.assertIsInstance(potion, UiItem) + self.assertEqual(potion.kind, "consumable") + self.assertIsInstance(sword, UiItem) + self.assertEqual(sword.name, "Sword") + self.assertIsNone(node.database.get_by_name(alias, "Missing")) + def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None: document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)]) node = CultMesh.create_node(runtime_id="mesh-watch-validation") From 31af506691d6d96a6cec434b74bb2a6dc809806b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:51:18 +0100 Subject: [PATCH 083/498] Emit Python alias-shaped mesh watch changes --- packages/cultcache-py/src/cultmesh_py/node.py | 52 ++++++++++++-- packages/cultcache-py/tests/test_cultcache.py | 71 ++++++++++++++++++- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index a11aed2a..3f8d235e 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -264,7 +264,16 @@ class CultMeshDatabase: document: DocumentDefinition[Any] | None = None, key: str | None = None, ) -> Callable[[], None]: - subscriber = (document, key, callback) + requested = document + registered = None if document is None else self._resolve_document_alias(document) + + def converted(change: CultMeshDatabaseChange) -> None: + if requested is None: + callback(change) + else: + callback(self._change_as_document(change, requested)) + + subscriber = (registered, key, converted) self._subscribers.append(subscriber) def unsubscribe() -> None: @@ -279,8 +288,13 @@ class CultMeshDatabase: key: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: - document = self._resolve_document_alias(document) - return self.watch(callback, document=document, key=key) + requested = document + registered = self._resolve_document_alias(document) + + def converted(change: CultMeshDatabaseChange) -> None: + callback(self._change_as_document(change, requested)) + + return self.watch(converted, document=registered, key=key) def watch_global( self, @@ -295,6 +309,7 @@ class CultMeshDatabase: name: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: + requested = document document = self._resolve_document_alias(document) if not name: raise ValueError("Name watch values must be non-empty") @@ -303,7 +318,7 @@ class CultMeshDatabase: def filtered(change: CultMeshDatabaseChange) -> None: if self._change_matches_extractor(change, document.name, name): - callback(change) + callback(self._change_as_document(change, requested)) return self.watch(filtered, document=document) @@ -314,6 +329,7 @@ class CultMeshDatabase: value: str, callback: Callable[[CultMeshDatabaseChange], None], ) -> Callable[[], None]: + requested = document document = self._resolve_document_alias(document) if not index: raise ValueError("Index watch aliases must be non-empty") @@ -325,7 +341,7 @@ class CultMeshDatabase: def filtered(change: CultMeshDatabaseChange) -> None: if self._change_matches_extractor(change, extractor, value): - callback(change) + callback(self._change_as_document(change, requested)) return self.watch(filtered, document=document) @@ -914,6 +930,32 @@ class CultMeshDatabase: ) -> Any: return target.decode_payload(source.encode_payload(value)) + def _change_as_document( + self, + change: CultMeshDatabaseChange, + target: DocumentDefinition[Any], + ) -> CultMeshDatabaseChange: + if change.document.type == target.type: + return change + if not self._documents_alias(change.document, target): + return change + return CultMeshDatabaseChange( + document=target, + schema_id=target.catalog_entry().schema_id, + record_key=change.record_key, + change_kind=change.change_kind, + value=None if change.value is None else self._convert_document_value( + change.value, + change.document, + target, + ), + previous_value=None if change.previous_value is None else self._convert_document_value( + change.previous_value, + change.document, + target, + ), + ) + def _documents_alias( self, left: DocumentDefinition[Any], diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index ce0461e9..ccb90066 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2127,6 +2127,8 @@ class CultCacheTests(unittest.TestCase): node = create_node(runtime_id="python-node") node.register_document(document) seen: list[CultMeshDatabaseChange] = [] + all_seen: list[CultMeshDatabaseChange] = [] + node.database.watch(all_seen.append, document=alias) node.database.watch_record(alias, "note:alias", seen.append) node.put(alias, "note:alias", UiNote("from-alias-put")) @@ -2138,9 +2140,20 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(len(alias_collection), 1) self.assertIsInstance(alias_collection[0], UiNote) self.assertEqual(alias_collection[0].body, "from-alias-put") - self.assertEqual(seen[0].document.type, document.type) - self.assertIsInstance(seen[0].value, CanonicalNote) + self.assertEqual(seen[0].document.type, alias.type) + self.assertEqual(seen[0].schema_id, "mesh.alias_note.v1") + self.assertIsInstance(seen[0].value, UiNote) self.assertEqual(seen[0].value.body, "from-alias-put") + self.assertEqual(all_seen[0].document.type, alias.type) + self.assertIsInstance(all_seen[0].value, UiNote) + self.assertEqual(all_seen[0].value.body, "from-alias-put") + + node.put(document, "note:alias", CanonicalNote("from-canonical-put")) + self.assertEqual(seen[1].document.type, alias.type) + self.assertIsInstance(seen[1].value, UiNote) + self.assertIsInstance(seen[1].previous_value, UiNote) + self.assertEqual(seen[1].value.body, "from-canonical-put") + self.assertEqual(seen[1].previous_value.body, "from-alias-put") put = node.put_raw_message( alias, @@ -3147,6 +3160,60 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(sword.name, "Sword") self.assertIsNone(node.database.get_by_name(alias, "Missing")) + def test_cultmesh_database_alias_collection_watches_emit_alias_values(self) -> None: + @dataclass + class CanonicalItem: + name: str + kind: str + + @dataclass + class UiItem: + name: str + kind: str + + document = define_database_entry_type( + "mesh.alias_collection_watch_item", + [("name", 0), ("kind", 1)], + cls=CanonicalItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_watch_item.v1", + schema_name="mesh.alias_collection_watch_item", + schema_version="mesh.alias_collection_watch_item.v1", + ) + alias = define_database_entry_type( + "mesh.alias_collection_watch_item.ui", + [("name", 0), ("kind", 1)], + cls=UiItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_watch_item.v1", + schema_name="mesh.alias_collection_watch_item", + schema_version="mesh.alias_collection_watch_item.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-collection-watch") + node.database.register_document(document) + name_changes: list[CultMeshDatabaseChange] = [] + index_changes: list[CultMeshDatabaseChange] = [] + + node.database.watch_by_name(alias, "Potion", name_changes.append) + node.database.watch_by_index(alias, "kind", "consumable", index_changes.append) + + node.database.put(document, "item:potion", CanonicalItem("Potion", "consumable")) + node.database.put(document, "item:potion", CanonicalItem("Elixir", "rare")) + + self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"]) + self.assertTrue(all(change.document.type == alias.type for change in name_changes)) + self.assertIsInstance(name_changes[0].value, UiItem) + self.assertIsInstance(name_changes[1].value, UiItem) + self.assertIsInstance(name_changes[1].previous_value, UiItem) + self.assertEqual(name_changes[1].previous_value.name, "Potion") + self.assertEqual(name_changes[1].value.name, "Elixir") + self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"]) + self.assertTrue(all(change.document.type == alias.type for change in index_changes)) + self.assertIsInstance(index_changes[0].value, UiItem) + self.assertIsInstance(index_changes[1].previous_value, UiItem) + def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None: document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)]) node = CultMesh.create_node(runtime_id="mesh-watch-validation") From 672f59dba0fbf9de78f04068572a6ecd2db7f043 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:53:45 +0100 Subject: [PATCH 084/498] Prefer direct alias remote snapshot handles --- src/GameCult.Mesh/docs/public-api.md | 23 ++++++++++--------- .../CultMeshStreamingTests.cs | 10 ++++---- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 3ff86524..512b3e30 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -287,19 +287,20 @@ id, response filtering, raw MessagePack payload decode, route metadata, and polling watch fallback: ```csharp -var remoteHealth = CultMesh.DocumentFromPeerSnapshot( +var remoteHealth = CultMesh.DocumentFromPeerSnapshot( "cultnet://daemon.local:3075", "daemon:aetheria.health.v1", verse); -var health = await remoteHealth.LatestAsync(); -var uiHealth = remoteHealth.AsSchemaAlias(); +var uiHealth = await remoteHealth.LatestAsync(); ``` Snapshot requests are record-key-first so same logical documents can still be read when another runtime generated a different schema id for a compatible schema alias. The decode path prefers exact schema id matches when present and -falls back to the requested record key. +falls back to the requested record key. Ask for the runtime-facing alias type +directly; `AsSchemaAlias()` is for adapting handles that were already +constructed elsewhere. A domain facade can still collect existing handles into one schema-aware catalog when it is composing pre-built surfaces: @@ -368,7 +369,7 @@ const stopRoster = pilots.watchChanges(change => refreshRoster(change)); const remoteHealth = CultMesh.documentFromPeerSnapshot( () => daemonPeer, - "gamecult.aetheria.daemon_health.v1", + daemonHealthUiDocument, "daemon:aetheria.health.v1", ); const health = await remoteHealth.latest(); @@ -503,12 +504,12 @@ fragmentation, and channel semantics. TypeScript runtimes can also wrap a CultNet peer snapshot as a normal document handle with `CultMesh.documentFromPeerSnapshot(...)`. The caller supplies the -peer or peer factory, schema id, and record key; CultMesh sends the snapshot -request, waits for the matching response, decodes the raw MessagePack document, -and returns the same `latest()`/`watch()` document surface used by local cache -and single-file documents. Exact schema ids are preferred, but record-key -publications remain readable when two runtimes alias the same logical document -through different schema names. +peer or peer factory, document definition or schema id, and record key; CultMesh +sends the snapshot request, waits for the matching response, decodes the raw +MessagePack document, and returns the same `latest()`/`watch()` document surface +used by local cache and single-file documents. Exact schema ids are preferred, +but record-key publications remain readable when two runtimes alias the same +logical document through different schema names. ### Shard Authority diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index bfdd3f0e..5659d17a 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2203,7 +2203,7 @@ public sealed class CultMeshStreamingTests .Register(CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared)); var requests = new List(); - var handle = CultMesh.DocumentFromPublication( + var handle = CultMesh.DocumentFromPublication( CultMeshDocumentPublicationSource.PeerSnapshot( () => new MeshSnapshotSchemaClient(request => { @@ -2218,13 +2218,13 @@ public sealed class CultMeshStreamingTests PollInterval = TimeSpan.FromMilliseconds(10), MessageIdPrefix = "mesh-test-snapshot" }); - var alias = handle.AsSchemaAlias(); var catalog = CultMesh.Documents(handle); - var snapshot = await alias.LatestAsync(); + var snapshot = await handle.LatestAsync(); snapshot.Text.Should().Be("remote-snapshot"); snapshot.Revision.Should().Be(9); + handle.DocumentType.Should().Be(typeof(MeshNoteAliasDocument)); handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be(key.Value); requests.Should().ContainSingle(); @@ -2233,7 +2233,7 @@ public sealed class CultMeshStreamingTests requests[0].RecordKeys.Should().ContainSingle().Which.Should().Be(key.Value); (await catalog.LatestAsync()).Text.Should().Be("remote-snapshot"); - var aliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( + var aliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( _ => Task.FromResult(new CultNetSnapshotResponseRawMessage { MessageId = "alias-schema", @@ -2261,7 +2261,7 @@ public sealed class CultMeshStreamingTests (await aliasSchemaHandle.LatestAsync()).Text.Should().Be("record-key-fallback"); - var mixedAliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( + var mixedAliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( _ => Task.FromResult(new CultNetSnapshotResponseRawMessage { MessageId = "alias-schema-with-neighbor", From 854165e94394966063da96d7cef9e2b1ada11df1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:56:17 +0100 Subject: [PATCH 085/498] Add Python one-call document snapshot sync --- packages/cultcache-py/src/cultmesh_py/node.py | 40 +++++++++++ packages/cultcache-py/tests/test_cultcache.py | 71 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index 3f8d235e..53e175c7 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -180,6 +180,23 @@ class CultMeshNode: shard_epoch=shard_epoch, ) + def sync_document( + self, + client: CultNetRawClient, + document: DocumentDefinition[Any], + key: str, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> Any: + return self.database.sync_document( + client, + document, + key, + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + def sync_shard_log( self, client: CultNetRawClient, @@ -630,6 +647,29 @@ class CultMeshDatabase: ) return self.apply_snapshot_response(response) + def sync_document( + self, + client: CultNetRawClient, + document: DocumentDefinition[Any], + key: str, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> Any: + if not key: + raise ValueError("key must be non-empty") + requested = document + registered = self._resolve_document_alias(document) + schema_id = registered.catalog_entry().schema_id + self.sync_snapshot( + client, + schema_ids=[schema_id], + record_keys=[key], + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + return self.get_required(requested, key) + def sync_shard_log( self, client: CultNetRawClient, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index ccb90066..c9648f8d 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2054,6 +2054,77 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(log_changes[0].change_kind, "updated") self.assertEqual(node.get_required(document, "item:node").value, 9) + def test_cultmesh_node_sync_document_returns_requested_alias_type(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.sync_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.sync_alias_note.v1", + schema_name="mesh.sync_alias_note", + schema_version="mesh.sync_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.sync_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.sync_alias_note.v1", + schema_name="mesh.sync_alias_note", + schema_version="mesh.sync_alias_note.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + requests: list[dict[str, Any]] = [] + + class SnapshotClient: + def fetch_snapshot_response( + self, + *, + schema_ids: list[str] | None = None, + record_keys: list[str] | None = None, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> dict[str, Any]: + requests.append({ + "schema_ids": schema_ids, + "record_keys": record_keys, + "shard_id": shard_id, + "shard_epoch": shard_epoch, + }) + return { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "sync-document", + "documents": [ + { + "schemaId": document.catalog_entry().schema_id, + "recordKey": "note:remote", + "storedAt": "2026-06-27T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(CanonicalNote("synced-alias")), + } + ], + } + + synced = node.sync_document(SnapshotClient(), alias, "note:remote") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "synced-alias") + self.assertEqual(node.get_required(document, "note:remote").body, "synced-alias") + self.assertIsInstance(node.get_required(alias, "note:remote"), UiNote) + self.assertEqual(requests, [{ + "schema_ids": ["mesh.sync_alias_note.v1"], + "record_keys": ["note:remote"], + "shard_id": None, + "shard_epoch": None, + }]) + def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: document = define_database_entry_type( "mesh.emit_item", From 1885e2244aa6d6bdd9a32a858e30eab747871fbf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 22:58:50 +0100 Subject: [PATCH 086/498] Expose Python facade document sync --- packages/cultcache-py/README.md | 8 ++++++- .../cultcache-py/src/cultmesh_py/facade.py | 19 +++++++++++++++ packages/cultcache-py/tests/test_cultcache.py | 23 ++++++++++++++----- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index cd1427d6..b23cbb2e 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -383,7 +383,13 @@ client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica" client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) raw_client = CultNetRawClient("127.0.0.1", 3075) -node.database.sync_snapshot(raw_client, schema_ids=[note_doc.catalog_entry().schema_id]) +synced_note = node.database.sync_document(raw_client, note_doc, "note:remote") +synced_stock = CultMesh.sync_document( + node, + raw_client, + station_stock_ui_doc, + "station:starbridge:stock", +) node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1) replicator = CultNetShardReplicator( node.database, diff --git a/packages/cultcache-py/src/cultmesh_py/facade.py b/packages/cultcache-py/src/cultmesh_py/facade.py index a7900a44..42ba8859 100644 --- a/packages/cultcache-py/src/cultmesh_py/facade.py +++ b/packages/cultcache-py/src/cultmesh_py/facade.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any from urllib.parse import urlparse +from cultcache_py.documents import DocumentDefinition from cultnet_py import ( CultNetRawClient, CultNetReconnectPolicy, @@ -273,6 +274,24 @@ class CultMesh: def create_simulation_fact_committer(node: CultMeshNode) -> CultMeshSimulationFactCommitter: return CultMeshSimulationFactCommitter(node) + @staticmethod + def sync_document( + node: CultMeshNode, + client: CultNetRawClient, + document: DocumentDefinition[Any], + key: str, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> Any: + return node.sync_document( + client, + document, + key, + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + @staticmethod def create_game_session( node: CultMeshNode, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index c9648f8d..d1c9c568 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2113,17 +2113,28 @@ class CultCacheTests(unittest.TestCase): } synced = node.sync_document(SnapshotClient(), alias, "note:remote") + facade_synced = CultMesh.sync_document(node, SnapshotClient(), alias, "note:remote") self.assertIsInstance(synced, UiNote) self.assertEqual(synced.body, "synced-alias") + self.assertIsInstance(facade_synced, UiNote) + self.assertEqual(facade_synced.body, "synced-alias") self.assertEqual(node.get_required(document, "note:remote").body, "synced-alias") self.assertIsInstance(node.get_required(alias, "note:remote"), UiNote) - self.assertEqual(requests, [{ - "schema_ids": ["mesh.sync_alias_note.v1"], - "record_keys": ["note:remote"], - "shard_id": None, - "shard_epoch": None, - }]) + self.assertEqual(requests, [ + { + "schema_ids": ["mesh.sync_alias_note.v1"], + "record_keys": ["note:remote"], + "shard_id": None, + "shard_epoch": None, + }, + { + "schema_ids": ["mesh.sync_alias_note.v1"], + "record_keys": ["note:remote"], + "shard_id": None, + "shard_epoch": None, + }, + ]) def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: document = define_database_entry_type( From bd98bfe979eaec944e5fe5e87c9e6f68ca8dfec1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:03:30 +0100 Subject: [PATCH 087/498] Add Python fanout document sync helper --- packages/cultcache-py/README.md | 7 +++ .../cultcache-py/src/cultmesh_py/client.py | 36 ++++++++++++ packages/cultcache-py/tests/test_cultcache.py | 56 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index b23cbb2e..45826c4e 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -381,6 +381,13 @@ verses = client.fetch_verses(transport_version="cultmesh.v0") mesh_peers = client.fetch_peers(verse_id="python-interop", roles=["read-replica"]) client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) +replica_notes = client.sync_documents( + node.database, + peers, + verse_id="python-interop", + roles=["read-replica"], + documents=[(note_doc, "note:remote")], +) raw_client = CultNetRawClient("127.0.0.1", 3075) synced_note = node.database.sync_document(raw_client, note_doc, "note:remote") diff --git a/packages/cultcache-py/src/cultmesh_py/client.py b/packages/cultcache-py/src/cultmesh_py/client.py index 89a9b442..c549354e 100644 --- a/packages/cultcache-py/src/cultmesh_py/client.py +++ b/packages/cultcache-py/src/cultmesh_py/client.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime from typing import Any, Callable from urllib.parse import urlparse +from cultcache_py.documents import DocumentDefinition from cultnet_py import CultNetRawClient, CultNetRawSnapshotResponse, hello from .wire import ( @@ -279,6 +280,41 @@ class CultMeshDiscoveryClient: applied.extend(database.apply_snapshot_response(response)) return applied + def sync_documents( + self, + database: Any, + catalog: CultMeshPeerCatalog, + *, + verse_id: str, + documents: list[tuple[DocumentDefinition[Any], str]], + roles: list[str] | None = None, + shard_id: str | None = None, + shard_epoch: int | None = None, + on_error: Callable[[str, Exception], None] | None = None, + ) -> list[Any]: + if not documents: + return [] + schema_ids = _distinct_non_empty([ + document.catalog_entry().schema_id + for document, _key in documents + ]) + record_keys = _distinct_non_empty([key for _document, key in documents]) + self.sync_snapshots( + database, + catalog, + verse_id=verse_id, + roles=roles, + schema_ids=schema_ids, + record_keys=record_keys, + shard_id=shard_id, + shard_epoch=shard_epoch, + on_error=on_error, + ) + return [ + database.get_required(document, key) + for document, key in documents + ] + def _fanout_endpoints( self, catalog: CultMeshPeerCatalog, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index d1c9c568..d163d0c2 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -4420,6 +4420,62 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(target.database.get_required(document, "note:a")["body"], "from-a") self.assertEqual(target.database.get_required(document, "note:b")["body"], "from-b") + def test_cultmesh_discovery_client_sync_documents_returns_requested_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.snapshot_fanout_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.snapshot_fanout_alias_note.v1", + schema_name="mesh.snapshot_fanout_alias_note", + schema_version="mesh.snapshot_fanout_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.snapshot_fanout_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.snapshot_fanout_alias_note.v1", + schema_name="mesh.snapshot_fanout_alias_note", + schema_version="mesh.snapshot_fanout_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:alias", CanonicalNote("from-source")) + target = CultMesh.create_node(runtime_id="snapshot-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-alias-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + + values = CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0).sync_documents( + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + documents=[(alias, "note:alias")], + ) + finally: + server.stop() + + self.assertEqual(len(values), 1) + self.assertIsInstance(values[0], UiNote) + self.assertEqual(values[0].body, "from-source") + self.assertEqual(target.database.get_required(document, "note:alias").body, "from-source") + self.assertIsInstance(target.database.get_required(alias, "note:alias"), UiNote) + def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None: document = define_database_entry_type( "mesh.snapshot_loop_note", From c1ceb860a0eb8dd511f78ef93d1005330f06d1dd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:09:16 +0100 Subject: [PATCH 088/498] Add typed Python snapshot fanout coverage --- packages/cultcache-py/README.md | 11 ++++ .../cultcache-py/src/cultmesh_py/client.py | 17 ++++++ packages/cultcache-py/tests/test_cultcache.py | 61 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 45826c4e..b4dcbf98 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -267,6 +267,7 @@ from cultmesh_py import ( CultMeshGameSessionOptions, CultMeshHmacAuthorityLeaseVerifier, CultMeshNodeOptions, + CultMeshSnapshotFanout, peer_exchange_request, ) @@ -388,6 +389,16 @@ replica_notes = client.sync_documents( roles=["read-replica"], documents=[(note_doc, "note:remote")], ) +snapshot_fanout = CultMeshSnapshotFanout( + client, + node.database, + peers, + verse_id="python-interop", + roles=["read-replica"], + documents=[(note_doc, "note:remote")], + on_document=lambda note: print(note), +) +snapshot_fanout.sync_once() raw_client = CultNetRawClient("127.0.0.1", 3075) synced_note = node.database.sync_document(raw_client, note_doc, "note:remote") diff --git a/packages/cultcache-py/src/cultmesh_py/client.py b/packages/cultcache-py/src/cultmesh_py/client.py index c549354e..e4e9ad6d 100644 --- a/packages/cultcache-py/src/cultmesh_py/client.py +++ b/packages/cultcache-py/src/cultmesh_py/client.py @@ -473,17 +473,34 @@ class CultMeshSnapshotFanout: peer_catalog: CultMeshPeerCatalog verse_id: str roles: list[str] | None = None + documents: list[tuple[DocumentDefinition[Any], str]] | None = None schema_ids: list[str] | None = None record_keys: list[str] | None = None shard_id: str | None = None shard_epoch: int | None = None interval_seconds: float = 1.0 on_applied: Callable[[Any], None] | None = None + on_document: Callable[[Any], None] | None = None on_error: Callable[[str, Exception], None] | None = None _stop: threading.Event = field(default_factory=threading.Event, init=False, repr=False) _thread: threading.Thread | None = field(default=None, init=False, repr=False) def sync_once(self) -> list[Any]: + if self.documents is not None: + values = self.client.sync_documents( + self.database, + self.peer_catalog, + verse_id=self.verse_id, + roles=self.roles, + documents=self.documents, + shard_id=self.shard_id, + shard_epoch=self.shard_epoch, + on_error=self.on_error, + ) + for value in values: + if self.on_document is not None: + self.on_document(value) + return values applied = self.client.sync_snapshots( self.database, self.peer_catalog, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index d163d0c2..1630d05f 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -4515,6 +4515,67 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(seen, ["note:sync-once"]) self.assertEqual(target.database.get_required(document, "note:sync-once")["body"], "from-source") + def test_cultmesh_snapshot_fanout_syncs_typed_documents_and_reports_alias_values(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.snapshot_loop_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.snapshot_loop_alias_note.v1", + schema_name="mesh.snapshot_loop_alias_note", + schema_version="mesh.snapshot_loop_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.snapshot_loop_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.snapshot_loop_alias_note.v1", + schema_name="mesh.snapshot_loop_alias_note", + schema_version="mesh.snapshot_loop_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-loop-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:typed-loop", CanonicalNote("from-source")) + target = CultMesh.create_node(runtime_id="snapshot-loop-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-loop-alias-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[UiNote] = [] + fanout = CultMeshSnapshotFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + documents=[(alias, "note:typed-loop")], + on_document=seen.append, + ) + + values = fanout.sync_once() + finally: + server.stop() + + self.assertEqual(len(values), 1) + self.assertIsInstance(values[0], UiNote) + self.assertEqual(values[0].body, "from-source") + self.assertEqual(seen, [values[0]]) + self.assertEqual(target.database.get_required(document, "note:typed-loop").body, "from-source") + self.assertIsInstance(target.database.get_required(alias, "note:typed-loop"), UiNote) + def test_cultmesh_snapshot_fanout_runs_background_loop(self) -> None: document = define_database_entry_type( "mesh.snapshot_background_note", From 743979eca78b7515ccae7cd2bc151cb8ab645a95 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:14:18 +0100 Subject: [PATCH 089/498] Add TS typed peer snapshot node sync --- packages/cultmesh-ts/README.md | 20 ++++++ packages/cultmesh-ts/src/index.ts | 53 ++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 84 ++++++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index 5ffc1033..714ac2fa 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -316,6 +316,18 @@ const health = CultMesh.documentFromPeerSnapshot( ); const latestHealth = await health.latest(); + +const syncedHealth = await station.syncDocumentFromPeerSnapshot( + () => client, + daemonHealthUiDocument, + "daemon:aetheria.health.v1", + { + timeoutMs: 5_000, + }, +); +const localHealth = await station + .document(daemonHealthUiDocument, "daemon:aetheria.health.v1") + .latest(); ``` `CultMesh.createRudpPeerForPeer(...)` remains available for already trusted @@ -336,6 +348,14 @@ still read the publication with one CultMesh call. Pass a CultCache document definition instead of a raw schema id when you want a typed handle whose `latest()` result is parsed through that definition. +Use `node.syncDocumentFromPeerSnapshot(...)` or +`CultMesh.syncDocumentFromPeerSnapshot(...)` when a runtime should hydrate its +local node from the remote snapshot. The helper requests the raw snapshot, +applies it through the node's document registry, and returns the requested +typed definition. Same-schema aliases stay local after the call: the caller can +keep using `node.document(aliasDefinition, key)` or `node.reactiveDocument(...)` +without re-threading the RUDP peer. + The same typed-definition overload is available for `CultMesh.documentFromStore(...)` and `CultMesh.documentFromSingleFile(...)`, so local schema publications can be read as typed reactive documents without diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 0081ec32..ab1b4779 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -3815,6 +3815,46 @@ export class CultMeshNode { .reactive & object>(reactiveOptions); } + public async syncDocumentFromPeerSnapshot( + peer: CultNetPeer | (() => CultNetPeer | Promise), + definition: TDefinition, + key: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise> { + const registered = this.resolveCacheDefinition(definition); + const schema = cultMeshSchemaFromDefinition(definition); + const schemaId = schema.schemaId ?? schema.type; + if (!schemaId) { + throw new Error("CultMesh synced document requires a schema id or document type."); + } + const document = await requestCultNetRawSnapshotDocument( + peer, + schemaId, + key, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix ?? key, + accepts: record => { + try { + definition.schema.parse(decodeCultNetRawDocumentPayload(record)); + return true; + } catch { + return false; + } + }, + }, + ); + await this.documents.applyRawDocumentPutMessage(this.cache, { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: options.messageIdPrefix ?? `sync:${key}`, + document, + }); + return this.parseDocumentValue(definition, this.cache.getRequired(registered, key)); + } + public globalDocument( definition: TDefinition, options: { @@ -4792,6 +4832,19 @@ export class CultMesh { return node.reactiveDocument(definition, key, options); } + public static syncDocumentFromPeerSnapshot( + node: CultMeshNode, + peer: CultNetPeer | (() => CultNetPeer | Promise), + definition: TDefinition, + key: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise> { + return node.syncDocumentFromPeerSnapshot(peer, definition, key, options); + } + public static documents( ...documents: readonly CultMeshDocumentHandle[] ): CultMeshDocumentCatalog { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 993ef62d..0a859861 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -2530,6 +2530,90 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async ( } }); +test("CultMesh TS syncs remote RUDP snapshots into a local node with same-schema aliases", async () => { + const connectionId = 0x1020304a; + const source = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-sync-source-")), "node.ccmp"), + { + documents: [noteDocument], + }, + ); + await source.put(noteDocument, "note:sync-remote", { + noteId: "note:sync-remote", + body: "synced once, read as local", + }); + const target = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-sync-target-")), "node.ccmp"), + { + documents: [noteDocument], + }, + ); + + const server = CultMesh.createRudpDocumentServer( + "cultmesh-ts-rudp-sync-server", + connectionId, + { + documents: new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: noteDocument }), + ]), + getCache: () => source.cache, + bindHost: "127.0.0.1", + bindPort: 0, + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + }, + ); + + let peer: CultNetPeer | undefined; + try { + await server.start(); + peer = await CultMesh.createRudpPeer( + "cultmesh-ts-rudp-sync-client", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + }, + ); + + const synced = await target.syncDocumentFromPeerSnapshot( + peer, + noteAliasDocument, + "note:sync-remote", + { + timeoutMs: 1_000, + }, + ); + const facadeSynced = await CultMesh.syncDocumentFromPeerSnapshot( + target, + peer, + noteAliasDocument, + "note:sync-remote", + { + timeoutMs: 1_000, + }, + ); + + assert.deepEqual(synced, { + noteId: "note:sync-remote", + body: "synced once, read as local", + }); + assert.deepEqual(facadeSynced, synced); + assert.equal(target.getRequired(noteDocument, "note:sync-remote").body, "synced once, read as local"); + assert.equal(target.getRequired(noteAliasDocument, "note:sync-remote").body, "synced once, read as local"); + assert.equal((await target.document(noteAliasDocument, "note:sync-remote").latest()).body, "synced once, read as local"); + } finally { + peer?.close(); + server.close(); + } +}); + test("CultMesh TS peer snapshot handles choose compatible foreign schema payloads", async () => { const connectionId = 0x10203049; const node = await CultMesh.startNode( From 76a38ec36e6ed64d1b45a917053ae8ba41c86e7d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:18:48 +0100 Subject: [PATCH 090/498] Add C# typed peer snapshot node sync --- src/GameCult.Mesh/CultMeshSnapshots.cs | 74 +++++++++++++++++++ src/GameCult.Mesh/README.md | 15 ++++ .../CultMeshStreamingTests.cs | 54 ++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 82828fbd..5b01c8b2 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -510,6 +510,80 @@ namespace GameCult.Mesh return new CultMeshSnapshotDocumentBinding(recordKey, documentId); } + /// + /// Fetches one typed snapshot document and syncs it into a local node in one call. + /// + public static Task SyncDocumentFromPeerSnapshotAsync( + CultMeshNode node, + string endpoint, + string recordKey, + CultMeshSnapshotEndpointOptions? options = null, + bool flush = false) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + + return SnapshotEndpoint(endpoint, options).SyncDocumentAsync(node, recordKey, flush); + } + + /// + /// Fetches one typed snapshot document through a caller-provided schema client and syncs it into a local node in one call. + /// + public static Task SyncDocumentFromPeerSnapshotAsync( + CultMeshNode node, + Func createClient, + string endpoint, + string recordKey, + CultMeshSnapshotEndpointOptions? options = null, + bool flush = false) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (createClient == null) throw new ArgumentNullException(nameof(createClient)); + if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + + var request = CloneSnapshotFacadeRequestOptions(options?.Request); + request.CreateClient = createClient; + var resolvedOptions = new CultMeshSnapshotEndpointOptions + { + Context = options?.Context, + DocumentRegistry = options?.DocumentRegistry, + Request = request, + SourceId = options?.SourceId, + RouteHint = options?.RouteHint, + PollInterval = options?.PollInterval ?? TimeSpan.FromMilliseconds(250) + }; + return SnapshotEndpoint(endpoint, resolvedOptions).SyncDocumentAsync(node, recordKey, flush); + } + + private static CultMeshSnapshotRequestOptions CloneSnapshotFacadeRequestOptions(CultMeshSnapshotRequestOptions? source) + { + if (source == null) + return new CultMeshSnapshotRequestOptions(); + + return new CultMeshSnapshotRequestOptions + { + SchemaIds = source.SchemaIds, + RecordKeys = source.RecordKeys, + ShardId = source.ShardId, + ShardEpoch = source.ShardEpoch, + ResponseTimeout = source.ResponseTimeout, + ConnectTimeout = source.ConnectTimeout, + MessageIdPrefix = source.MessageIdPrefix, + Security = source.Security, + ConfigureClient = source.ConfigureClient, + CreateClient = source.CreateClient, + RudpRuntimeId = source.RudpRuntimeId, + RudpConnectionId = source.RudpConnectionId, + RudpConnectPayload = source.RudpConnectPayload, + RudpMaxFragmentBytes = source.RudpMaxFragmentBytes, + RudpResendDelayMs = source.RudpResendDelayMs + }; + } + /// /// Fetches one scoped raw CultNet snapshot from an endpoint. /// diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index 20ae25c2..4689136a 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -186,6 +186,21 @@ Use `DocumentFromPublication()` when the caller should provide a configured source instead of branching between local files, backing stores, and remote CultNet snapshots itself. +When the remote snapshot should hydrate a local node, use the direct sync +facade and keep the rest of the code on local typed handles: + +```csharp +var syncedHealth = await CultMesh.SyncDocumentFromPeerSnapshotAsync( + node, + "cultnet://daemon.local:3075", + "daemon:aetheria.health.v1", + new CultMeshSnapshotEndpointOptions { DocumentRegistry = daemonRegistry }); + +var localHealth = await node + .Document("daemon:aetheria.health.v1", verse) + .LatestAsync(); +``` + ## Game Session `CultMeshGameSession` wires the common gameplay loop: local catalogs, diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 5659d17a..4f6114ea 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2480,6 +2480,60 @@ public sealed class CultMeshStreamingTests node.Cache.Get(key)!.Revision.Should().Be(45); } + [Test] + public async Task SnapshotFacade_SyncsRemoteDocumentIntoLocalNodeWithSameSchemaAlias() + { + var sourceCache = new CultCache(); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); + registry.Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); + var key = new CultRecordKey("mesh-note:facade-sync-alias"); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "facade-synced", + Revision = 46 + }, new CultRecordHandle(key)); + var requests = new List(); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(Path.GetTempPath(), $"cultmesh-facade-sync-alias-{Guid.NewGuid():N}.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = sourceCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + + var synced = await CultMesh.SyncDocumentFromPeerSnapshotAsync( + node, + () => new MeshSnapshotSchemaClient(request => + { + requests.Add(request); + return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request); + }), + "cultnet://facade-sync.test:3075", + key.Value, + new CultMeshSnapshotEndpointOptions + { + DocumentRegistry = registry, + Context = CultMesh.Verse("starbridge", "unity-raven").Context + }); + var localAlias = await node.Document(key.Value, CultMesh.Verse("starbridge", "unity-raven")) + .LatestAsync(); + + synced.Text.Should().Be("facade-synced"); + synced.Revision.Should().Be(46); + localAlias.Text.Should().Be("facade-synced"); + node.Cache.Get(key)!.Revision.Should().Be(46); + requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value); + } + [Test] public async Task DocumentRegistryHelpers_CreateTypedNetworkRegistriesForAliases() { From 719ea7950a84957a2e0e39f79dc9ccc89009ceac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:23:50 +0100 Subject: [PATCH 091/498] Add Python typed document subscriptions --- packages/cultcache-py/README.md | 8 ++ .../cultcache-py/src/cultmesh_py/__init__.py | 2 + .../cultcache-py/src/cultmesh_py/client.py | 104 +++++++++++++++++- .../cultcache-py/src/cultmesh_py/facade.py | 23 +++- packages/cultcache-py/tests/test_cultcache.py | 65 +++++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index b4dcbf98..48c023ba 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -408,6 +408,14 @@ synced_stock = CultMesh.sync_document( station_stock_ui_doc, "station:starbridge:stock", ) +with CultMesh.subscribe_document( + node, + raw_client, + station_stock_ui_doc, + "station:starbridge:stock", +) as stock_subscription: + stock = stock_subscription.sync_initial() + next_change = stock_subscription.read_next_change() node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1) replicator = CultNetShardReplicator( node.database, diff --git a/packages/cultcache-py/src/cultmesh_py/__init__.py b/packages/cultcache-py/src/cultmesh_py/__init__.py index 72f649ba..faf39479 100644 --- a/packages/cultcache-py/src/cultmesh_py/__init__.py +++ b/packages/cultcache-py/src/cultmesh_py/__init__.py @@ -1,5 +1,6 @@ from .client import ( CultMeshDiscoveryClient, + CultMeshDocumentSubscription, CultMeshPeerExchangeClient, CultMeshPeerHealth, CultMeshPeerHealthMonitor, @@ -72,6 +73,7 @@ __all__ = [ "CultMeshHmacAuthorityLeaseVerifier", "AuthorityLeaseVerifier", "CultMeshDiscoveryClient", + "CultMeshDocumentSubscription", "CultMeshPeerExchangeClient", "CultMeshPeerHealth", "CultMeshPeerHealthMonitor", diff --git a/packages/cultcache-py/src/cultmesh_py/client.py b/packages/cultcache-py/src/cultmesh_py/client.py index e4e9ad6d..e69e0403 100644 --- a/packages/cultcache-py/src/cultmesh_py/client.py +++ b/packages/cultcache-py/src/cultmesh_py/client.py @@ -7,7 +7,7 @@ from typing import Any, Callable from urllib.parse import urlparse from cultcache_py.documents import DocumentDefinition -from cultnet_py import CultNetRawClient, CultNetRawSnapshotResponse, hello +from cultnet_py import CultNetDatabaseSubscription, CultNetRawClient, CultNetRawSnapshotResponse, hello from .wire import ( PEER_EXCHANGE_RESPONSE, @@ -315,6 +315,26 @@ class CultMeshDiscoveryClient: for document, key in documents ] + def subscribe_document( + self, + database: Any, + document: DocumentDefinition[Any], + key: str, + *, + subscription_id: str | None = None, + message_id: str = "cultmesh-python-document-subscribe", + include_snapshot: bool = True, + ) -> "CultMeshDocumentSubscription": + return CultMeshDocumentSubscription( + client=CultNetRawClient(self.host, self.port, self.timeout_seconds), + database=database, + document=document, + key=key, + subscription_id=subscription_id, + message_id=message_id, + include_snapshot=include_snapshot, + ) + def _fanout_endpoints( self, catalog: CultMeshPeerCatalog, @@ -389,6 +409,88 @@ class CultMeshPeerExchangeClient(CultMeshDiscoveryClient): return self.fanout_peer_catalog(catalog, verse_id=verse_id, roles=roles, limit=limit) +@dataclass +class CultMeshDocumentSubscription: + client: CultNetRawClient + database: Any + document: DocumentDefinition[Any] + key: str + subscription_id: str | None = None + message_id: str = "cultmesh-python-document-subscribe" + include_snapshot: bool = True + _subscription: CultNetDatabaseSubscription | None = field(default=None, init=False, repr=False) + + def __enter__(self) -> "CultMeshDocumentSubscription": + if not self.key: + raise ValueError("key must be non-empty") + registered = self.database._resolve_document_alias(self.document) + self._subscription = self.client.subscribe_database( + subscription_id=self.subscription_id or f"{registered.type}:{self.key}", + message_id=self.message_id, + schema_ids=[registered.catalog_entry().schema_id], + record_keys=[self.key], + include_snapshot=self.include_snapshot, + ) + self._subscription.__enter__() + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + if self._subscription is not None: + self._subscription.__exit__(exc_type, exc, traceback) + self._subscription = None + + def sync_initial(self) -> Any: + self._require_subscription() + changes = self._capture_changes(lambda: self.database.apply_snapshot_response( + self._subscription.read_next_snapshot_response() # type: ignore[union-attr] + )) + if changes: + return changes[-1].value + return self.database.get_required(self.document, self.key) + + def send(self, message: Any) -> None: + self._require_subscription() + self._subscription.send(message) # type: ignore[union-attr] + + def read_next_change(self) -> Any: + self._require_subscription() + remote_change = self._subscription.read_next_change() # type: ignore[union-attr] + + def apply_remote_change() -> None: + if remote_change.raw_document is not None: + self.database.apply_raw_put_message({ + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": remote_change.message_id, + "document": remote_change.raw_document.to_wire(), + }) + return + if remote_change.change_kind == "removed": + self.database.apply_raw_delete_message({ + "schemaVersion": "cultnet.document_delete.v0", + "messageId": remote_change.message_id, + "schemaId": remote_change.schema_id, + "recordKey": remote_change.record_key, + }) + + changes = self._capture_changes(apply_remote_change) + if not changes: + raise RuntimeError("CultMesh document subscription change did not apply to the local database") + return changes[-1] + + def _capture_changes(self, action: Callable[[], Any]) -> list[Any]: + changes: list[Any] = [] + unsubscribe = self.database.watch_record(self.document, self.key, changes.append) + try: + action() + finally: + unsubscribe() + return changes + + def _require_subscription(self) -> None: + if self._subscription is None: + raise RuntimeError("CultMesh document subscription is not open") + + @dataclass class CultMeshSimulationObservationFanout: client: CultMeshDiscoveryClient diff --git a/packages/cultcache-py/src/cultmesh_py/facade.py b/packages/cultcache-py/src/cultmesh_py/facade.py index 42ba8859..849d8a7a 100644 --- a/packages/cultcache-py/src/cultmesh_py/facade.py +++ b/packages/cultcache-py/src/cultmesh_py/facade.py @@ -21,7 +21,7 @@ from cultnet_py import ( wire_message_schema_catalog, ) -from .client import CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient +from .client import CultMeshDocumentSubscription, CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient from .node import CultMeshNode, CultMeshNodeOptions, create_node from .server import CultMeshLocalServer from .session import CultMeshGameSession, CultMeshGameSessionOptions @@ -292,6 +292,27 @@ class CultMesh: shard_epoch=shard_epoch, ) + @staticmethod + def subscribe_document( + node: CultMeshNode, + client: CultNetRawClient, + document: DocumentDefinition[Any], + key: str, + *, + subscription_id: str | None = None, + message_id: str = "cultmesh-python-document-subscribe", + include_snapshot: bool = True, + ) -> CultMeshDocumentSubscription: + return CultMeshDocumentSubscription( + client=client, + database=node.database, + document=document, + key=key, + subscription_id=subscription_id, + message_id=message_id, + include_snapshot=include_snapshot, + ) + @staticmethod def create_game_session( node: CultMeshNode, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 1630d05f..fb8b2b2b 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -4476,6 +4476,71 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(target.database.get_required(document, "note:alias").body, "from-source") self.assertIsInstance(target.database.get_required(alias, "note:alias"), UiNote) + def test_cultmesh_document_subscription_syncs_alias_snapshot_and_changes(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.subscription_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.subscription_alias_note.v1", + schema_name="mesh.subscription_alias_note", + schema_version="mesh.subscription_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.subscription_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.subscription_alias_note.v1", + schema_name="mesh.subscription_alias_note", + schema_version="mesh.subscription_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="subscription-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:live", CanonicalNote("initial")) + target = CultMesh.create_node(runtime_id="subscription-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription: + initial = subscription.sync_initial() + subscription.send(document_put_raw( + message_id="subscription-alias-put", + key="note:live", + schema_id="mesh.subscription_alias_note.v1", + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload(CanonicalNote("updated")), + )) + updated = subscription.read_next_change() + subscription.send(document_delete( + message_id="subscription-alias-delete", + schema_id="mesh.subscription_alias_note.v1", + record_key="note:live", + )) + removed = subscription.read_next_change() + finally: + server.stop() + + self.assertIsInstance(initial, UiNote) + self.assertEqual(initial.body, "initial") + self.assertEqual(updated.change_kind, "updated") + self.assertIsInstance(updated.value, UiNote) + self.assertEqual(updated.value.body, "updated") + self.assertIsInstance(updated.previous_value, UiNote) + self.assertEqual(updated.previous_value.body, "initial") + self.assertEqual(removed.change_kind, "removed") + self.assertIsNone(removed.value) + self.assertIsInstance(removed.previous_value, UiNote) + self.assertEqual(removed.previous_value.body, "updated") + self.assertIsNone(target.database.get(alias, "note:live")) + def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None: document = define_database_entry_type( "mesh.snapshot_loop_note", From 7a335b1a1178137d1b8123e33351022b39b742bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:27:01 +0100 Subject: [PATCH 092/498] Add TS typed publication node sync --- packages/cultmesh-ts/README.md | 4 ++ packages/cultmesh-ts/src/index.ts | 56 ++++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 43 +++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index 714ac2fa..279e271e 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -364,6 +364,10 @@ When the caller wants source selection to be configuration, use `CultMesh.documentFromPublication(...)` with a `single-file`, `store`, or `peer-snapshot` source; it returns the same typed handle while hiding whether sync came from local persistence or a remote CultNet snapshot. +Use `node.syncDocumentFromPublication(...)` or +`CultMesh.syncDocumentFromPublication(...)` for the same source-agnostic shape +when the publication should hydrate a local node before the caller continues +through `node.document(...)` or `node.reactiveDocument(...)`. ## Streaming Mode diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index ab1b4779..d817370f 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -2756,6 +2756,37 @@ export function cultMeshDocumentFromPublication( } } +export async function cultMeshSyncDocumentFromPublication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + recordKey: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, +): Promise> { + if (!source) { + throw new Error("CultMesh publication source is required."); + } + requireNonEmpty(recordKey, "recordKey"); + + if (source.kind === "peer-snapshot") { + return node.syncDocumentFromPeerSnapshot(source.peer, definition, recordKey, options); + } + + const value = await cultMeshDocumentFromPublication( + source, + definition, + recordKey, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix, + }, + ).latest(); + return node.put(definition, recordKey, value); +} + export function cultMeshGlobalDocumentFromCache( cache: CultCache, definition: TDefinition, @@ -3855,6 +3886,18 @@ export class CultMeshNode { return this.parseDocumentValue(definition, this.cache.getRequired(registered, key)); } + public syncDocumentFromPublication( + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + key: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise> { + return cultMeshSyncDocumentFromPublication(this, source, definition, key, options); + } + public globalDocument( definition: TDefinition, options: { @@ -4845,6 +4888,19 @@ export class CultMesh { return node.syncDocumentFromPeerSnapshot(peer, definition, key, options); } + public static syncDocumentFromPublication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSource, + definition: TDefinition, + key: string, + options: { + timeoutMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise> { + return node.syncDocumentFromPublication(source, definition, key, options); + } + public static documents( ...documents: readonly CultMeshDocumentHandle[] ): CultMeshDocumentCatalog { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 0a859861..7bd1d8e0 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -679,6 +679,49 @@ test("CultMesh TS binds publication document catalogs from source resolvers", as assert.equal(catalog.document(noteDocument).routeHint.description, "publication catalog"); }); +test("CultMesh TS syncs configured publications into local node aliases", async () => { + const sourcePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-source-")), "source.ccmp"); + const targetPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-target-")), "target.ccmp"); + const source = await CultMesh.startNode(sourcePath, { + documents: [noteDocument], + }); + await source.put(noteDocument, "note:published", { + noteId: "note:published", + body: "publication source hydrates local alias", + }); + await source.flush(); + const target = await CultMesh.startNode(targetPath, { + documents: [noteDocument], + }); + + const synced = await target.syncDocumentFromPublication( + { + kind: "single-file", + path: sourcePath, + }, + noteAliasDocument, + "note:published", + ); + const facadeSynced = await CultMesh.syncDocumentFromPublication( + target, + { + kind: "single-file", + path: sourcePath, + }, + noteAliasDocument, + "note:published", + ); + + assert.deepEqual(synced, { + noteId: "note:published", + body: "publication source hydrates local alias", + }); + assert.deepEqual(facadeSynced, synced); + assert.equal(target.getRequired(noteDocument, "note:published").body, "publication source hydrates local alias"); + assert.equal(target.getRequired(noteAliasDocument, "note:published").body, "publication source hydrates local alias"); + assert.equal((await target.reactiveDocument(noteAliasDocument, "note:published", { watch: false }).ready).body, "publication source hydrates local alias"); +}); + test("CultMesh TS document catalogs resolve semantic schema versions passed as schema ids", async () => { const current = { noteId: "note:semantic", From abdfff6f4e82562d0c302896de0163822ec3d883 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:37:15 +0100 Subject: [PATCH 093/498] Add publication sync and reconciliation coverage --- packages/cultcache-py/tests/test_cultcache.py | 56 +++++++++++++ src/GameCult.Mesh/CultMesh.cs | 83 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 56 +++++++++++++ 3 files changed, 195 insertions(+) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index fb8b2b2b..bc27892f 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -3742,6 +3742,62 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(session.pending_predictions(), ()) self.assertEqual(session.resimulation_inputs(), ()) + def test_cultmesh_game_session_partial_reconciliation_keeps_unmatched_predictions(self) -> None: + note_doc = define_database_entry_type( + "mesh.partial_input", + [("body", 0)], + schema_id="mesh.partial_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-partial") + node.register_document(note_doc) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-partial", + schema_ids=("mesh.partial_input.v1",), + key_prefix="input:client-partial", + ), + ) + ), + ) + pending = session.predict(note_doc, "input:client-partial:pending", {"body": "pending-local"}) + accepted = session.predict(note_doc, "input:client-partial:accepted", {"body": "accepted-local"}) + put = document_put_raw( + message_id="authoritative-partial", + key=accepted.key, + schema_id=accepted.schema_id, + stored_at="2026-06-13T00:00:00Z", + payload=note_doc.encode_payload({"body": "accepted-authority"}), + shard_id="inputs", + shard_epoch=1, + ) + + changes = session.apply_shard_log_response({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-partial-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "updated", + "put": put.to_wire(), + } + ], + "resyncRequired": False, + }) + + self.assertEqual([(change.change_kind, change.record_key) for change in changes], [ + ("reconciled", accepted.key), + ]) + self.assertEqual(node.get_required(note_doc, accepted.key)["body"], "accepted-authority") + self.assertEqual(node.get_required(note_doc, pending.key)["body"], "pending-local") + self.assertEqual([prediction.key for prediction in session.pending_predictions()], [pending.key]) + self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [pending.key]) + def test_cultmesh_game_session_rolls_back_pending_predictions(self) -> None: note_doc = define_database_entry_type( "mesh.rollback_input", diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 29b47fb6..e06ced87 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -1544,6 +1544,89 @@ namespace GameCult.Mesh return DocumentFromPublication(source, key, verse.Context, storeOptions, peerOptions); } + /// + /// Reads one typed document from a configured publication source and hydrates it into a local node. + /// + public static async Task SyncDocumentFromPublicationAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + CultRecordKey key, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null, + bool flush = false) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (source == null) throw new ArgumentNullException(nameof(source)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var document = await DocumentFromPublication( + source, + key, + context, + storeOptions, + peerOptions) + .LatestAsync() + .ConfigureAwait(false); + + await Document(node, key, context) + .ReplaceAsync(document) + .ConfigureAwait(false); + + if (flush) + await node.FlushAsync().ConfigureAwait(false); + + return document; + } + + /// + /// Reads one typed document from a configured publication source and hydrates it into a local node. + /// + public static Task SyncDocumentFromPublicationAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + CultRecordKey key, + CultMeshVerse verse, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null, + bool flush = false) + where TDocument : class + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return SyncDocumentFromPublicationAsync( + node, + source, + key, + verse.Context, + storeOptions, + peerOptions, + flush); + } + + /// + /// Reads one typed document from a configured publication source and hydrates it into a local node. + /// + public static Task SyncDocumentFromPublicationAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + string recordKey, + CultMeshVerse verse, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null, + bool flush = false) + where TDocument : class + { + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + return SyncDocumentFromPublicationAsync( + node, + source, + new CultRecordKey(recordKey), + verse, + storeOptions, + peerOptions, + flush); + } /// /// Creates a typed document handle directly over one distributed CultNet database record. /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 4f6114ea..45fbf6d3 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2305,6 +2305,62 @@ public sealed class CultMeshStreamingTests (await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key"); } + [Test] + public async Task PublicationHelpers_SyncConfiguredPeerSourceIntoLocalNodeAlias() + { + var sourceCache = new CultCache(); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); + registry.Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); + var key = new CultRecordKey("mesh-note:publication-sync-alias"); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "publication-synced", + Revision = 51 + }, new CultRecordHandle(key)); + var requests = new List(); + var verse = CultMesh.Verse("starbridge", "unity-support"); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(Path.GetTempPath(), $"cultmesh-publication-sync-alias-{Guid.NewGuid():N}.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = sourceCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + + var synced = await CultMesh.SyncDocumentFromPublicationAsync( + node, + CultMeshDocumentPublicationSource.PeerSnapshot( + () => new MeshSnapshotSchemaClient(request => + { + requests.Add(request); + return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request); + }), + "cultnet://publication-sync.test:3075"), + key, + verse, + peerOptions: new CultMeshPeerSnapshotDocumentOptions + { + MessageIdPrefix = "mesh-test-publication-sync", + PollInterval = TimeSpan.FromMilliseconds(10) + }); + var localAlias = await node.Document(key, verse).LatestAsync(); + + synced.Text.Should().Be("publication-synced"); + synced.Revision.Should().Be(51); + localAlias.Text.Should().Be("publication-synced"); + localAlias.Revision.Should().Be(51); + requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value); + } + [Test] public async Task SnapshotHelpers_FetchApplyAndDecodeScopedSnapshots() { From 420e91f4f95808fa1db08f7a5276d3425131973c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:44:06 +0100 Subject: [PATCH 094/498] Add Python publication document sync --- .../cultcache-py/src/cultmesh_py/__init__.py | 10 +- .../cultcache-py/src/cultmesh_py/facade.py | 28 +++- packages/cultcache-py/src/cultmesh_py/node.py | 67 +++++++++ packages/cultcache-py/tests/test_cultcache.py | 130 ++++++++++++++++++ 4 files changed, 233 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-py/src/cultmesh_py/__init__.py b/packages/cultcache-py/src/cultmesh_py/__init__.py index faf39479..0667ab77 100644 --- a/packages/cultcache-py/src/cultmesh_py/__init__.py +++ b/packages/cultcache-py/src/cultmesh_py/__init__.py @@ -10,7 +10,14 @@ from .client import ( ) from .daemon import READY_SCHEMA_VERSION, daemon_ready_document from .facade import CultMesh, CultMeshRudpEndpoint -from .node import CultMeshDatabase, CultMeshDatabaseChange, CultMeshNode, CultMeshNodeOptions, create_node +from .node import ( + CultMeshDatabase, + CultMeshDatabaseChange, + CultMeshDocumentPublicationSource, + CultMeshNode, + CultMeshNodeOptions, + create_node, +) from .server import CultMeshLocalServer from .session import ( CultMeshGameSession, @@ -56,6 +63,7 @@ __all__ = [ "CultMeshNodeOptions", "CultMeshDatabase", "CultMeshDatabaseChange", + "CultMeshDocumentPublicationSource", "CultMesh", "CultMeshGameSession", "CultMeshGameSessionOptions", diff --git a/packages/cultcache-py/src/cultmesh_py/facade.py b/packages/cultcache-py/src/cultmesh_py/facade.py index 849d8a7a..5feab180 100644 --- a/packages/cultcache-py/src/cultmesh_py/facade.py +++ b/packages/cultcache-py/src/cultmesh_py/facade.py @@ -22,7 +22,7 @@ from cultnet_py import ( ) from .client import CultMeshDocumentSubscription, CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient -from .node import CultMeshNode, CultMeshNodeOptions, create_node +from .node import CultMeshDocumentPublicationSource, CultMeshNode, CultMeshNodeOptions, create_node from .server import CultMeshLocalServer from .session import CultMeshGameSession, CultMeshGameSessionOptions from .simulation import CultMeshSimulationFactCommitter @@ -292,6 +292,32 @@ class CultMesh: shard_epoch=shard_epoch, ) + @staticmethod + def publication_source_from_peer_snapshot( + client: CultNetRawClient, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> CultMeshDocumentPublicationSource: + return CultMeshDocumentPublicationSource.peer_snapshot( + client, + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + + @staticmethod + def publication_source_from_single_file(path: str | Path) -> CultMeshDocumentPublicationSource: + return CultMeshDocumentPublicationSource.single_file(path) + + @staticmethod + def sync_document_from_publication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSource, + document: DocumentDefinition[Any], + key: str, + ) -> Any: + return node.sync_document_from_publication(source, document, key) + @staticmethod def subscribe_document( node: CultMeshNode, diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index 53e175c7..59d49c9a 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -37,6 +37,33 @@ class CultMeshDatabaseChange: previous_value: Any | None = None +@dataclass(frozen=True) +class CultMeshDocumentPublicationSource: + kind: str + client: CultNetRawClient | None = None + path: Path | None = None + shard_id: str | None = None + shard_epoch: int | None = None + + @staticmethod + def peer_snapshot( + client: CultNetRawClient, + *, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> "CultMeshDocumentPublicationSource": + return CultMeshDocumentPublicationSource( + kind="peer_snapshot", + client=client, + shard_id=shard_id, + shard_epoch=shard_epoch, + ) + + @staticmethod + def single_file(path: str | Path) -> "CultMeshDocumentPublicationSource": + return CultMeshDocumentPublicationSource(kind="single_file", path=Path(path)) + + @dataclass class CultMeshNode: cache: CultCache = field(default_factory=CultCache) @@ -197,6 +224,14 @@ class CultMeshNode: shard_epoch=shard_epoch, ) + def sync_document_from_publication( + self, + source: CultMeshDocumentPublicationSource, + document: DocumentDefinition[Any], + key: str, + ) -> Any: + return self.database.sync_document_from_publication(source, document, key) + def sync_shard_log( self, client: CultNetRawClient, @@ -670,6 +705,38 @@ class CultMeshDatabase: ) return self.get_required(requested, key) + def sync_document_from_publication( + self, + source: CultMeshDocumentPublicationSource, + document: DocumentDefinition[Any], + key: str, + ) -> Any: + if not key: + raise ValueError("key must be non-empty") + if source.kind == "peer_snapshot": + if source.client is None: + raise ValueError("peer snapshot publication sources require a client") + return self.sync_document( + source.client, + document, + key, + shard_id=source.shard_id, + shard_epoch=source.shard_epoch, + ) + if source.kind == "single_file": + if source.path is None: + raise ValueError("single-file publication sources require a path") + registered = self._resolve_document_alias(document) + cache = CultCache() + cache.register_document_type(registered) + cache.add_generic_store(SingleFileMessagePackBackingStore(source.path)) + cache.pull_all_backing_stores() + value = cache.get_required(registered, key) + requested = self._convert_document_value(value, registered, document) + self.put(document, key, requested) + return self.get_required(document, key) + raise ValueError(f"Unsupported CultMesh document publication source: {source.kind!r}") + def sync_shard_log( self, client: CultNetRawClient, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index bc27892f..ecd839f2 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2136,6 +2136,136 @@ class CultCacheTests(unittest.TestCase): }, ]) + def test_cultmesh_node_sync_document_from_publication_returns_requested_alias_type(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.publication_alias_note.v1", + schema_name="mesh.publication_alias_note", + schema_version="mesh.publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.publication_alias_note.v1", + schema_name="mesh.publication_alias_note", + schema_version="mesh.publication_alias_note.v1", + ) + node = create_node(runtime_id="python-publication-node") + node.register_document(document) + requests: list[dict[str, Any]] = [] + + class SnapshotClient: + def fetch_snapshot_response( + self, + *, + schema_ids: list[str] | None = None, + record_keys: list[str] | None = None, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> dict[str, Any]: + requests.append({ + "schema_ids": schema_ids, + "record_keys": record_keys, + "shard_id": shard_id, + "shard_epoch": shard_epoch, + }) + return { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "publication-sync-document", + "documents": [ + { + "schemaId": document.catalog_entry().schema_id, + "recordKey": "note:published-peer", + "storedAt": "2026-06-27T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(CanonicalNote("peer-publication-alias")), + } + ], + } + + source = CultMesh.publication_source_from_peer_snapshot( + SnapshotClient(), + shard_id="notes", + shard_epoch=3, + ) + + synced = node.sync_document_from_publication(source, alias, "note:published-peer") + facade_synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-peer") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "peer-publication-alias") + self.assertIsInstance(facade_synced, UiNote) + self.assertEqual(facade_synced.body, "peer-publication-alias") + self.assertEqual(node.get_required(document, "note:published-peer").body, "peer-publication-alias") + self.assertEqual(requests, [ + { + "schema_ids": ["mesh.publication_alias_note.v1"], + "record_keys": ["note:published-peer"], + "shard_id": "notes", + "shard_epoch": 3, + }, + { + "schema_ids": ["mesh.publication_alias_note.v1"], + "record_keys": ["note:published-peer"], + "shard_id": "notes", + "shard_epoch": 3, + }, + ]) + + def test_cultmesh_node_sync_document_from_single_file_publication_uses_node_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.file_publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.file_publication_alias_note.v1", + schema_name="mesh.file_publication_alias_note", + schema_version="mesh.file_publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.file_publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.file_publication_alias_note.v1", + schema_name="mesh.file_publication_alias_note", + schema_version="mesh.file_publication_alias_note.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "publication.ccmp" + writer = CultCache() + writer.register_document_type(document) + writer.add_generic_store(SingleFileMessagePackBackingStore(path)) + writer.put(document, "note:published-file", CanonicalNote("file-publication-alias")) + + node = create_node(runtime_id="python-file-publication-node") + node.register_document(document) + source = CultMesh.publication_source_from_single_file(path) + + synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-file") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "file-publication-alias") + self.assertEqual(node.get_required(document, "note:published-file").body, "file-publication-alias") + self.assertIsInstance(node.get_required(alias, "note:published-file"), UiNote) + def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: document = define_database_entry_type( "mesh.emit_item", From 47a84f5689d8355bbe1ed0cddd7f591005502389 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:48:53 +0100 Subject: [PATCH 095/498] Add C# publication document catalogs --- src/GameCult.Mesh/CultMesh.cs | 169 ++++++++++++++++++ .../CultMeshStreamingTests.cs | 54 ++++++ 2 files changed, 223 insertions(+) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index e06ced87..38fb7954 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -285,6 +285,67 @@ namespace GameCult.Mesh } } + /// + /// Describes one typed document to bind from a CultMesh publication source. + /// + public interface ICultMeshPublicationDocumentBinding + { + /// Gets the publication source override for this document, when it differs from the catalog source. + CultMeshDocumentPublicationSource? Source { get; } + + /// Binds this document request to a publication-backed handle. + ICultMeshDocumentHandle Bind( + CultMeshDocumentPublicationSource source, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions, + CultMeshPeerSnapshotDocumentOptions? peerOptions); + } + + /// + /// Describes one typed document to bind from a CultMesh publication source. + /// + public sealed class CultMeshPublicationDocumentBinding : ICultMeshPublicationDocumentBinding + where TDocument : class + { + internal CultMeshPublicationDocumentBinding( + CultRecordKey key, + string? documentId, + string? sourceId, + CultMeshDocumentPublicationSource? source) + { + Key = key; + DocumentId = documentId; + SourceId = sourceId; + Source = source; + } + + /// Gets the record key to read. + public CultRecordKey Key { get; } + + /// Gets the semantic document id. Defaults to the record key. + public string? DocumentId { get; } + + /// Gets the source id advertised by diagnostics. Defaults to the document id or record key. + public string? SourceId { get; } + + /// + public CultMeshDocumentPublicationSource? Source { get; } + + /// + public ICultMeshDocumentHandle Bind( + CultMeshDocumentPublicationSource source, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions, + CultMeshPeerSnapshotDocumentOptions? peerOptions) + { + return CultMesh.DocumentFromPublication( + Source ?? source, + Key, + context, + CultMesh.WithPublicationBindingOptions(storeOptions, Key, DocumentId, SourceId), + CultMesh.WithPublicationBindingOptions(peerOptions, Key, DocumentId, SourceId)); + } + } /// /// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade. /// @@ -1544,6 +1605,67 @@ namespace GameCult.Mesh return DocumentFromPublication(source, key, verse.Context, storeOptions, peerOptions); } + /// + /// Describes one typed document to bind from a CultMesh publication source. + /// + public static CultMeshPublicationDocumentBinding PublicationDocument( + CultRecordKey key, + string? documentId = null, + string? sourceId = null, + CultMeshDocumentPublicationSource? source = null) + where TDocument : class + { + return new CultMeshPublicationDocumentBinding(key, documentId, sourceId, source); + } + + /// + /// Describes one typed document to bind from a CultMesh publication source. + /// + public static CultMeshPublicationDocumentBinding PublicationDocument( + string recordKey, + string? documentId = null, + string? sourceId = null, + CultMeshDocumentPublicationSource? source = null) + where TDocument : class + { + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + return PublicationDocument(new CultRecordKey(recordKey), documentId, sourceId, source); + } + + /// + /// Creates a schema-aware document catalog from one or more configured publication bindings. + /// + public static CultMeshDocumentCatalog DocumentsFromPublication( + CultMeshDocumentPublicationSource source, + IEnumerable bindings, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (bindings == null) throw new ArgumentNullException(nameof(bindings)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + return Documents(bindings.Select(binding => + { + if (binding == null) throw new ArgumentException("CultMesh publication bindings cannot contain null.", nameof(bindings)); + return binding.Bind(source, context, storeOptions, peerOptions); + }).ToArray()); + } + + /// + /// Creates a schema-aware document catalog from one or more configured publication bindings. + /// + public static CultMeshDocumentCatalog DocumentsFromPublication( + CultMeshDocumentPublicationSource source, + IEnumerable bindings, + CultMeshVerse verse, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return DocumentsFromPublication(source, bindings, verse.Context, storeOptions, peerOptions); + } /// /// Reads one typed document from a configured publication source and hydrates it into a local node. /// @@ -1627,6 +1749,53 @@ namespace GameCult.Mesh peerOptions, flush); } + internal static CultMeshStoreDocumentOptions WithPublicationBindingOptions( + CultMeshStoreDocumentOptions? options, + CultRecordKey key, + string? documentId, + string? sourceId) + { + return new CultMeshStoreDocumentOptions + { + DocumentId = string.IsNullOrWhiteSpace(documentId) ? key.Value : documentId, + SourceId = string.IsNullOrWhiteSpace(sourceId) + ? (string.IsNullOrWhiteSpace(documentId) ? key.Value : documentId) + : sourceId, + RouteHint = options?.RouteHint, + PollInterval = options?.PollInterval ?? TimeSpan.FromMilliseconds(250), + Registry = options?.Registry + }; + } + + internal static CultMeshPeerSnapshotDocumentOptions WithPublicationBindingOptions( + CultMeshPeerSnapshotDocumentOptions? options, + CultRecordKey key, + string? documentId, + string? sourceId) + { + return new CultMeshPeerSnapshotDocumentOptions + { + DocumentId = string.IsNullOrWhiteSpace(documentId) ? key.Value : documentId, + SourceId = string.IsNullOrWhiteSpace(sourceId) + ? (string.IsNullOrWhiteSpace(documentId) ? key.Value : documentId) + : sourceId, + RouteHint = options?.RouteHint, + ResponseTimeout = options?.ResponseTimeout ?? TimeSpan.FromSeconds(5), + ConnectTimeout = options?.ConnectTimeout ?? TimeSpan.FromSeconds(5), + PollInterval = options?.PollInterval ?? TimeSpan.FromMilliseconds(250), + MessageIdPrefix = options?.MessageIdPrefix, + SchemaIds = options?.SchemaIds, + ShardId = options?.ShardId, + ShardEpoch = options?.ShardEpoch, + Security = options?.Security, + ConfigureClient = options?.ConfigureClient, + RudpRuntimeId = options?.RudpRuntimeId, + RudpConnectionId = options?.RudpConnectionId ?? 0x43554c54, + RudpConnectPayload = options?.RudpConnectPayload ?? "cultnet-schema-rudp", + RudpMaxFragmentBytes = options?.RudpMaxFragmentBytes ?? 1024, + RudpResendDelayMs = options?.RudpResendDelayMs ?? 25 + }; + } /// /// Creates a typed document handle directly over one distributed CultNet database record. /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 45fbf6d3..b7db4594 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2305,6 +2305,60 @@ public sealed class CultMeshStreamingTests (await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key"); } + [Test] + public async Task PublicationHelpers_BindConfiguredDocumentsIntoCatalog() + { + using var directory = new TemporaryDirectory(); + var firstPath = Path.Combine(directory.Path, "first.ccmp"); + var secondPath = Path.Combine(directory.Path, "second.ccmp"); + var firstKey = new CultRecordKey("mesh-note:publication-catalog-first"); + var secondKey = new CultRecordKey("mesh-note:publication-catalog-second"); + var firstWriter = new CultCache(); + firstWriter.AddBackingStore(new SingleFileMessagePackBackingStore(firstPath)); + await firstWriter.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument + { + Schema = "tests.mesh_publication_note.v1", + Text = "first source", + Revision = 21 + }, firstKey); + firstWriter.FlushAllBackingStores(); + var secondWriter = new CultCache(); + secondWriter.AddBackingStore(new SingleFileMessagePackBackingStore(secondPath)); + await secondWriter.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "second source", + Revision = 22 + }, new CultRecordHandle(secondKey)); + secondWriter.FlushAllBackingStores(); + + var catalog = CultMesh.DocumentsFromPublication( + CultMeshDocumentPublicationSource.SingleFile(firstPath), + new ICultMeshPublicationDocumentBinding[] + { + CultMesh.PublicationDocument( + firstKey, + documentId: "daemon:first"), + CultMesh.PublicationDocument( + secondKey, + documentId: "daemon:second", + source: CultMeshDocumentPublicationSource.SingleFile(secondPath)) + }, + CultMesh.Verse("starbridge", "unity-catalog"), + new CultMeshStoreDocumentOptions + { + RouteHint = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "publication catalog"), + PollInterval = TimeSpan.FromMilliseconds(10) + }); + + catalog.Documents.Select(document => document.DocumentId).Should().Equal("daemon:first", "daemon:second"); + (await catalog.LatestAsync()).Text.Should().Be("first source"); + (await catalog.LatestAsync()).Text.Should().Be("second source"); + catalog.Document().DocumentId.Should().Be("daemon:second"); + catalog.Document().RouteHint.Description.Should().Be("publication catalog"); + catalog.Document().Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:second"); + } + [Test] public async Task PublicationHelpers_SyncConfiguredPeerSourceIntoLocalNodeAlias() { From e94cd0973d0b232ab70bdf1b701f52f1dfcb83cb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:53:35 +0100 Subject: [PATCH 096/498] Add C# publication catalog sync --- src/GameCult.Mesh/CultMesh.cs | 85 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 62 ++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 38fb7954..dab5dd28 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -299,6 +299,14 @@ namespace GameCult.Mesh CultMeshVerseContext context, CultMeshStoreDocumentOptions? storeOptions, CultMeshPeerSnapshotDocumentOptions? peerOptions); + + /// Reads this publication document and hydrates it into a local node. + Task SyncAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions, + CultMeshPeerSnapshotDocumentOptions? peerOptions); } /// @@ -345,6 +353,32 @@ namespace GameCult.Mesh CultMesh.WithPublicationBindingOptions(storeOptions, Key, DocumentId, SourceId), CultMesh.WithPublicationBindingOptions(peerOptions, Key, DocumentId, SourceId)); } + + /// + public async Task SyncAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions, + CultMeshPeerSnapshotDocumentOptions? peerOptions) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var publication = CultMesh.DocumentFromPublication( + Source ?? source, + Key, + context, + CultMesh.WithPublicationBindingOptions(storeOptions, Key, DocumentId, SourceId), + CultMesh.WithPublicationBindingOptions(peerOptions, Key, DocumentId, SourceId)); + var local = CultMesh.Document( + node, + Key, + context, + string.IsNullOrWhiteSpace(DocumentId) ? Key.Value : DocumentId); + await local.ReplaceAsync(await publication.LatestAsync().ConfigureAwait(false)).ConfigureAwait(false); + return local; + } } /// /// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade. @@ -1667,6 +1701,57 @@ namespace GameCult.Mesh return DocumentsFromPublication(source, bindings, verse.Context, storeOptions, peerOptions); } /// + /// Reads configured publication documents, hydrates them into a local node, and returns local handles as a catalog. + /// + public static async Task SyncDocumentsFromPublicationAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + IEnumerable bindings, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null, + bool flush = false) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (source == null) throw new ArgumentNullException(nameof(source)); + if (bindings == null) throw new ArgumentNullException(nameof(bindings)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var handles = await Task.WhenAll(bindings.Select(binding => + { + if (binding == null) throw new ArgumentException("CultMesh publication bindings cannot contain null.", nameof(bindings)); + return binding.SyncAsync(node, source, context, storeOptions, peerOptions); + })).ConfigureAwait(false); + + if (flush) + await node.FlushAsync().ConfigureAwait(false); + + return Documents(handles); + } + + /// + /// Reads configured publication documents, hydrates them into a local node, and returns local handles as a catalog. + /// + public static Task SyncDocumentsFromPublicationAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + IEnumerable bindings, + CultMeshVerse verse, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null, + bool flush = false) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return SyncDocumentsFromPublicationAsync( + node, + source, + bindings, + verse.Context, + storeOptions, + peerOptions, + flush); + } + /// /// Reads one typed document from a configured publication source and hydrates it into a local node. /// public static async Task SyncDocumentFromPublicationAsync( diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index b7db4594..b5f5f308 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2359,6 +2359,68 @@ public sealed class CultMeshStreamingTests catalog.Document().Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:second"); } + [Test] + public async Task PublicationHelpers_SyncConfiguredDocumentsIntoLocalCatalog() + { + using var directory = new TemporaryDirectory(); + var firstPath = Path.Combine(directory.Path, "sync-first.ccmp"); + var secondPath = Path.Combine(directory.Path, "sync-second.ccmp"); + var firstKey = new CultRecordKey("mesh-note:publication-sync-catalog-first"); + var secondKey = new CultRecordKey("mesh-note:publication-sync-catalog-second"); + var firstWriter = new CultCache(); + firstWriter.AddBackingStore(new SingleFileMessagePackBackingStore(firstPath)); + await firstWriter.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument + { + Schema = "tests.mesh_publication_note.v1", + Text = "first synced source", + Revision = 31 + }, firstKey); + firstWriter.FlushAllBackingStores(); + var secondWriter = new CultCache(); + secondWriter.AddBackingStore(new SingleFileMessagePackBackingStore(secondPath)); + await secondWriter.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "second synced source", + Revision = 32 + }, new CultRecordHandle(secondKey)); + secondWriter.FlushAllBackingStores(); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(directory.Path, "target.ccmp"), + new CultMeshNodeOptions + { + StartServer = false + }); + + var catalog = await CultMesh.SyncDocumentsFromPublicationAsync( + node, + CultMeshDocumentPublicationSource.SingleFile(firstPath), + new ICultMeshPublicationDocumentBinding[] + { + CultMesh.PublicationDocument( + firstKey, + documentId: "local:first"), + CultMesh.PublicationDocument( + secondKey, + documentId: "local:second", + source: CultMeshDocumentPublicationSource.SingleFile(secondPath)) + }, + CultMesh.Verse("starbridge", "unity-sync-catalog"), + new CultMeshStoreDocumentOptions + { + PollInterval = TimeSpan.FromMilliseconds(10) + }); + + catalog.Documents.Select(document => document.DocumentId).Should().Equal("local:first", "local:second"); + (await catalog.LatestAsync()).Text.Should().Be("first synced source"); + (await catalog.LatestAsync()).Text.Should().Be("second synced source"); + node.Cache.Get(firstKey)!.Revision.Should().Be(31); + (await node.Document(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog")) + .LatestAsync()).Text.Should().Be("second synced source"); + (await node.Document(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog")) + .LatestAsync()).Revision.Should().Be(32); + } + [Test] public async Task PublicationHelpers_SyncConfiguredPeerSourceIntoLocalNodeAlias() { From bcb15d461e81763f401060476c1bf294680cf7bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:57:10 +0100 Subject: [PATCH 097/498] Add TS publication catalog sync --- packages/cultmesh-ts/src/index.ts | 78 ++++++++++++++++++++ packages/cultmesh-ts/test/cultmesh.test.ts | 83 ++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index d817370f..c61dfee1 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -2787,6 +2787,57 @@ export async function cultMeshSyncDocumentFromPublication { + if (!node) { + throw new Error("CultMesh node is required."); + } + if (!source) { + throw new Error("CultMesh publication source is required."); + } + if (!bindings) { + throw new Error("CultMesh publication bindings are required."); + } + + const documents = await Promise.all(bindings.map(async binding => { + if (!binding) { + throw new Error("CultMesh publication binding is required."); + } + if (typeof binding.schema === "string") { + throw new Error( + `CultMesh synced publication binding '${binding.recordKey}' requires a document definition, not only a schema id.`, + ); + } + const resolvedSource = binding.source ?? + (typeof source === "function" ? source(binding) : source); + await node.syncDocumentFromPublication( + resolvedSource, + binding.schema, + binding.recordKey, + { + timeoutMs: options.timeoutMs, + messageIdPrefix: options.messageIdPrefix, + }, + ); + return node.document(binding.schema, binding.recordKey, { + documentId: binding.documentId ?? binding.recordKey, + routeHint: options.routeHint, + pollMs: options.pollMs, + }); + })); + + return cultMeshDocuments(...documents); +} + export function cultMeshGlobalDocumentFromCache( cache: CultCache, definition: TDefinition, @@ -3898,6 +3949,19 @@ export class CultMeshNode { return cultMeshSyncDocumentFromPublication(this, source, definition, key, options); } + public syncDocumentsFromPublication( + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise { + return cultMeshSyncDocumentsFromPublication(this, source, bindings, options); + } + public globalDocument( definition: TDefinition, options: { @@ -4901,6 +4965,20 @@ export class CultMesh { return node.syncDocumentFromPublication(source, definition, key, options); } + public static syncDocumentsFromPublication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSourceResolver, + bindings: readonly CultMeshPublicationDocumentBinding[], + options: { + routeHint?: CultMeshRouteHint; + timeoutMs?: number; + pollMs?: number; + messageIdPrefix?: string; + } = {}, + ): Promise { + return node.syncDocumentsFromPublication(source, bindings, options); + } + public static documents( ...documents: readonly CultMeshDocumentHandle[] ): CultMeshDocumentCatalog { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 7bd1d8e0..f4e8f9cc 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -722,6 +722,89 @@ test("CultMesh TS syncs configured publications into local node aliases", async assert.equal((await target.reactiveDocument(noteAliasDocument, "note:published", { watch: false }).ready).body, "publication source hydrates local alias"); }); +test("CultMesh TS syncs configured publication catalogs into local node aliases", async () => { + const firstPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-first-")), "first.ccmp"); + const secondPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-second-")), "second.ccmp"); + const targetPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-target-")), "target.ccmp"); + const first = await CultMesh.startNode(firstPath, { + documents: [noteDocument], + }); + const second = await CultMesh.startNode(secondPath, { + documents: [noteDocument], + }); + await first.put(noteDocument, "note:first-sync", { + noteId: "note:first-sync", + body: "first synced publication", + }); + await second.put(noteDocument, "note:second-sync", { + noteId: "note:second-sync", + body: "second synced publication", + }); + await first.flush(); + await second.flush(); + const target = await CultMesh.startNode(targetPath, { + documents: [noteDocument], + }); + const bindings = [ + CultMesh.publicationDocument(noteDocument, "note:first-sync", { + documentId: "local:first", + }), + CultMesh.publicationDocument(noteAliasDocument, "note:second-sync", { + documentId: "local:second", + source: { + kind: "single-file", + path: secondPath, + }, + }), + ]; + + const catalog = await target.syncDocumentsFromPublication( + { + kind: "single-file", + path: firstPath, + }, + bindings, + { + routeHint: CultMesh.routeHint("shared-memory", "publication sync catalog"), + pollMs: 5, + }, + ); + const facadeCatalog = await CultMesh.syncDocumentsFromPublication( + target, + { + kind: "single-file", + path: firstPath, + }, + bindings, + ); + + assert.deepEqual( + catalog.documents.map(document => document.documentId), + ["local:first", "local:second"], + ); + assert.equal( + await catalog.document(noteDocument, { + parse: value => noteDocument.schema.parse(value), + }).latest().then(value => value.body), + "first synced publication", + ); + assert.equal( + await catalog.document(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).latest().then(value => value.body), + "second synced publication", + ); + assert.equal(catalog.document(noteAliasDocument).routeHint.description, "publication sync catalog"); + assert.equal(target.getRequired(noteDocument, "note:second-sync").body, "second synced publication"); + assert.equal(target.getRequired(noteAliasDocument, "note:second-sync").body, "second synced publication"); + assert.equal( + await facadeCatalog.document(noteAliasDocument, { + parse: value => noteAliasDocument.schema.parse(value), + }).latest().then(value => value.body), + "second synced publication", + ); +}); + test("CultMesh TS document catalogs resolve semantic schema versions passed as schema ids", async () => { const current = { noteId: "note:semantic", From 6627eb474610a742e48208304864c27441f1321e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 00:00:15 +0100 Subject: [PATCH 098/498] Add Python publication catalog sync --- .../cultcache-py/src/cultmesh_py/__init__.py | 2 + .../cultcache-py/src/cultmesh_py/facade.py | 27 +++++++- packages/cultcache-py/src/cultmesh_py/node.py | 32 +++++++++ packages/cultcache-py/tests/test_cultcache.py | 65 +++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/cultcache-py/src/cultmesh_py/__init__.py b/packages/cultcache-py/src/cultmesh_py/__init__.py index 0667ab77..8c812395 100644 --- a/packages/cultcache-py/src/cultmesh_py/__init__.py +++ b/packages/cultcache-py/src/cultmesh_py/__init__.py @@ -16,6 +16,7 @@ from .node import ( CultMeshDocumentPublicationSource, CultMeshNode, CultMeshNodeOptions, + CultMeshPublicationDocumentBinding, create_node, ) from .server import CultMeshLocalServer @@ -65,6 +66,7 @@ __all__ = [ "CultMeshDatabaseChange", "CultMeshDocumentPublicationSource", "CultMesh", + "CultMeshPublicationDocumentBinding", "CultMeshGameSession", "CultMeshGameSessionOptions", "CultMeshLocalServer", diff --git a/packages/cultcache-py/src/cultmesh_py/facade.py b/packages/cultcache-py/src/cultmesh_py/facade.py index 5feab180..f5cbea07 100644 --- a/packages/cultcache-py/src/cultmesh_py/facade.py +++ b/packages/cultcache-py/src/cultmesh_py/facade.py @@ -22,7 +22,13 @@ from cultnet_py import ( ) from .client import CultMeshDocumentSubscription, CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient -from .node import CultMeshDocumentPublicationSource, CultMeshNode, CultMeshNodeOptions, create_node +from .node import ( + CultMeshDocumentPublicationSource, + CultMeshNode, + CultMeshNodeOptions, + CultMeshPublicationDocumentBinding, + create_node, +) from .server import CultMeshLocalServer from .session import CultMeshGameSession, CultMeshGameSessionOptions from .simulation import CultMeshSimulationFactCommitter @@ -309,6 +315,17 @@ class CultMesh: def publication_source_from_single_file(path: str | Path) -> CultMeshDocumentPublicationSource: return CultMeshDocumentPublicationSource.single_file(path) + @staticmethod + def publication_document( + document: DocumentDefinition[Any], + key: str, + *, + source: CultMeshDocumentPublicationSource | None = None, + ) -> CultMeshPublicationDocumentBinding: + if not key: + raise ValueError("key must be non-empty") + return CultMeshPublicationDocumentBinding(document=document, key=key, source=source) + @staticmethod def sync_document_from_publication( node: CultMeshNode, @@ -318,6 +335,14 @@ class CultMesh: ) -> Any: return node.sync_document_from_publication(source, document, key) + @staticmethod + def sync_documents_from_publication( + node: CultMeshNode, + source: CultMeshDocumentPublicationSource, + bindings: list[CultMeshPublicationDocumentBinding] | tuple[CultMeshPublicationDocumentBinding, ...], + ) -> list[Any]: + return node.sync_documents_from_publication(source, bindings) + @staticmethod def subscribe_document( node: CultMeshNode, diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index 59d49c9a..8441d514 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -64,6 +64,13 @@ class CultMeshDocumentPublicationSource: return CultMeshDocumentPublicationSource(kind="single_file", path=Path(path)) +@dataclass(frozen=True) +class CultMeshPublicationDocumentBinding: + document: DocumentDefinition[Any] + key: str + source: CultMeshDocumentPublicationSource | None = None + + @dataclass class CultMeshNode: cache: CultCache = field(default_factory=CultCache) @@ -232,6 +239,13 @@ class CultMeshNode: ) -> Any: return self.database.sync_document_from_publication(source, document, key) + def sync_documents_from_publication( + self, + source: CultMeshDocumentPublicationSource, + bindings: list[CultMeshPublicationDocumentBinding] | tuple[CultMeshPublicationDocumentBinding, ...], + ) -> list[Any]: + return self.database.sync_documents_from_publication(source, bindings) + def sync_shard_log( self, client: CultNetRawClient, @@ -737,6 +751,24 @@ class CultMeshDatabase: return self.get_required(document, key) raise ValueError(f"Unsupported CultMesh document publication source: {source.kind!r}") + def sync_documents_from_publication( + self, + source: CultMeshDocumentPublicationSource, + bindings: list[CultMeshPublicationDocumentBinding] | tuple[CultMeshPublicationDocumentBinding, ...], + ) -> list[Any]: + if source is None: + raise ValueError("publication source is required") + if bindings is None: + raise ValueError("publication bindings are required") + return [ + self.sync_document_from_publication( + binding.source or source, + binding.document, + binding.key, + ) + for binding in bindings + ] + def sync_shard_log( self, client: CultNetRawClient, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index ecd839f2..e9562474 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -2266,6 +2266,71 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(node.get_required(document, "note:published-file").body, "file-publication-alias") self.assertIsInstance(node.get_required(alias, "note:published-file"), UiNote) + def test_cultmesh_node_sync_documents_from_publication_uses_binding_sources_and_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.catalog_publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.catalog_publication_alias_note.v1", + schema_name="mesh.catalog_publication_alias_note", + schema_version="mesh.catalog_publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.catalog_publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.catalog_publication_alias_note.v1", + schema_name="mesh.catalog_publication_alias_note", + schema_version="mesh.catalog_publication_alias_note.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + first_path = Path(tmp) / "first.ccmp" + second_path = Path(tmp) / "second.ccmp" + first = CultCache() + first.register_document_type(document) + first.add_generic_store(SingleFileMessagePackBackingStore(first_path)) + first.put(document, "note:first", CanonicalNote("first catalog publication")) + second = CultCache() + second.register_document_type(document) + second.add_generic_store(SingleFileMessagePackBackingStore(second_path)) + second.put(document, "note:second", CanonicalNote("second catalog publication")) + + node = create_node(runtime_id="python-catalog-publication-node") + node.register_document(document) + source = CultMesh.publication_source_from_single_file(first_path) + bindings = [ + CultMesh.publication_document(alias, "note:first"), + CultMesh.publication_document( + alias, + "note:second", + source=CultMesh.publication_source_from_single_file(second_path), + ), + ] + + synced = node.sync_documents_from_publication(source, bindings) + facade_synced = CultMesh.sync_documents_from_publication(node, source, bindings) + + self.assertEqual([value.body for value in synced], [ + "first catalog publication", + "second catalog publication", + ]) + self.assertTrue(all(isinstance(value, UiNote) for value in synced)) + self.assertEqual([value.body for value in facade_synced], [ + "first catalog publication", + "second catalog publication", + ]) + self.assertEqual(node.get_required(document, "note:first").body, "first catalog publication") + self.assertEqual(node.get_required(document, "note:second").body, "second catalog publication") + self.assertIsInstance(node.get_required(alias, "note:second"), UiNote) + def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: document = define_database_entry_type( "mesh.emit_item", From a769895e1309e32b055a486f80318796105dd65c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 00:04:48 +0100 Subject: [PATCH 099/498] Add C# reactive document edge coverage --- .../CultMeshStreamingTests.cs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index b5f5f308..dca59b42 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1060,6 +1060,120 @@ public sealed class CultMeshStreamingTests reactive.IsDirty.Should().BeFalse(); } + [Test] + public async Task ReactiveDocument_DetectsAndCoalescesDirectCurrentMemberWrites() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.direct-members", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + value => + { + predictions.Add(value); + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMilliseconds(25) + }); + + reactive.Current.Text = "direct-member-one"; + reactive.Current.Text = "direct-member-two"; + reactive.Current.Revision = 2; + + await WaitForAsync(() => predictions.Count == 1); + + predictions[0].Text.Should().Be("direct-member-two"); + predictions[0].Revision.Should().Be(2); + current.Text.Should().Be("direct-member-two"); + reactive.IsDirty.Should().BeFalse(); + } + + [Test] + public async Task ReactiveDocument_QueuesDirectCurrentEditsWhileFlushIsInFlight() + { + var subject = new Subject(); + var current = new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "initial", + Revision = 1 + }; + var firstPredictionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirstPrediction = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var predictions = new List(); + var handle = CultMesh.Document( + "mesh.note.reactive.in-flight", + CultMesh.Verse("starbridge", "pilot-a"), + _ => Task.FromResult(current), + _ => subject, + value => + { + current = value; + subject.OnNext(value); + return Task.CompletedTask; + }, + async value => + { + predictions.Add(value); + if (predictions.Count == 1) + { + firstPredictionStarted.SetResult(); + await releaseFirstPrediction.Task.ConfigureAwait(false); + } + + current = value; + subject.OnNext(value); + }); + + using var reactive = await handle.ReactiveAsync( + new CultMeshReactiveDocumentOptions + { + FlushDelay = TimeSpan.FromMinutes(1) + }); + + reactive.Update(document => + { + document.Text = "first-in-flight"; + document.Revision = 2; + }); + var flushTask = reactive.FlushAsync(); + await firstPredictionStarted.Task; + + reactive.Current.Text = "second-after-flight-started"; + reactive.Current.Revision = 3; + releaseFirstPrediction.SetResult(); + + await flushTask; + + predictions.Should().HaveCount(2); + predictions.Select(document => document.Text).Should().Equal( + "first-in-flight", + "second-after-flight-started"); + current.Text.Should().Be("second-after-flight-started"); + current.Revision.Should().Be(3); + reactive.IsDirty.Should().BeFalse(); + } + [Test] public async Task ReactiveDocument_UsesReplacementWhenPredictionIsUnavailable() { From 8533da0037324d6c513fbf1848a1c3a3d0a264e7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 00:12:05 +0100 Subject: [PATCH 100/498] Add Kotlin and Rust alias sync coverage --- .../kotlin/org/gamecult/cultmesh/CultMesh.kt | 77 +++++++++++++++++++ packages/cultnet-rs/tests/cultnet.rs | 74 ++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 4b7bd68a..32567bef 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -225,6 +225,34 @@ fun bytesDocument( global: Boolean = false, ): CultDocumentDefinition = cultDocument(ByteArrayDocumentCodec(documentType, schemaVersion), global) +private data class KotlinCanonicalNote( + val schemaVersion: String, + val body: String, +) + +private data class KotlinUiNote( + val schemaVersion: String, + val body: String, +) + +private class KotlinAliasNoteCodec( + override val documentType: String, + override val schemaVersion: String, + private val create: (String, String) -> T, + private val readSchemaVersion: (T) -> String, + private val readBody: (T) -> String, +) : CultDocumentCodec { + override fun encode(value: T): ByteArray = + MessagePackWriter() + .value(listOf(readSchemaVersion(value), readBody(value))) + .toByteArray() + + override fun decode(payload: ByteArray): T { + val slots = anyList(MessagePackReader(payload).readAny()) + return create(slots[0] as String, slots[1] as String) + } +} + class CultMeshNode( val cache: CultCache = CultCache(), private val random: SecureRandom = SecureRandom(), @@ -270,6 +298,9 @@ class CultMeshNode( fun applyRawSnapshotResponse(response: CultNetMessage): List = cache.applyRawSnapshotResponse(response) + fun syncDocument(response: CultNetMessage, document: CultDocumentDefinition, key: String): T = + cache.syncDocument(response, document, key) + fun applyDocumentDelete(message: CultNetMessage): Boolean = cache.applyDocumentDelete(message) fun applyShardLogResponse(response: CultNetMessage): List = cache.applyShardLogResponse(response) @@ -1973,6 +2004,22 @@ fun CultCache.applyRawSnapshotResponse(response: CultNetMessage): List { .map { applyRawDocumentRecord(it) } } +fun CultCache.syncDocument( + response: CultNetMessage, + document: CultDocumentDefinition, + key: String, +): T { + require(response.schemaVersion == "cultnet.snapshot_response_raw.v0") { + "Expected cultnet.snapshot_response_raw.v0, received ${response.schemaVersion}" + } + val rawDocument = mapList(response.body["documents"]) + .map { rawDocumentRecordFromWire(it) } + .firstOrNull { it.schemaId == document.schemaVersion && it.recordKey == key } + ?: throw NoSuchElementException("No ${document.schemaVersion} document for key $key") + applyRawDocumentRecord(rawDocument) + return getRequired(document, key) +} + fun CultCache.applyDocumentDelete(message: CultNetMessage): Boolean { require(message.schemaVersion == "cultnet.document_delete.v0") { "Expected cultnet.document_delete.v0, received ${message.schemaVersion}" @@ -3085,6 +3132,36 @@ private fun cultCacheRawSnapshotsRoundTripThroughCultNetMessages() { check(target.require(notes, "note:2") == "second") check(target.recallGlobal(settings) == null) + val canonicalNote = cultDocument(KotlinAliasNoteCodec( + documentType = "kotlin.alias_note", + schemaVersion = "kotlin.alias_note.v1", + create = ::KotlinCanonicalNote, + readSchemaVersion = KotlinCanonicalNote::schemaVersion, + readBody = KotlinCanonicalNote::body, + )) + val uiNote = cultDocument(KotlinAliasNoteCodec( + documentType = "kotlin.alias_note.ui", + schemaVersion = "kotlin.alias_note.v1", + create = ::KotlinUiNote, + readSchemaVersion = KotlinUiNote::schemaVersion, + readBody = KotlinUiNote::body, + )) + source.cache.register(canonicalNote) + target.cache.register(uiNote) + source.remember(canonicalNote, "note:alias", KotlinCanonicalNote("kotlin.alias_note.v1", "canonical-to-ui")) + val aliasSnapshotResponse = source.createRawSnapshotResponse( + cultNetSnapshotRequest( + messageId = "snapshot-alias-note", + schemaIds = listOf(uiNote.schemaVersion), + recordKeys = listOf("note:alias"), + ), + storedAt = "2026-06-15T00:00:02Z", + sourceRuntimeId = "kotlin-source", + ) + val syncedAlias = target.syncDocument(aliasSnapshotResponse, uiNote, "note:alias") + check(syncedAlias == KotlinUiNote("kotlin.alias_note.v1", "canonical-to-ui")) + check(target.require(uiNote, "note:alias") == syncedAlias) + val rawPut = parseCultNetMessage( cultNetDocumentPutRaw( messageId = "put-note", diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 2803db51..39ea2e39 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -149,6 +149,20 @@ struct GhostlightAgentStateFixture { display_name: String, } +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "ghostlight.agent-state.ui", + schema = "GhostlightAgentStateUiFixture" +)] +struct GhostlightAgentStateUiFixture { + #[cultcache(key = 0)] + schema_version: String, + #[cultcache(key = 1)] + agent_id: String, + #[cultcache(key = 2)] + display_name: String, +} + #[test] fn security_helpers_round_trip_encrypted_strings_and_validate_sessions() -> Result<()> { let server_security = CultNetServerSecurityOptions::development(); @@ -1682,6 +1696,66 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> Ok(()) } +#[test] +fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { + let temp = tempfile::tempdir()?; + let origin_store = temp.path().join("origin-alias.msgpack"); + let target_store = temp.path().join("target-alias.msgpack"); + let canonical = GhostlightAgentStateFixture { + schema_version: "ghostlight.agent_state.v0".to_string(), + agent_id: "epiphany.persona".to_string(), + display_name: "Persona".to_string(), + }; + + let mut origin_registry = CultNetDocumentRegistry::new(); + origin_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightAgentStateFixture, + >( + "ghostlight.agent_state.v0".to_string(), + "ghostlight.agent_state.v0".to_string(), + )); + let mut origin = CultCache::new(); + origin.register_entry_type::()?; + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store)); + origin.pull_all_backing_stores()?; + origin.put("epiphany.persona", &canonical)?; + let raw_snapshot = origin_registry.create_raw_snapshot_response( + &origin, + "raw-snapshot-alias", + Some(&["ghostlight.agent_state.v0".to_string()]), + Some(&["epiphany.persona".to_string()]), + )?; + + let mut alias_registry = CultNetDocumentRegistry::new(); + alias_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightAgentStateUiFixture, + >( + "ghostlight.agent_state.v0".to_string(), + "ghostlight.agent_state.v0".to_string(), + )); + let mut target = CultCache::new(); + target.register_entry_type::()?; + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); + target.pull_all_backing_stores()?; + + let applied = alias_registry + .apply_raw_snapshot_response::(&mut target, &raw_snapshot)?; + + assert_eq!( + applied, + vec![GhostlightAgentStateUiFixture { + schema_version: "ghostlight.agent_state.v0".to_string(), + agent_id: "epiphany.persona".to_string(), + display_name: "Persona".to_string(), + }] + ); + assert_eq!( + target.get_required::("epiphany.persona")?, + applied[0] + ); + Ok(()) +} + #[test] fn client_security_keeps_the_connection_key_visible_without_exposing_cipher_logic() -> Result<()> { let options = CultNetClientSecurityOptions::development(); From 41427d37620747f27e9fd8de3b4c060b7bbf1bae Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 00:14:55 +0100 Subject: [PATCH 101/498] Document typed alias sync ergonomics --- docs/runtime-parity-scope.md | 4 ++-- packages/cultmesh-kotlin/README.md | 22 ++++++++++++++++++++++ packages/cultnet-rs/README.md | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index dc8f5fe9..cd3761d7 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -33,8 +33,8 @@ CultMesh. | C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | | Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe | | TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack | -| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server | -| Kotlin | JVM/Android client substrate | Typed MessagePack cache, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout | +| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server | +| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout | ## Evidence Gates diff --git a/packages/cultmesh-kotlin/README.md b/packages/cultmesh-kotlin/README.md index b25050bb..b31f88af 100644 --- a/packages/cultmesh-kotlin/README.md +++ b/packages/cultmesh-kotlin/README.md @@ -66,6 +66,28 @@ target.applyRawSnapshotResponse(response) val note = target.require(notes, "note:1") ``` +When the caller wants one record as a local typed view, `syncDocument(...)` +applies just that snapshot record and returns the requested document type. This +is the comfortable alias path: the source can publish one Kotlin type, while +the target registers a different Kotlin type with the same schema version. + +```kotlin +val sourceNote = cultDocument(SourceNoteCodec) +val uiNote = cultDocument(UiNoteCodec) // same schemaVersion as sourceNote + +source.remember(sourceNote, "note:bridge", SourceNote("kotlin.note.v1", "hello")) + +val response = source.createRawSnapshotResponse( + cultNetSnapshotRequest( + messageId = "sync-ui-note", + schemaIds = listOf(uiNote.schemaVersion), + recordKeys = listOf("note:bridge"), + ), +) + +val note: UiNote = target.syncDocument(response, uiNote, "note:bridge") +``` + `applyRawDocumentPut(...)`, `applyRawSnapshotResponse(...)`, and `applyDocumentDelete(...)` require matching codecs to be registered first, so raw bytes re-enter the typed cache through the same document definitions that diff --git a/packages/cultnet-rs/README.md b/packages/cultnet-rs/README.md index 69ab72c4..664b7154 100644 --- a/packages/cultnet-rs/README.md +++ b/packages/cultnet-rs/README.md @@ -43,6 +43,7 @@ The initial tests prove: - shard catalog request/response filtering and response application - CultCache snapshot replication through registered typed documents - raw snapshot replication that preserves the original payload bytes +- raw snapshot replication into a local same-schema Rust alias type - document mutation contract advertisement through hello frames and registries ## Schema Discovery @@ -110,6 +111,24 @@ CultCache along with the typed envelope metadata. Combined with `CultCache::put_envelope::()`, that lets a bit-compatible neighbor ingest the document without re-encoding the payload first. +The receiving Rust type does not have to be the sender's concrete Rust type. If +the local registry binds a different `DatabaseEntry` to the same schema id, +`apply_raw_snapshot_response::(...)` hydrates the local alias type +directly: + +```rust +let mut target_registry = CultNetDocumentRegistry::new(); +target_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::( + "aetheria.note.v1".to_string(), + "aetheria.note.v1".to_string(), +)); + +let applied = target_registry.apply_raw_snapshot_response::( + &mut target_cache, + &snapshot_response, +)?; +``` + That still is not zero-copy in the religious sense. Frames allocate, bytes move, and the receiving cache still decodes once to keep typed reads and validation honest. The win is narrower and realer: identical payload bytes stop getting From bbde981082a14d8665a7b903018d1b1680ff8d19 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 00:17:24 +0100 Subject: [PATCH 102/498] Add Rust one-call snapshot document sync --- packages/cultnet-rs/README.md | 7 +-- packages/cultnet-rs/src/replication.rs | 68 ++++++++++++++++++++++++++ packages/cultnet-rs/tests/cultnet.rs | 18 +++++-- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/cultnet-rs/README.md b/packages/cultnet-rs/README.md index 664b7154..18cb3d7f 100644 --- a/packages/cultnet-rs/README.md +++ b/packages/cultnet-rs/README.md @@ -113,8 +113,8 @@ document without re-encoding the payload first. The receiving Rust type does not have to be the sender's concrete Rust type. If the local registry binds a different `DatabaseEntry` to the same schema id, -`apply_raw_snapshot_response::(...)` hydrates the local alias type -directly: +`sync_raw_document_from_snapshot_response::(...)` hydrates one local +alias type directly: ```rust let mut target_registry = CultNetDocumentRegistry::new(); @@ -123,9 +123,10 @@ target_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::( +let note = target_registry.sync_raw_document_from_snapshot_response::( &mut target_cache, &snapshot_response, + "note:bridge", )?; ``` diff --git a/packages/cultnet-rs/src/replication.rs b/packages/cultnet-rs/src/replication.rs index 492cf317..1597bd26 100644 --- a/packages/cultnet-rs/src/replication.rs +++ b/packages/cultnet-rs/src/replication.rs @@ -352,6 +352,40 @@ impl CultNetDocumentRegistry { Ok(applied) } + pub fn sync_document_from_snapshot_response( + &self, + cache: &mut CultCache, + response: &CultNetMessage, + record_key: &str, + ) -> Result + where + T: DatabaseEntry + Serialize + DeserializeOwned, + { + let CultNetMessage::SnapshotResponse { documents, .. } = response else { + return Err(anyhow!("expected cultnet.snapshot_response.v0")); + }; + let binding = self.require_binding(T::TYPE)?; + let document = documents + .iter() + .find(|document| { + document.schema_id == binding.schema_id && document.record_key == record_key + }) + .ok_or_else(|| { + anyhow!( + "No CultNet snapshot document for schema {:?} and record key {:?}", + binding.schema_id, + record_key + ) + })?; + self.apply_document_put_message::( + cache, + &CultNetMessage::DocumentPut { + message_id: "snapshot-sync".to_string(), + document: document.clone(), + }, + ) + } + pub fn apply_raw_snapshot_response( &self, cache: &mut CultCache, @@ -380,6 +414,40 @@ impl CultNetDocumentRegistry { Ok(applied) } + pub fn sync_raw_document_from_snapshot_response( + &self, + cache: &mut CultCache, + response: &CultNetMessage, + record_key: &str, + ) -> Result + where + T: DatabaseEntry + Serialize + DeserializeOwned, + { + let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { + return Err(anyhow!("expected cultnet.snapshot_response_raw.v0")); + }; + let binding = self.require_binding(T::TYPE)?; + let document = documents + .iter() + .find(|document| { + document.schema_id == binding.schema_id && document.record_key == record_key + }) + .ok_or_else(|| { + anyhow!( + "No raw CultNet snapshot document for schema {:?} and record key {:?}", + binding.schema_id, + record_key + ) + })?; + self.apply_raw_document_put_message::( + cache, + &CultNetMessage::DocumentPutRaw { + message_id: "snapshot-sync-raw".to_string(), + document: document.clone(), + }, + ) + } + fn document_record_from_envelope( &self, envelope: &CultCacheEnvelope, diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 39ea2e39..8ff65fa0 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -1630,7 +1630,13 @@ fn document_registry_replicates_typed_cultcache_state() -> Result<()> { target.pull_all_backing_stores()?; let applied = registry.apply_snapshot_response::(&mut target, &snapshot)?; + let synced = registry.sync_document_from_snapshot_response::( + &mut target, + &snapshot, + "epiphany.persona", + )?; assert_eq!(applied, vec![payload.clone()]); + assert_eq!(synced, payload); assert_eq!( target.get_required::("epiphany.persona")?, payload @@ -1739,19 +1745,23 @@ fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { target.pull_all_backing_stores()?; let applied = alias_registry - .apply_raw_snapshot_response::(&mut target, &raw_snapshot)?; + .sync_raw_document_from_snapshot_response::( + &mut target, + &raw_snapshot, + "epiphany.persona", + )?; assert_eq!( applied, - vec![GhostlightAgentStateUiFixture { + GhostlightAgentStateUiFixture { schema_version: "ghostlight.agent_state.v0".to_string(), agent_id: "epiphany.persona".to_string(), display_name: "Persona".to_string(), - }] + } ); assert_eq!( target.get_required::("epiphany.persona")?, - applied[0] + applied ); Ok(()) } From 9266bbec224a8bcc2a866c39f72d282f99d16fd3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 00:24:46 +0100 Subject: [PATCH 103/498] Add Python reactive document sync --- .../cultcache-py/src/cultmesh_py/__init__.py | 6 + .../cultcache-py/src/cultmesh_py/facade.py | 11 + packages/cultcache-py/src/cultmesh_py/node.py | 261 ++++++++++++++++++ packages/cultcache-py/tests/test_cultcache.py | 96 +++++++ 4 files changed, 374 insertions(+) diff --git a/packages/cultcache-py/src/cultmesh_py/__init__.py b/packages/cultcache-py/src/cultmesh_py/__init__.py index 8c812395..95aee189 100644 --- a/packages/cultcache-py/src/cultmesh_py/__init__.py +++ b/packages/cultcache-py/src/cultmesh_py/__init__.py @@ -17,6 +17,9 @@ from .node import ( CultMeshNode, CultMeshNodeOptions, CultMeshPublicationDocumentBinding, + CultMeshReactiveDocument, + CultMeshReactiveDocumentOptions, + CultMeshReactiveDocumentReconciliation, create_node, ) from .server import CultMeshLocalServer @@ -67,6 +70,9 @@ __all__ = [ "CultMeshDocumentPublicationSource", "CultMesh", "CultMeshPublicationDocumentBinding", + "CultMeshReactiveDocument", + "CultMeshReactiveDocumentOptions", + "CultMeshReactiveDocumentReconciliation", "CultMeshGameSession", "CultMeshGameSessionOptions", "CultMeshLocalServer", diff --git a/packages/cultcache-py/src/cultmesh_py/facade.py b/packages/cultcache-py/src/cultmesh_py/facade.py index f5cbea07..115259f3 100644 --- a/packages/cultcache-py/src/cultmesh_py/facade.py +++ b/packages/cultcache-py/src/cultmesh_py/facade.py @@ -27,6 +27,8 @@ from .node import ( CultMeshNode, CultMeshNodeOptions, CultMeshPublicationDocumentBinding, + CultMeshReactiveDocument, + CultMeshReactiveDocumentOptions, create_node, ) from .server import CultMeshLocalServer @@ -298,6 +300,15 @@ class CultMesh: shard_epoch=shard_epoch, ) + @staticmethod + def reactive_document( + node: CultMeshNode, + document: DocumentDefinition[Any], + key: str, + options: CultMeshReactiveDocumentOptions | None = None, + ) -> CultMeshReactiveDocument: + return node.reactive_document(document, key, options) + @staticmethod def publication_source_from_peer_snapshot( client: CultNetRawClient, diff --git a/packages/cultcache-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py index 8441d514..142fe226 100644 --- a/packages/cultcache-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -1,5 +1,6 @@ from __future__ import annotations +import threading from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path @@ -71,6 +72,22 @@ class CultMeshPublicationDocumentBinding: source: CultMeshDocumentPublicationSource | None = None +@dataclass(frozen=True) +class CultMeshReactiveDocumentReconciliation: + canonical: Any + predicted: Any + delta: dict[str, Any] + version: int + received_at: datetime + + +@dataclass(frozen=True) +class CultMeshReactiveDocumentOptions: + flush_delay_seconds: float = 0.016 + detect_local_changes: bool = True + replace_dirty_current_on_canonical_snapshot: bool = False + + @dataclass class CultMeshNode: cache: CultCache = field(default_factory=CultCache) @@ -231,6 +248,14 @@ class CultMeshNode: shard_epoch=shard_epoch, ) + def reactive_document( + self, + document: DocumentDefinition[Any], + key: str, + options: CultMeshReactiveDocumentOptions | None = None, + ) -> "CultMeshReactiveDocument": + return self.database.reactive_document(document, key, options) + def sync_document_from_publication( self, source: CultMeshDocumentPublicationSource, @@ -719,6 +744,18 @@ class CultMeshDatabase: ) return self.get_required(requested, key) + def reactive_document( + self, + document: DocumentDefinition[Any], + key: str, + options: CultMeshReactiveDocumentOptions | None = None, + ) -> "CultMeshReactiveDocument": + if not key: + raise ValueError("key must be non-empty") + if not any(self._documents_alias(document, registered) for registered in self.documents): + self.register_document(document) + return CultMeshReactiveDocument(self, document, key, options) + def sync_document_from_publication( self, source: CultMeshDocumentPublicationSource, @@ -1217,6 +1254,230 @@ def _document_matches_schema_id(document: DocumentDefinition[Any], schema_id: st return _infer_schema_name(schema_id) == entry.schema_name +class CultMeshReactiveDocument: + def __init__( + self, + database: CultMeshDatabase, + document: DocumentDefinition[Any], + key: str, + options: CultMeshReactiveDocumentOptions | None = None, + ) -> None: + self._database = database + self._document = document + self._key = key + self._options = options or CultMeshReactiveDocumentOptions() + self._lock = threading.RLock() + self._disposed = False + self._dirty = False + self._flushing = False + self._flush_queued = False + self._flush_timer: threading.Timer | None = None + self._detect_timer: threading.Timer | None = None + self._reconciliation_version = 0 + self.reconciliation: CultMeshReactiveDocumentReconciliation | None = None + self.current = self._clone(self._database.get_required(document, key)) + self._last_clean_payload = self._serialize(self.current) + self._unsubscribe = self._database.watch_record(document, key, self._apply_canonical_change) + if self._options.detect_local_changes: + self._schedule_detection() + + @property + def document(self) -> DocumentDefinition[Any]: + return self._document + + @property + def key(self) -> str: + return self._key + + @property + def is_dirty(self) -> bool: + with self._lock: + return self._dirty + + def update(self, callback: Callable[[Any], None]) -> Any: + with self._lock: + self._raise_if_disposed() + callback(self.current) + self._dirty = True + self._schedule_flush_locked() + return self.current + + def set_current(self, value: Any) -> Any: + with self._lock: + self._raise_if_disposed() + self.current = self._clone(value) + self._dirty = True + self._schedule_flush_locked() + return self.current + + def mark_dirty(self) -> None: + with self._lock: + self._raise_if_disposed() + self._dirty = True + self._schedule_flush_locked() + + def refresh(self) -> Any: + with self._lock: + self._raise_if_disposed() + self.current = self._clone(self._database.get_required(self._document, self._key)) + self._last_clean_payload = self._serialize(self.current) + self._dirty = False + self.reconciliation = None + return self.current + + def flush(self) -> None: + with self._lock: + self._raise_if_disposed() + if not self._dirty: + self._detect_local_changes_locked() + if not self._dirty: + return + if self._flushing: + self._flush_queued = True + return + if self._flush_timer is not None: + self._flush_timer.cancel() + self._flush_timer = None + self._flushing = True + self._dirty = False + predicted = self._clone(self.current) + self._last_clean_payload = self._serialize(predicted) + + try: + self._database.put(self._document, self._key, predicted) + finally: + should_flush_again = False + with self._lock: + self._flushing = False + self._detect_local_changes_locked() + should_flush_again = self._flush_queued or self._dirty + self._flush_queued = False + if should_flush_again: + self.flush() + + def clear_reconciliation(self) -> None: + with self._lock: + self._raise_if_disposed() + self.reconciliation = None + + def dispose(self) -> None: + with self._lock: + if self._disposed: + return + self._disposed = True + if self._flush_timer is not None: + self._flush_timer.cancel() + if self._detect_timer is not None: + self._detect_timer.cancel() + self._unsubscribe() + + def __enter__(self) -> "CultMeshReactiveDocument": + return self + + def __exit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> None: + self.dispose() + + def _apply_canonical_change(self, change: CultMeshDatabaseChange) -> None: + if change.value is None: + return + with self._lock: + if self._disposed: + return + canonical = self._clone(change.value) + if self._dirty or self._flushing: + predicted = self._clone(self.current) + delta = self._create_delta(predicted, canonical) + if delta: + self._reconciliation_version += 1 + self.reconciliation = CultMeshReactiveDocumentReconciliation( + canonical=canonical, + predicted=predicted, + delta=delta, + version=self._reconciliation_version, + received_at=datetime.now(UTC), + ) + else: + self.reconciliation = None + if not self._options.replace_dirty_current_on_canonical_snapshot: + return + self.current = canonical + self._last_clean_payload = self._serialize(self.current) + self.reconciliation = None + + def _schedule_detection(self) -> None: + delay = max(self._options.flush_delay_seconds, 0.001) + timer = threading.Timer(delay, self._detect_timer_elapsed) + timer.daemon = True + self._detect_timer = timer + timer.start() + + def _detect_timer_elapsed(self) -> None: + with self._lock: + if self._disposed: + return + if not self._dirty and not self._flushing: + self._detect_local_changes_locked() + if self._dirty: + self._schedule_flush_locked() + if not self._disposed and self._options.detect_local_changes: + self._schedule_detection() + + def _detect_local_changes_locked(self) -> None: + if self._serialize(self.current) != self._last_clean_payload: + self._dirty = True + + def _schedule_flush_locked(self) -> None: + if self._options.flush_delay_seconds <= 0: + threading.Thread(target=self.flush, daemon=True).start() + return + if self._flush_timer is not None: + self._flush_timer.cancel() + timer = threading.Timer(self._options.flush_delay_seconds, self.flush) + timer.daemon = True + self._flush_timer = timer + timer.start() + + def _serialize(self, value: Any) -> bytes: + return self._document.encode_payload(value) + + def _clone(self, value: Any) -> Any: + return self._document.decode_payload(self._document.encode_payload(value)) + + @staticmethod + def _create_delta(predicted: Any, canonical: Any) -> dict[str, Any]: + delta: dict[str, Any] = {} + canonical_members = _public_document_members(canonical) + for name, predicted_value in _public_document_members(predicted).items(): + canonical_value = canonical_members.get(name) + if predicted_value == canonical_value: + continue + if isinstance(predicted_value, (int, float)) and isinstance(canonical_value, (int, float)): + delta[name] = predicted_value - canonical_value + else: + delta[name] = predicted_value + return delta + + def _raise_if_disposed(self) -> None: + if self._disposed: + raise RuntimeError("CultMeshReactiveDocument has been disposed") + + +def _public_document_members(value: Any) -> dict[str, Any]: + if hasattr(value, "__dataclass_fields__"): + return { + name: getattr(value, name) + for name in value.__dataclass_fields__ # type: ignore[attr-defined] + if not name.startswith("_") + } + if isinstance(value, dict): + return {str(key): item for key, item in value.items()} + return { + name: item + for name, item in vars(value).items() + if not name.startswith("_") + } + + def create_node( cache_path: str | Path | None = None, *, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index e9562474..3ecb763f 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -119,6 +119,7 @@ from cultmesh_py import ( CultMeshDatabaseChange, CultMeshGameSessionOptions, CultMeshNodeOptions, + CultMeshReactiveDocumentOptions, CultMeshPeerCard, CultMeshPeerCatalog, CultMeshSimulationFact, @@ -2447,6 +2448,101 @@ class CultCacheTests(unittest.TestCase): self.assertIsNone(node.get(alias, "note:alias")) self.assertEqual(node.get_all(alias), []) + def test_cultmesh_reactive_document_coalesces_direct_alias_member_writes(self) -> None: + @dataclass + class CanonicalNote: + body: str + revision: int + + @dataclass + class UiNote: + body: str + revision: int + + document = define_database_entry_type( + "mesh.reactive_alias_note", + [("body", 0), ("revision", 1)], + cls=CanonicalNote, + schema_id="mesh.reactive_alias_note.v1", + schema_name="mesh.reactive_alias_note", + schema_version="mesh.reactive_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.reactive_alias_note.ui", + [("body", 0), ("revision", 1)], + cls=UiNote, + schema_id="mesh.reactive_alias_note.v1", + schema_name="mesh.reactive_alias_note", + schema_version="mesh.reactive_alias_note.v1", + ) + node = create_node(runtime_id="python-reactive") + node.register_document(document) + node.put(document, "note:reactive", CanonicalNote("initial", 1)) + seen: list[CultMeshDatabaseChange] = [] + node.database.watch_record(alias, "note:reactive", seen.append) + + reactive = node.reactive_document( + alias, + "note:reactive", + CultMeshReactiveDocumentOptions(flush_delay_seconds=0.02), + ) + try: + self.assertIsInstance(reactive.current, UiNote) + reactive.current.body = "first-local-edit" + reactive.current.body = "second-local-edit" + reactive.current.revision = 2 + + self._wait_until(lambda: node.get_required(alias, "note:reactive").body == "second-local-edit") + + self.assertFalse(reactive.is_dirty) + self.assertEqual(node.get_required(document, "note:reactive").revision, 2) + self.assertEqual(seen[-1].value.body, "second-local-edit") + finally: + reactive.dispose() + + def test_cultmesh_reactive_document_tracks_canonical_reconciliation_delta(self) -> None: + @dataclass + class Note: + body: str + revision: int + + document = define_database_entry_type( + "mesh.reactive_reconcile_note", + [("body", 0), ("revision", 1)], + cls=Note, + schema_id="mesh.reactive_reconcile_note.v1", + schema_name="mesh.reactive_reconcile_note", + schema_version="mesh.reactive_reconcile_note.v1", + ) + node = create_node(runtime_id="python-reactive-reconcile") + node.register_document(document) + node.put(document, "note:reconcile", Note("initial", 1)) + + reactive = CultMesh.reactive_document( + node, + document, + "note:reconcile", + CultMeshReactiveDocumentOptions(flush_delay_seconds=60), + ) + try: + reactive.update(lambda note: setattr(note, "body", "local-prediction")) + reactive.current.revision = 2 + + node.put(document, "note:reconcile", Note("authoritative", 7)) + + self.assertEqual(reactive.current.body, "local-prediction") + self.assertIsNotNone(reactive.reconciliation) + self.assertEqual(reactive.reconciliation.canonical.body, "authoritative") + self.assertEqual(reactive.reconciliation.predicted.body, "local-prediction") + self.assertEqual(reactive.reconciliation.delta["body"], "local-prediction") + self.assertEqual(reactive.reconciliation.delta["revision"], -5) + + reactive.flush() + self.assertEqual(node.get_required(document, "note:reconcile").body, "local-prediction") + self.assertIsNone(reactive.reconciliation) + finally: + reactive.dispose() + def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None: message = document_delete( message_id="delete-1", From 60f782faa7078b68d7b4003e474943b40889302c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 00:31:35 +0100 Subject: [PATCH 104/498] Add Rust reactive document sync --- packages/cultnet-rs/README.md | 34 ++ packages/cultnet-rs/src/replication.rs | 506 +++++++++++++++++++++++++ packages/cultnet-rs/tests/cultnet.rs | 168 ++++++++ 3 files changed, 708 insertions(+) diff --git a/packages/cultnet-rs/README.md b/packages/cultnet-rs/README.md index 18cb3d7f..7ade81dc 100644 --- a/packages/cultnet-rs/README.md +++ b/packages/cultnet-rs/README.md @@ -136,6 +136,40 @@ honest. The win is narrower and realer: identical payload bytes stop getting decoded into generic sludge and then encoded right back into the same bytes for no reason. +## Reactive Documents + +For local state that should feel owned by the caller, Rust can wrap a registered +CultCache record as a managed reactive document: + +```rust +use std::sync::{Arc, Mutex}; +use cultnet_rs::{CultNetReactiveDocumentOptions, CultNetDocumentRegistry}; + +let cache = Arc::new(Mutex::new(target_cache)); +let note = registry.reactive_document::( + Arc::clone(&cache), + "note:bridge", + CultNetReactiveDocumentOptions::default(), +)?; + +{ + let current = note.current(); + current.lock().unwrap().body = "local prediction".to_string(); +} +``` + +The handle polls the serialized `current` value on the configured debounce +cadence and flushes changed state back through the typed cache. `update(...)`, +`set_current(...)`, `mark_dirty()`, and `flush()` are available when the caller +wants a more explicit Rust shape. + +When a canonical document put arrives while local state is dirty, +`apply_document_put_message(...)` or `apply_raw_document_put_message(...)` +records a `CultNetReactiveDocumentReconciliation`: the canonical value, the +predicted local value, and a delta that can be smoothed by the caller. CultCache +payloads serialize by numeric field slot, so Rust tuple-backed deltas use slot +keys such as `"0"` and `"1"` when field names are not present on the wire. + ## CultMesh Rust Facade Rust is still the low-level substrate runtime, not a clone of the C# game diff --git a/packages/cultnet-rs/src/replication.rs b/packages/cultnet-rs/src/replication.rs index 1597bd26..d02a141f 100644 --- a/packages/cultnet-rs/src/replication.rs +++ b/packages/cultnet-rs/src/replication.rs @@ -9,6 +9,13 @@ use serde::de::DeserializeOwned; use serde_json::Value; use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::thread::JoinHandle; +use std::time::Duration; use crate::CultNetDocumentMutationContract; use crate::CultNetDocumentRecord; @@ -67,6 +74,56 @@ pub struct CultNetDocumentRegistry { bindings_by_schema_id: BTreeMap, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultNetReactiveDocumentOptions { + pub flush_delay: Duration, + pub detect_local_changes: bool, + pub replace_dirty_current_on_canonical_snapshot: bool, +} + +impl Default for CultNetReactiveDocumentOptions { + fn default() -> Self { + Self { + flush_delay: Duration::from_millis(16), + detect_local_changes: true, + replace_dirty_current_on_canonical_snapshot: false, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CultNetReactiveDocumentReconciliation { + pub canonical: T, + pub predicted: T, + pub delta: BTreeMap, + pub version: u64, + pub received_at: String, +} + +struct CultNetReactiveDocumentState { + dirty: bool, + flushing: bool, + flush_queued: bool, + last_clean_payload: Vec, + reconciliation: Option>, + reconciliation_version: u64, + last_error: Option, +} + +pub struct CultNetReactiveDocument +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + registry: CultNetDocumentRegistry, + cache: Arc>, + record_key: String, + current: Arc>, + state: Arc>>, + options: CultNetReactiveDocumentOptions, + disposed: Arc, + detect_worker: Option>, +} + impl CultNetDocumentRegistry { pub fn new() -> Self { Self::default() @@ -448,6 +505,18 @@ impl CultNetDocumentRegistry { ) } + pub fn reactive_document( + &self, + cache: Arc>, + record_key: impl Into, + options: CultNetReactiveDocumentOptions, + ) -> Result> + where + T: DatabaseEntry + Serialize + DeserializeOwned, + { + CultNetReactiveDocument::new(self.clone(), cache, record_key, options) + } + fn document_record_from_envelope( &self, envelope: &CultCacheEnvelope, @@ -496,6 +565,443 @@ impl CultNetDocumentRegistry { } } +impl CultNetReactiveDocument +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + pub fn new( + registry: CultNetDocumentRegistry, + cache: Arc>, + record_key: impl Into, + options: CultNetReactiveDocumentOptions, + ) -> Result { + let record_key = record_key.into(); + if record_key.trim().is_empty() { + return Err(anyhow!("record_key must be non-empty")); + } + registry.require_binding(T::TYPE)?; + let current = { + let cache = cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + cache.get_required::(&record_key)? + }; + let last_clean_payload = serialize_typed(¤t)?; + let current = Arc::new(Mutex::new(current)); + let state = Arc::new(Mutex::new(CultNetReactiveDocumentState { + dirty: false, + flushing: false, + flush_queued: false, + last_clean_payload, + reconciliation: None, + reconciliation_version: 0, + last_error: None, + })); + let disposed = Arc::new(AtomicBool::new(false)); + let detect_worker = if options.detect_local_changes { + Some(start_reactive_detection_worker::( + Arc::clone(&cache), + Arc::clone(¤t), + Arc::clone(&state), + Arc::clone(&disposed), + options.flush_delay, + record_key.clone(), + )) + } else { + None + }; + Ok(Self { + registry, + cache, + record_key, + current, + state, + options, + disposed, + detect_worker, + }) + } + + pub fn current(&self) -> Arc> { + Arc::clone(&self.current) + } + + pub fn record_key(&self) -> &str { + &self.record_key + } + + pub fn is_dirty(&self) -> bool { + self.state.lock().map(|state| state.dirty).unwrap_or(false) + } + + pub fn reconciliation(&self) -> Option> { + self.state + .lock() + .ok() + .and_then(|state| state.reconciliation.clone()) + } + + pub fn last_error(&self) -> Option { + self.state + .lock() + .ok() + .and_then(|state| state.last_error.clone()) + } + + pub fn update(&self, update: impl FnOnce(&mut T)) -> Result { + let predicted = { + let mut current = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + update(&mut current); + current.clone() + }; + { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.dirty = true; + } + if self.options.flush_delay.is_zero() { + self.flush()?; + } + Ok(predicted) + } + + pub fn set_current(&self, value: T) -> Result { + { + let mut current = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + *current = value.clone(); + } + { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.dirty = true; + } + if self.options.flush_delay.is_zero() { + self.flush()?; + } + Ok(value) + } + + pub fn mark_dirty(&self) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.dirty = true; + Ok(()) + } + + pub fn refresh(&self) -> Result { + let canonical = { + let cache = self + .cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + cache.get_required::(&self.record_key)? + }; + { + let mut current = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + *current = canonical.clone(); + } + { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.last_clean_payload = serialize_typed(&canonical)?; + state.dirty = false; + state.flush_queued = false; + state.reconciliation = None; + } + Ok(canonical) + } + + pub fn flush(&self) -> Result<()> { + flush_reactive_document::(&self.cache, &self.current, &self.state, &self.record_key) + } + + pub fn apply_document_put_message(&self, message: &CultNetMessage) -> Result { + let canonical = { + let mut cache = self + .cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + self.registry + .apply_document_put_message::(&mut cache, message)? + }; + self.apply_canonical_value(canonical) + } + + pub fn apply_raw_document_put_message(&self, message: &CultNetMessage) -> Result { + let canonical = { + let mut cache = self + .cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + self.registry + .apply_raw_document_put_message::(&mut cache, message)? + }; + self.apply_canonical_value(canonical) + } + + pub fn clear_reconciliation(&self) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.reconciliation = None; + Ok(()) + } + + fn apply_canonical_value(&self, canonical: T) -> Result { + let predicted = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))? + .clone(); + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + if state.dirty || state.flushing { + let delta = create_reconciliation_delta(&predicted, &canonical)?; + if delta.is_empty() { + state.reconciliation = None; + } else { + state.reconciliation_version += 1; + state.reconciliation = Some(CultNetReactiveDocumentReconciliation { + canonical: canonical.clone(), + predicted, + delta, + version: state.reconciliation_version, + received_at: now_utc_second(), + }); + } + if !self.options.replace_dirty_current_on_canonical_snapshot { + return Ok(canonical); + } + } + drop(state); + { + let mut current = self + .current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + *current = canonical.clone(); + } + let mut state = self + .state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.last_clean_payload = serialize_typed(&canonical)?; + state.reconciliation = None; + Ok(canonical) + } +} + +impl Drop for CultNetReactiveDocument +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + fn drop(&mut self) { + self.disposed.store(true, Ordering::SeqCst); + let _ = self.detect_worker.take(); + } +} + +fn start_reactive_detection_worker( + cache: Arc>, + current: Arc>, + state: Arc>>, + disposed: Arc, + flush_delay: Duration, + record_key: String, +) -> JoinHandle<()> +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + thread::spawn(move || { + let delay = if flush_delay.is_zero() { + Duration::from_millis(1) + } else { + flush_delay + }; + while !disposed.load(Ordering::SeqCst) { + thread::sleep(delay); + match detect_reactive_document_change::(¤t, &state).and_then(|dirty| { + if dirty { + flush_reactive_document::(&cache, ¤t, &state, &record_key)?; + } + Ok(()) + }) { + Ok(()) => {} + Err(error) => { + if let Ok(mut state) = state.lock() { + state.last_error = Some(error.to_string()); + } + } + } + } + }) +} + +fn detect_reactive_document_change( + current: &Arc>, + state: &Arc>>, +) -> Result +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + let payload = { + let current = current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + serialize_typed(&*current)? + }; + let mut state = state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + if !state.dirty && !state.flushing && payload != state.last_clean_payload { + state.dirty = true; + } + Ok(state.dirty && !state.flushing) +} + +fn flush_reactive_document( + cache: &Arc>, + current: &Arc>, + state: &Arc>>, + record_key: &str, +) -> Result<()> +where + T: DatabaseEntry + Serialize + DeserializeOwned, +{ + let predicted = { + let mut state_guard = state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + if !state_guard.dirty { + drop(state_guard); + if !detect_reactive_document_change::(current, state)? { + return Ok(()); + } + state_guard = state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + } + if state_guard.flushing { + state_guard.flush_queued = true; + return Ok(()); + } + state_guard.flushing = true; + state_guard.dirty = false; + current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))? + .clone() + }; + let payload = serialize_typed(&predicted)?; + { + let mut cache = cache + .lock() + .map_err(|_| anyhow!("CultCache mutex poisoned"))?; + cache.put::(record_key.to_string(), &predicted)?; + } + let should_flush_again = { + let mut state = state + .lock() + .map_err(|_| anyhow!("reactive document state mutex poisoned"))?; + state.flushing = false; + state.last_clean_payload = payload; + state.last_error = None; + state.reconciliation = None; + let changed_after_flush = { + let current = current + .lock() + .map_err(|_| anyhow!("reactive document mutex poisoned"))?; + serialize_typed(&*current)? != state.last_clean_payload + }; + if changed_after_flush { + state.dirty = true; + } + let should_flush_again = state.flush_queued || state.dirty; + state.flush_queued = false; + should_flush_again + }; + if should_flush_again { + flush_reactive_document::(cache, current, state, record_key)?; + } + Ok(()) +} + +fn serialize_typed(value: &T) -> Result> +where + T: Serialize, +{ + Ok(rmp_serde::to_vec(value)?) +} + +fn create_reconciliation_delta(predicted: &T, canonical: &T) -> Result> +where + T: Serialize, +{ + let predicted = serde_json::to_value(predicted)?; + let canonical = serde_json::to_value(canonical)?; + let mut delta = BTreeMap::new(); + match (predicted, canonical) { + (Value::Object(predicted), Value::Object(canonical)) => { + for (key, predicted_value) in predicted { + let canonical_value = canonical.get(&key).cloned().unwrap_or(Value::Null); + if predicted_value == canonical_value { + continue; + } + delta.insert( + key, + numeric_delta(&predicted_value, &canonical_value).unwrap_or(predicted_value), + ); + } + } + (Value::Array(predicted), Value::Array(canonical)) => { + for (index, predicted_value) in predicted.into_iter().enumerate() { + let canonical_value = canonical.get(index).cloned().unwrap_or(Value::Null); + if predicted_value == canonical_value { + continue; + } + delta.insert( + index.to_string(), + numeric_delta(&predicted_value, &canonical_value).unwrap_or(predicted_value), + ); + } + } + (predicted, canonical) if predicted != canonical => { + delta.insert( + "value".to_string(), + numeric_delta(&predicted, &canonical).unwrap_or(predicted), + ); + } + _ => {} + } + Ok(delta) +} + +fn numeric_delta(predicted: &Value, canonical: &Value) -> Option { + let predicted = predicted.as_f64()?; + let canonical = canonical.as_f64()?; + serde_json::Number::from_f64(predicted - canonical).map(Value::Number) +} + fn round_trip_typed(value: &T) -> Result where T: Serialize + DeserializeOwned, diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 8ff65fa0..e72a93a1 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -17,6 +17,7 @@ use cultnet_rs::CultNetDocumentPutOptions; use cultnet_rs::CultNetDocumentRegistry; use cultnet_rs::CultNetMessage; use cultnet_rs::CultNetMutationAuthority; +use cultnet_rs::CultNetReactiveDocumentOptions; use cultnet_rs::CultNetReconnectController; use cultnet_rs::CultNetReconnectPolicyOptions; use cultnet_rs::CultNetRudpPacket; @@ -163,6 +164,30 @@ struct GhostlightAgentStateUiFixture { display_name: String, } +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "ghostlight.reactive-note", + schema = "GhostlightReactiveNoteFixture" +)] +struct GhostlightReactiveNoteFixture { + #[cultcache(key = 0)] + body: String, + #[cultcache(key = 1)] + revision: i32, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "ghostlight.reactive-note.ui", + schema = "GhostlightReactiveNoteUiFixture" +)] +struct GhostlightReactiveNoteUiFixture { + #[cultcache(key = 0)] + body: String, + #[cultcache(key = 1)] + revision: i32, +} + #[test] fn security_helpers_round_trip_encrypted_strings_and_validate_sessions() -> Result<()> { let server_security = CultNetServerSecurityOptions::development(); @@ -1766,6 +1791,149 @@ fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { Ok(()) } +#[test] +fn reactive_document_coalesces_direct_same_schema_alias_member_writes() -> Result<()> { + let temp = tempfile::tempdir()?; + let target_store = temp.path().join("target-reactive-alias.msgpack"); + let mut registry = CultNetDocumentRegistry::new(); + registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightReactiveNoteUiFixture, + >( + "ghostlight.reactive_note.v0".to_string(), + "ghostlight.reactive_note.v0".to_string(), + )); + let cache = Arc::new(Mutex::new(CultCache::new())); + { + let mut cache = cache.lock().expect("cache mutex"); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); + cache.pull_all_backing_stores()?; + cache.put( + "note:reactive", + &GhostlightReactiveNoteUiFixture { + body: "initial".to_string(), + revision: 1, + }, + )?; + } + + let reactive = registry.reactive_document::( + Arc::clone(&cache), + "note:reactive", + CultNetReactiveDocumentOptions { + flush_delay: StdDuration::from_millis(5), + ..CultNetReactiveDocumentOptions::default() + }, + )?; + { + let current = reactive.current(); + let mut current = current.lock().expect("reactive current mutex"); + current.body = "first-local-edit".to_string(); + current.body = "second-local-edit".to_string(); + current.revision = 2; + } + + for _ in 0..30 { + let body = cache + .lock() + .expect("cache mutex") + .get_required::("note:reactive")? + .body; + if body == "second-local-edit" { + break; + } + thread::sleep(StdDuration::from_millis(5)); + } + + let stored = cache + .lock() + .expect("cache mutex") + .get_required::("note:reactive")?; + assert_eq!(stored.body, "second-local-edit"); + assert_eq!(stored.revision, 2); + assert!(!reactive.is_dirty()); + assert!(reactive.last_error().is_none()); + Ok(()) +} + +#[test] +fn reactive_document_tracks_canonical_reconciliation_delta() -> Result<()> { + let temp = tempfile::tempdir()?; + let target_store = temp.path().join("target-reactive-reconcile.msgpack"); + let mut registry = CultNetDocumentRegistry::new(); + registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightReactiveNoteFixture, + >( + "ghostlight.reactive_note.v0".to_string(), + "ghostlight.reactive_note.v0".to_string(), + )); + let cache = Arc::new(Mutex::new(CultCache::new())); + { + let mut cache = cache.lock().expect("cache mutex"); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); + cache.pull_all_backing_stores()?; + cache.put( + "note:reconcile", + &GhostlightReactiveNoteFixture { + body: "initial".to_string(), + revision: 1, + }, + )?; + } + + let reactive = registry.reactive_document::( + Arc::clone(&cache), + "note:reconcile", + CultNetReactiveDocumentOptions { + flush_delay: StdDuration::from_secs(60), + detect_local_changes: false, + ..CultNetReactiveDocumentOptions::default() + }, + )?; + reactive.update(|note| { + note.body = "local-prediction".to_string(); + note.revision = 2; + })?; + let authoritative = registry.create_document_put_message( + "put-authoritative", + "note:reconcile", + &GhostlightReactiveNoteFixture { + body: "authoritative".to_string(), + revision: 7, + }, + CultNetDocumentPutOptions::default(), + )?; + + reactive.apply_document_put_message(&authoritative)?; + + let current = reactive.current(); + let current = current.lock().expect("reactive current mutex").clone(); + assert_eq!(current.body, "local-prediction"); + let reconciliation = reactive + .reconciliation() + .expect("dirty canonical apply records reconciliation"); + assert_eq!(reconciliation.canonical.body, "authoritative"); + assert_eq!(reconciliation.predicted.body, "local-prediction"); + assert_eq!( + reconciliation.delta.get("0"), + Some(&serde_json::json!("local-prediction")) + ); + assert_eq!( + reconciliation.delta.get("1"), + Some(&serde_json::json!(-5.0)) + ); + + reactive.flush()?; + let stored = cache + .lock() + .expect("cache mutex") + .get_required::("note:reconcile")?; + assert_eq!(stored.body, "local-prediction"); + assert!(reactive.reconciliation().is_none()); + Ok(()) +} + #[test] fn client_security_keeps_the_connection_key_visible_without_exposing_cipher_logic() -> Result<()> { let options = CultNetClientSecurityOptions::development(); From b155e32541dff4d1012b27ac2a999db38cf532bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 00:36:59 +0100 Subject: [PATCH 105/498] Add Kotlin reactive document sync --- packages/cultmesh-kotlin/README.md | 21 ++ .../kotlin/org/gamecult/cultmesh/CultMesh.kt | 324 ++++++++++++++++++ 2 files changed, 345 insertions(+) diff --git a/packages/cultmesh-kotlin/README.md b/packages/cultmesh-kotlin/README.md index b31f88af..3da68679 100644 --- a/packages/cultmesh-kotlin/README.md +++ b/packages/cultmesh-kotlin/README.md @@ -93,6 +93,27 @@ val note: UiNote = target.syncDocument(response, uiNote, "note:bridge") raw bytes re-enter the typed cache through the same document definitions that local Kotlin callers use. +Reactive document handles let Kotlin callers keep a typed local view and have +CultMesh flush changed state on the debounce cadence: + +```kotlin +val note = CultMesh.reactiveDocument( + target, + uiNote, + "note:bridge", + CultReactiveDocumentOptions(flushDelayMs = 16), +) + +note.current.body = "local prediction" +``` + +For immutable document types, use `update { it.copy(...) }` or +`setCurrent(...)`. When an authoritative raw document put arrives while local +state is dirty, `applyRawDocumentPut(...)` records a +`CultReactiveDocumentReconciliation` with the canonical value, predicted value, +and a delta. Tuple-backed MessagePack codecs report delta keys by field slot, +such as `"0"` and `"1"`. + ## WebSocket Transport For browser/mobile-friendly schema lanes, `CultNetWebSocketTransportConnection` diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 32567bef..968e7669 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -195,6 +195,202 @@ fun CultCache.document(document: CultDocumentDefinition, key: Strin fun CultCache.global(document: CultDocumentDefinition): CultCacheDocumentHandle = CultCacheDocumentHandle(this, document, CultCache.GLOBAL_KEY) +data class CultReactiveDocumentOptions( + val flushDelayMs: Long = 16, + val detectLocalChanges: Boolean = true, + val replaceDirtyCurrentOnCanonicalSnapshot: Boolean = false, +) + +data class CultReactiveDocumentReconciliation( + val canonical: T, + val predicted: T, + val delta: Map, + val version: Long, + val receivedAt: Instant, +) + +class CultReactiveDocument( + private val cache: CultCache, + private val document: CultDocumentDefinition, + private val key: String, + private val options: CultReactiveDocumentOptions = CultReactiveDocumentOptions(), +) : AutoCloseable { + private val lock = Any() + private val disposed = AtomicBoolean(false) + private var lastCleanPayload = ByteArray(0) + private var flushing = false + private var flushQueued = false + private var reconciliationVersion = 0L + private val detectWorker: Thread? + + @Volatile + var current: T = cache.getRequired(document, key) + private set + + @Volatile + var isDirty: Boolean = false + private set + + @Volatile + var reconciliation: CultReactiveDocumentReconciliation? = null + private set + + @Volatile + var lastError: Throwable? = null + private set + + init { + require(key.isNotBlank()) { "key must be non-empty" } + lastCleanPayload = document.codec.encode(current) + detectWorker = if (options.detectLocalChanges) { + Thread { + val delay = options.flushDelayMs.coerceAtLeast(1) + while (!disposed.get()) { + try { + Thread.sleep(delay) + if (disposed.get()) return@Thread + if (detectLocalChanges()) flush() + } catch (_: InterruptedException) { + if (disposed.get()) return@Thread + } catch (error: Throwable) { + lastError = error + } + } + }.apply { + isDaemon = true + name = "cultmesh-reactive-document-${document.documentType}-$key" + start() + } + } else { + null + } + } + + fun update(update: (T) -> T): T { + val predicted = synchronized(lock) { + current = update(current) + isDirty = true + current + } + if (options.flushDelayMs <= 0) flush() + return predicted + } + + fun setCurrent(value: T): T { + synchronized(lock) { + current = value + isDirty = true + } + if (options.flushDelayMs <= 0) flush() + return value + } + + fun markDirty() { + synchronized(lock) { + isDirty = true + } + } + + fun refresh(): T { + val canonical = cache.getRequired(document, key) + synchronized(lock) { + current = canonical + lastCleanPayload = document.codec.encode(canonical) + isDirty = false + flushQueued = false + reconciliation = null + } + return canonical + } + + fun flush() { + val predicted = synchronized(lock) { + if (!isDirty && !detectLocalChangesLocked()) return + if (flushing) { + flushQueued = true + return + } + flushing = true + isDirty = false + current + } + val payload = document.codec.encode(predicted) + cache.put(document, key, predicted) + val shouldFlushAgain = synchronized(lock) { + flushing = false + lastCleanPayload = payload + reconciliation = null + lastError = null + if (!document.codec.encode(current).contentEquals(lastCleanPayload)) { + isDirty = true + } + val queued = flushQueued || isDirty + flushQueued = false + queued + } + if (shouldFlushAgain) flush() + } + + fun applyRawDocumentPut(message: CultNetMessage): T { + cache.applyRawDocumentPut(message) + return applyCanonical(cache.getRequired(document, key)) + } + + fun clearReconciliation() { + synchronized(lock) { + reconciliation = null + } + } + + override fun close() { + disposed.set(true) + detectWorker?.interrupt() + } + + private fun detectLocalChanges(): Boolean = synchronized(lock) { + detectLocalChangesLocked() && !flushing + } + + private fun detectLocalChangesLocked(): Boolean { + if (!isDirty && !flushing && !document.codec.encode(current).contentEquals(lastCleanPayload)) { + isDirty = true + } + return isDirty + } + + private fun applyCanonical(canonical: T): T { + synchronized(lock) { + if (isDirty || flushing) { + val predicted = current + val delta = reconciliationDelta(document.codec.encode(predicted), document.codec.encode(canonical)) + reconciliation = if (delta.isEmpty()) { + null + } else { + reconciliationVersion += 1 + CultReactiveDocumentReconciliation( + canonical = canonical, + predicted = predicted, + delta = delta, + version = reconciliationVersion, + receivedAt = Instant.now(), + ) + } + if (!options.replaceDirtyCurrentOnCanonicalSnapshot) return canonical + } + current = canonical + lastCleanPayload = document.codec.encode(canonical) + reconciliation = null + return canonical + } + } +} + +fun CultCache.reactiveDocument( + document: CultDocumentDefinition, + key: String, + options: CultReactiveDocumentOptions = CultReactiveDocumentOptions(), +): CultReactiveDocument = CultReactiveDocument(this, document, key, options) + class StringDocumentCodec( override val documentType: String, override val schemaVersion: String, @@ -235,6 +431,11 @@ private data class KotlinUiNote( val body: String, ) +private data class KotlinReactiveNote( + var body: String, + var revision: Long, +) + private class KotlinAliasNoteCodec( override val documentType: String, override val schemaVersion: String, @@ -253,6 +454,21 @@ private class KotlinAliasNoteCodec( } } +private class KotlinReactiveNoteCodec( + override val documentType: String, + override val schemaVersion: String, +) : CultDocumentCodec { + override fun encode(value: KotlinReactiveNote): ByteArray = + MessagePackWriter() + .value(listOf(value.body, value.revision)) + .toByteArray() + + override fun decode(payload: ByteArray): KotlinReactiveNote { + val slots = anyList(MessagePackReader(payload).readAny()) + return KotlinReactiveNote(slots[0] as String, (slots[1] as Number).toLong()) + } +} + class CultMeshNode( val cache: CultCache = CultCache(), private val random: SecureRandom = SecureRandom(), @@ -301,6 +517,12 @@ class CultMeshNode( fun syncDocument(response: CultNetMessage, document: CultDocumentDefinition, key: String): T = cache.syncDocument(response, document, key) + fun reactiveDocument( + document: CultDocumentDefinition, + key: String, + options: CultReactiveDocumentOptions = CultReactiveDocumentOptions(), + ): CultReactiveDocument = cache.reactiveDocument(document, key, options) + fun applyDocumentDelete(message: CultNetMessage): Boolean = cache.applyDocumentDelete(message) fun applyShardLogResponse(response: CultNetMessage): List = cache.applyShardLogResponse(response) @@ -311,6 +533,13 @@ object CultMesh { fun startNode(cache: CultCache = CultCache()): CultMeshNode = createNode(cache) + fun reactiveDocument( + node: CultMeshNode, + document: CultDocumentDefinition, + key: String, + options: CultReactiveDocumentOptions = CultReactiveDocumentOptions(), + ): CultReactiveDocument = node.reactiveDocument(document, key, options) + fun createVerseCatalog(): CultMeshVerseCatalog = CultMeshVerseCatalog() fun createPeerCatalog(): CultMeshPeerCatalog = CultMeshPeerCatalog() @@ -2255,6 +2484,39 @@ private fun mapValue(value: Any?): Map { return map } +private fun reconciliationDelta(predictedPayload: ByteArray, canonicalPayload: ByteArray): Map { + val predicted = MessagePackReader(predictedPayload).readAny() + val canonical = MessagePackReader(canonicalPayload).readAny() + val delta = linkedMapOf() + when { + predicted is Map<*, *> && canonical is Map<*, *> -> { + val canonicalMap = mapValue(canonical) + for ((key, predictedValue) in mapValue(predicted)) { + val canonicalValue = canonicalMap[key] + if (predictedValue == canonicalValue) continue + delta[key] = numericDelta(predictedValue, canonicalValue) ?: predictedValue + } + } + predicted is List<*> && canonical is List<*> -> { + predicted.forEachIndexed { index, predictedValue -> + val canonicalValue = canonical.getOrNull(index) + if (predictedValue == canonicalValue) return@forEachIndexed + delta[index.toString()] = numericDelta(predictedValue, canonicalValue) ?: predictedValue + } + } + predicted != canonical -> { + delta["value"] = numericDelta(predicted, canonical) ?: predicted + } + } + return delta +} + +private fun numericDelta(predicted: Any?, canonical: Any?): Double? { + val predictedNumber = predicted as? Number ?: return null + val canonicalNumber = canonical as? Number ?: return null + return predictedNumber.toDouble() - canonicalNumber.toDouble() +} + private fun sha256Hex(value: String): String { val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(StandardCharsets.UTF_8)) return digest.joinToString("") { "%02x".format(it.toInt() and 0xff) } @@ -3162,6 +3424,68 @@ private fun cultCacheRawSnapshotsRoundTripThroughCultNetMessages() { check(syncedAlias == KotlinUiNote("kotlin.alias_note.v1", "canonical-to-ui")) check(target.require(uiNote, "note:alias") == syncedAlias) + val reactiveUiNote = cultDocument(KotlinReactiveNoteCodec( + documentType = "kotlin.reactive_note.ui", + schemaVersion = "kotlin.reactive_note.v1", + )) + target.cache.register(reactiveUiNote) + target.remember(reactiveUiNote, "note:reactive", KotlinReactiveNote("initial", 1)) + CultMesh.reactiveDocument( + target, + reactiveUiNote, + "note:reactive", + CultReactiveDocumentOptions(flushDelayMs = 5), + ).use { reactive -> + reactive.current.body = "first-local-edit" + reactive.current.body = "second-local-edit" + reactive.current.revision = 2 + val deadline = System.nanoTime() + 500_000_000L + while (System.nanoTime() < deadline && target.require(reactiveUiNote, "note:reactive").body != "second-local-edit") { + Thread.sleep(5) + } + check(target.require(reactiveUiNote, "note:reactive") == KotlinReactiveNote("second-local-edit", 2)) + check(!reactive.isDirty) + check(reactive.lastError == null) + } + + val reactiveCanonicalNote = cultDocument(KotlinReactiveNoteCodec( + documentType = "kotlin.reactive_note", + schemaVersion = "kotlin.reactive_note.v1", + )) + target.cache.register(reactiveCanonicalNote) + target.remember(reactiveCanonicalNote, "note:reconcile", KotlinReactiveNote("initial", 1)) + target.reactiveDocument( + reactiveCanonicalNote, + "note:reconcile", + CultReactiveDocumentOptions(flushDelayMs = 60_000, detectLocalChanges = false), + ).use { reactive -> + reactive.update { + it.body = "local-prediction" + it.revision = 2 + it + } + val authoritative = cultNetDocumentPutRaw( + messageId = "put-authoritative-note", + document = CultNetRawDocumentRecord( + schemaId = reactiveCanonicalNote.schemaVersion, + recordKey = "note:reconcile", + storedAt = "2026-06-15T00:00:03Z", + payload = reactiveCanonicalNote.codec.encode(KotlinReactiveNote("authoritative", 7)), + sourceRuntimeId = "kotlin-authority", + ), + ) + reactive.applyRawDocumentPut(authoritative) + check(reactive.current == KotlinReactiveNote("local-prediction", 2)) + val reconciliation = reactive.reconciliation ?: error("Expected Kotlin reactive reconciliation") + check(reconciliation.canonical == KotlinReactiveNote("authoritative", 7)) + check(reconciliation.predicted == KotlinReactiveNote("local-prediction", 2)) + check(reconciliation.delta["0"] == "local-prediction") + check(reconciliation.delta["1"] == -5.0) + reactive.flush() + check(target.require(reactiveCanonicalNote, "note:reconcile") == KotlinReactiveNote("local-prediction", 2)) + check(reactive.reconciliation == null) + } + val rawPut = parseCultNetMessage( cultNetDocumentPutRaw( messageId = "put-note", From 743d7688f0894b223c09ac2570ce24cac1e38393 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 07:54:00 +0100 Subject: [PATCH 106/498] Add CultMesh distributed asset packages --- src/GameCult.Mesh/CultMesh.cs | 109 ++++ src/GameCult.Mesh/CultMeshCdn.cs | 578 ++++++++++++++++++ src/GameCult.Mesh/CultMeshEntityPrefabs.cs | 395 ++++++++++++ src/GameCult.Mesh/README.md | 18 + src/GameCult.Mesh/docs/distributed-cdn.md | 197 ++++++ tests/GameCult.Mesh.Tests/CultMeshCdnTests.cs | 123 ++++ .../CultMeshEntityPrefabTests.cs | 197 ++++++ 7 files changed, 1617 insertions(+) create mode 100644 src/GameCult.Mesh/CultMeshCdn.cs create mode 100644 src/GameCult.Mesh/CultMeshEntityPrefabs.cs create mode 100644 src/GameCult.Mesh/docs/distributed-cdn.md create mode 100644 tests/GameCult.Mesh.Tests/CultMeshCdnTests.cs create mode 100644 tests/GameCult.Mesh.Tests/CultMeshEntityPrefabTests.cs diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index dab5dd28..460584b6 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -380,6 +380,7 @@ namespace GameCult.Mesh return local; } } + /// /// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade. /// @@ -680,6 +681,110 @@ namespace GameCult.Mesh return new CultMeshStreamCatalog(); } + /// + /// Packs bytes into content-addressed CDN chunk documents and a versioned artifact manifest. + /// + public static CultMeshCdnArtifact PackCdnArtifact( + string artifactId, + byte[] payload, + CultMeshCdnPackOptions? options = null) + { + return CultMeshCdn.PackArtifact(artifactId, payload, options); + } + + /// + /// Publishes a packed CDN artifact into a local cache. + /// + public static Task PublishCdnArtifactAsync( + CultCache cache, + CultMeshCdnArtifact artifact) + { + return CultMeshCdn.PublishAsync(cache, artifact); + } + + /// + /// Publishes a packed CDN artifact into a distributed CultNet database. + /// + public static Task PublishCdnArtifactAsync( + CultNetDatabase database, + CultMeshCdnArtifact artifact) + { + return CultMeshCdn.PublishAsync(database, artifact); + } + + /// + /// Reassembles and verifies a CDN artifact from a local cache. + /// + public static byte[] ReadCdnArtifact( + CultCache cache, + CultMeshCdnArtifactManifest manifest) + { + return CultMeshCdn.ReadArtifact(cache, manifest); + } + + /// + /// Reassembles and verifies a CDN artifact from a distributed CultNet database. + /// + public static Task ReadCdnArtifactAsync( + CultNetDatabase database, + CultMeshCdnArtifactManifest manifest) + { + return CultMeshCdn.ReadArtifactAsync(database, manifest); + } + + /// + /// Creates a CultNet document registry for CDN artifact manifests and chunks. + /// + public static CultNetDocumentRegistry CreateCdnDocumentRegistry(CultDocumentRegistry? documents = null) + { + return CultMeshCdn.CreateDocumentRegistry(documents); + } + + /// + /// Computes and stamps the content hash for a portable entity prefab package. + /// + public static CultMeshEntityPrefabPackage FinalizeEntityPrefabPackage( + CultMeshEntityPrefabPackage package) + { + return CultMeshEntityPrefabs.FinalizePackage(package); + } + + /// + /// Publishes a portable entity prefab package into a local cache. + /// + public static Task PublishEntityPrefabPackageAsync( + CultCache cache, + CultMeshEntityPrefabPackage package) + { + return CultMeshEntityPrefabs.PublishAsync(cache, package); + } + + /// + /// Publishes a portable entity prefab package into a distributed CultNet database. + /// + public static Task PublishEntityPrefabPackageAsync( + CultNetDatabase database, + CultMeshEntityPrefabPackage package) + { + return CultMeshEntityPrefabs.PublishAsync(database, package); + } + + /// + /// Creates a CultNet document registry for portable entity prefab packages. + /// + public static CultNetDocumentRegistry CreateEntityPrefabDocumentRegistry(CultDocumentRegistry? documents = null) + { + return CultMeshEntityPrefabs.CreateDocumentRegistry(documents); + } + + /// + /// Creates a CultNet document registry for CDN assets and portable entity prefab packages. + /// + public static CultNetDocumentRegistry CreateAssetPipelineDocumentRegistry(CultDocumentRegistry? documents = null) + { + return CultMeshEntityPrefabs.CreateAssetPipelineDocumentRegistry(documents); + } + /// /// Creates a managed Verse context for generated domain sugar. /// @@ -1700,6 +1805,7 @@ namespace GameCult.Mesh if (verse == null) throw new ArgumentNullException(nameof(verse)); return DocumentsFromPublication(source, bindings, verse.Context, storeOptions, peerOptions); } + /// /// Reads configured publication documents, hydrates them into a local node, and returns local handles as a catalog. /// @@ -1751,6 +1857,7 @@ namespace GameCult.Mesh peerOptions, flush); } + /// /// Reads one typed document from a configured publication source and hydrates it into a local node. /// @@ -1834,6 +1941,7 @@ namespace GameCult.Mesh peerOptions, flush); } + internal static CultMeshStoreDocumentOptions WithPublicationBindingOptions( CultMeshStoreDocumentOptions? options, CultRecordKey key, @@ -1881,6 +1989,7 @@ namespace GameCult.Mesh RudpResendDelayMs = options?.RudpResendDelayMs ?? 25 }; } + /// /// Creates a typed document handle directly over one distributed CultNet database record. /// diff --git a/src/GameCult.Mesh/CultMeshCdn.cs b/src/GameCult.Mesh/CultMeshCdn.cs new file mode 100644 index 00000000..72f38c6a --- /dev/null +++ b/src/GameCult.Mesh/CultMeshCdn.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; + +namespace GameCult.Mesh +{ + /// + /// Schema versions for CultMesh distributed CDN documents. + /// + public static class CultMeshCdnSchemaVersions + { + /// Manifest document schema id. + public const string ArtifactManifest = "gamecult.mesh.cdn_artifact_manifest.v1"; + /// Chunk document schema id. + public const string ArtifactChunk = "gamecult.mesh.cdn_artifact_chunk.v1"; + } + + /// + /// Well-known CultMesh CDN artifact kinds. + /// + public static class CultMeshCdnArtifactKinds + { + /// Game, UI, shader, texture, audio, or data asset payload. + public const string Asset = "asset"; + /// Executable build or install/update payload. + public const string Build = "build"; + /// Generic packaged payload. + public const string Package = "package"; + } + + /// + /// Options for packing bytes into CultMesh CDN documents. + /// + public sealed class CultMeshCdnPackOptions + { + /// Default CDN chunk size. + public const int DefaultChunkSizeBytes = 256 * 1024; + + /// Maximum bytes per content chunk. + public int ChunkSizeBytes { get; set; } = DefaultChunkSizeBytes; + + /// Artifact kind, such as asset or build. + public string Kind { get; set; } = CultMeshCdnArtifactKinds.Asset; + + /// Caller-defined artifact version. + public string Version { get; set; } = string.Empty; + + /// Artifact MIME type. + public string MimeType { get; set; } = "application/octet-stream"; + + /// Search and routing tags copied to the manifest. + public string[] Tags { get; set; } = Array.Empty(); + + /// Caller metadata copied to the manifest. + public Dictionary Metadata { get; set; } = new(); + + /// Override manifest creation timestamp in round-trip UTC format. + public string? CreatedAtUtc { get; set; } + } + + /// + /// Reference from an artifact manifest to one content-addressed chunk document. + /// + [MessagePackObject] + public sealed class CultMeshCdnChunkRef + { + /// SHA-256 hash of the referenced chunk payload. + [Key(0)] + public string ChunkHash { get; set; } = string.Empty; + + /// Byte offset of this chunk inside the materialized artifact. + [Key(1)] + public long Offset { get; set; } + + /// Number of payload bytes contributed by this chunk. + [Key(2)] + public int SizeBytes { get; set; } + + /// CultCache record key for the referenced chunk document. + [Key(3)] + public string RecordKey { get; set; } = string.Empty; + } + + /// + /// Content-addressed artifact chunk stored in CultCache and replicated through CultNet. + /// + [MessagePackObject] + [CultDocument("gamecult.mesh.cdn_artifact_chunk", CultMeshCdnSchemaVersions.ArtifactChunk)] + public sealed class CultMeshCdnArtifactChunk + { + /// SHA-256 hash of the chunk payload. + [Key(0)] + [CultName] + public string ChunkHash { get; set; } = string.Empty; + + /// Payload byte count. + [Key(1)] + public int SizeBytes { get; set; } + + /// Raw chunk payload bytes. + [Key(2)] + public byte[] Payload { get; set; } = Array.Empty(); + + /// Builds the deterministic content-addressed chunk record key. + public static CultRecordKey CreateRecordKey(CultMeshCdnArtifactChunk chunk) + { + if (chunk == null) throw new ArgumentNullException(nameof(chunk)); + return CreateRecordKey(chunk.ChunkHash); + } + + /// Builds the deterministic content-addressed chunk record key. + public static CultRecordKey CreateRecordKey(string chunkHash) + { + return new CultRecordKey("mesh:cdn:chunk:" + CultMeshCdn.NormalizeHash(chunkHash, nameof(chunkHash))); + } + } + + /// + /// Manifest for one versioned CDN artifact assembled from content-addressed chunks. + /// + [MessagePackObject] + [CultDocument("gamecult.mesh.cdn_artifact_manifest", CultMeshCdnSchemaVersions.ArtifactManifest)] + public sealed class CultMeshCdnArtifactManifest + { + /// Stable artifact id, such as a logical asset or build path. + [Key(0)] + [CultName] + public string ArtifactId { get; set; } = string.Empty; + + /// Artifact kind, such as asset, build, or package. + [Key(1)] + [CultIndex] + public string Kind { get; set; } = CultMeshCdnArtifactKinds.Asset; + + /// Caller-defined artifact version. + [Key(2)] + [CultIndex] + public string Version { get; set; } = string.Empty; + + /// SHA-256 hash of the full materialized artifact. + [Key(3)] + [CultIndex] + public string ContentHash { get; set; } = string.Empty; + + /// Full materialized artifact size. + [Key(4)] + public long SizeBytes { get; set; } + + /// Artifact MIME type. + [Key(5)] + public string MimeType { get; set; } = "application/octet-stream"; + + /// Manifest creation timestamp in round-trip UTC format. + [Key(6)] + public string CreatedAtUtc { get; set; } = string.Empty; + + /// Ordered chunk references needed to materialize the artifact. + [Key(7)] + public CultMeshCdnChunkRef[] Chunks { get; set; } = Array.Empty(); + + /// Search and routing tags. + [Key(8)] + public string[] Tags { get; set; } = Array.Empty(); + + /// Caller metadata, such as platform or texture channel layout. + [Key(9)] + public Dictionary Metadata { get; set; } = new(); + + /// Builds the deterministic versioned artifact manifest record key. + public static CultRecordKey CreateRecordKey(CultMeshCdnArtifactManifest manifest) + { + if (manifest == null) throw new ArgumentNullException(nameof(manifest)); + return CreateRecordKey(manifest.ArtifactId, manifest.Version, manifest.ContentHash); + } + + /// Builds the deterministic versioned artifact manifest record key. + public static CultRecordKey CreateRecordKey(string artifactId, string version, string contentHash) + { + var artifactToken = CultMeshCdn.StableToken(artifactId, nameof(artifactId)); + var versionToken = string.IsNullOrWhiteSpace(version) + ? "unversioned" + : CultMeshCdn.StableToken(version, nameof(version)); + return new CultRecordKey( + "mesh:cdn:artifact:" + artifactToken + ":" + versionToken + ":" + CultMeshCdn.NormalizeHash(contentHash, nameof(contentHash))); + } + } + + /// + /// Packed CDN artifact ready to publish into a cache or database. + /// + public sealed class CultMeshCdnArtifact + { + /// Creates a packed artifact container. + public CultMeshCdnArtifact( + CultMeshCdnArtifactManifest manifest, + IReadOnlyList chunks) + { + Manifest = manifest ?? throw new ArgumentNullException(nameof(manifest)); + Chunks = chunks ?? throw new ArgumentNullException(nameof(chunks)); + } + + /// Artifact manifest document. + public CultMeshCdnArtifactManifest Manifest { get; } + + /// Content-addressed chunk documents. + public IReadOnlyList Chunks { get; } + + /// Deterministic manifest record key. + public CultRecordKey ManifestKey => CultMeshCdnArtifactManifest.CreateRecordKey(Manifest); + + /// All unique record keys required to replicate this artifact. + public IReadOnlyList RecordKeys => + new[] { ManifestKey }.Concat(Chunks.Select(CultMeshCdnArtifactChunk.CreateRecordKey)).Distinct().ToArray(); + } + + /// + /// Content-addressed byte artifact helpers for distributing assets and builds through CultMesh clients. + /// + public static class CultMeshCdn + { + /// + /// Splits a payload into content-addressed chunk documents and a versioned manifest. + /// + public static CultMeshCdnArtifact PackArtifact( + string artifactId, + byte[] payload, + CultMeshCdnPackOptions? options = null) + { + if (string.IsNullOrWhiteSpace(artifactId)) + { + throw new ArgumentException("Artifact id must be non-empty.", nameof(artifactId)); + } + + if (payload == null) throw new ArgumentNullException(nameof(payload)); + options ??= new CultMeshCdnPackOptions(); + if (options.ChunkSizeBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "ChunkSizeBytes must be greater than zero."); + } + + var chunks = new List(); + var refs = new List(); + for (var offset = 0; offset < payload.Length; offset += options.ChunkSizeBytes) + { + var count = Math.Min(options.ChunkSizeBytes, payload.Length - offset); + var chunkPayload = new byte[count]; + Buffer.BlockCopy(payload, offset, chunkPayload, 0, count); + var chunkHash = HashBytes(chunkPayload); + var chunk = new CultMeshCdnArtifactChunk + { + ChunkHash = chunkHash, + SizeBytes = count, + Payload = chunkPayload + }; + chunks.Add(chunk); + refs.Add(new CultMeshCdnChunkRef + { + ChunkHash = chunkHash, + Offset = offset, + SizeBytes = count, + RecordKey = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value + }); + } + + if (payload.Length == 0) + { + var chunkHash = HashBytes(Array.Empty()); + var chunk = new CultMeshCdnArtifactChunk + { + ChunkHash = chunkHash, + SizeBytes = 0, + Payload = Array.Empty() + }; + chunks.Add(chunk); + refs.Add(new CultMeshCdnChunkRef + { + ChunkHash = chunkHash, + Offset = 0, + SizeBytes = 0, + RecordKey = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value + }); + } + + var manifest = new CultMeshCdnArtifactManifest + { + ArtifactId = artifactId, + Kind = string.IsNullOrWhiteSpace(options.Kind) ? CultMeshCdnArtifactKinds.Asset : options.Kind, + Version = options.Version ?? string.Empty, + ContentHash = HashBytes(payload), + SizeBytes = payload.LongLength, + MimeType = string.IsNullOrWhiteSpace(options.MimeType) ? "application/octet-stream" : options.MimeType, + CreatedAtUtc = options.CreatedAtUtc ?? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + Chunks = refs.ToArray(), + Tags = options.Tags ?? Array.Empty(), + Metadata = options.Metadata ?? new Dictionary() + }; + + return new CultMeshCdnArtifact(manifest, chunks); + } + + /// + /// Publishes all chunk documents and the manifest into a local CultCache. + /// + public static async Task PublishAsync( + CultCache cache, + CultMeshCdnArtifact artifact) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (artifact == null) throw new ArgumentNullException(nameof(artifact)); + + ValidateArtifact(artifact); + foreach (var chunk in artifact.Chunks) + { + await cache.UpsertAsync( + chunk, + new CultRecordHandle( + CultMeshCdnArtifactChunk.CreateRecordKey(chunk))).ConfigureAwait(false); + } + + await cache.UpsertAsync( + artifact.Manifest, + new CultRecordHandle( + CultMeshCdnArtifactManifest.CreateRecordKey(artifact.Manifest))).ConfigureAwait(false); + + return artifact.Manifest; + } + + /// + /// Publishes all chunk documents and the manifest into a distributed CultNet database. + /// + public static async Task PublishAsync( + CultNetDatabase database, + CultMeshCdnArtifact artifact) + { + if (database == null) throw new ArgumentNullException(nameof(database)); + if (artifact == null) throw new ArgumentNullException(nameof(artifact)); + + ValidateArtifact(artifact); + foreach (var chunk in artifact.Chunks) + { + await database.PutAsync(CultMeshCdnArtifactChunk.CreateRecordKey(chunk), chunk) + .ConfigureAwait(false); + } + + await database.PutAsync(CultMeshCdnArtifactManifest.CreateRecordKey(artifact.Manifest), artifact.Manifest) + .ConfigureAwait(false); + return artifact.Manifest; + } + + /// + /// Reassembles and verifies an artifact from a local CultCache. + /// + public static byte[] ReadArtifact(CultCache cache, CultMeshCdnArtifactManifest manifest) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (manifest == null) throw new ArgumentNullException(nameof(manifest)); + + ValidateManifestShape(manifest); + var output = new byte[checked((int)manifest.SizeBytes)]; + foreach (var reference in manifest.Chunks.OrderBy(chunk => chunk.Offset)) + { + var recordKey = string.IsNullOrWhiteSpace(reference.RecordKey) + ? CultMeshCdnArtifactChunk.CreateRecordKey(reference.ChunkHash) + : new CultRecordKey(reference.RecordKey); + var chunk = cache.Get(recordKey) + ?? throw new FileNotFoundException("CDN artifact chunk is missing from the cache.", recordKey.Value); + ValidateChunk(reference, chunk); + Buffer.BlockCopy(chunk.Payload, 0, output, checked((int)reference.Offset), reference.SizeBytes); + } + + var actualHash = HashBytes(output); + if (!string.Equals(NormalizeHash(manifest.ContentHash, nameof(manifest.ContentHash)), actualHash, StringComparison.Ordinal)) + { + throw new InvalidDataException("CDN artifact content hash does not match its manifest."); + } + + return output; + } + + /// + /// Reassembles and verifies an artifact from a distributed CultNet database. + /// + public static async Task ReadArtifactAsync( + CultNetDatabase database, + CultMeshCdnArtifactManifest manifest) + { + if (database == null) throw new ArgumentNullException(nameof(database)); + if (manifest == null) throw new ArgumentNullException(nameof(manifest)); + + ValidateManifestShape(manifest); + var output = new byte[checked((int)manifest.SizeBytes)]; + foreach (var reference in manifest.Chunks.OrderBy(chunk => chunk.Offset)) + { + var recordKey = string.IsNullOrWhiteSpace(reference.RecordKey) + ? CultMeshCdnArtifactChunk.CreateRecordKey(reference.ChunkHash) + : new CultRecordKey(reference.RecordKey); + var chunk = await database.GetAsync(recordKey).ConfigureAwait(false) + ?? throw new FileNotFoundException("CDN artifact chunk is missing from the database.", recordKey.Value); + ValidateChunk(reference, chunk); + Buffer.BlockCopy(chunk.Payload, 0, output, checked((int)reference.Offset), reference.SizeBytes); + } + + var actualHash = HashBytes(output); + if (!string.Equals(NormalizeHash(manifest.ContentHash, nameof(manifest.ContentHash)), actualHash, StringComparison.Ordinal)) + { + throw new InvalidDataException("CDN artifact content hash does not match its manifest."); + } + + return output; + } + + /// + /// Creates a raw CultNet document registry for CDN manifests and chunks. + /// + public static CultNetDocumentRegistry CreateDocumentRegistry(CultDocumentRegistry? documents = null) + { + documents ??= CultDocumentRegistry.Shared; + return new CultNetDocumentRegistry(documents) + .Register(CultNetDocumentBinding.ForDocument(documents)) + .Register(CultNetDocumentBinding.ForDocument(documents)); + } + + internal static string HashBytes(byte[] bytes) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(bytes); + var builder = new StringBuilder(hash.Length * 2); + foreach (var value in hash) + { + builder.Append(value.ToString("x2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } + + private static void ValidateArtifact(CultMeshCdnArtifact artifact) + { + ValidateManifestShape(artifact.Manifest); + var expectedRefs = artifact.Chunks + .Select(chunk => new + { + Hash = HashBytes(chunk.Payload), + ChunkHash = NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash)), + chunk.SizeBytes, + Key = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value + }) + .ToArray(); + + if (artifact.Manifest.Chunks.Length != expectedRefs.Length) + { + throw new InvalidDataException("CDN artifact manifest chunk count does not match chunk payloads."); + } + + for (var i = 0; i < expectedRefs.Length; i++) + { + var reference = artifact.Manifest.Chunks[i]; + var expected = expectedRefs[i]; + if (!string.Equals(NormalizeHash(reference.ChunkHash, nameof(reference.ChunkHash)), expected.Hash, StringComparison.Ordinal) || + !string.Equals(expected.ChunkHash, expected.Hash, StringComparison.Ordinal) || + reference.SizeBytes != expected.SizeBytes || + !string.Equals(reference.RecordKey, expected.Key, StringComparison.Ordinal)) + { + throw new InvalidDataException("CDN artifact manifest chunk reference does not match its chunk payload."); + } + } + + var materialized = new byte[checked((int)artifact.Manifest.SizeBytes)]; + foreach (var reference in artifact.Manifest.Chunks.OrderBy(chunk => chunk.Offset)) + { + var chunk = artifact.Chunks.First(candidate => + string.Equals( + CultMeshCdnArtifactChunk.CreateRecordKey(candidate).Value, + reference.RecordKey, + StringComparison.Ordinal)); + Buffer.BlockCopy(chunk.Payload, 0, materialized, checked((int)reference.Offset), reference.SizeBytes); + } + + if (!string.Equals( + NormalizeHash(artifact.Manifest.ContentHash, nameof(artifact.Manifest.ContentHash)), + HashBytes(materialized), + StringComparison.Ordinal)) + { + throw new InvalidDataException("CDN artifact content hash does not match its manifest."); + } + } + + private static void ValidateManifestShape(CultMeshCdnArtifactManifest manifest) + { + if (manifest.SizeBytes < 0) + { + throw new InvalidDataException("CDN artifact manifest has a negative size."); + } + + if (manifest.SizeBytes > int.MaxValue) + { + throw new InvalidDataException("CDN artifact is too large to materialize into one byte array."); + } + + var expectedOffset = 0L; + foreach (var reference in manifest.Chunks.OrderBy(chunk => chunk.Offset)) + { + if (reference.Offset != expectedOffset) + { + throw new InvalidDataException("CDN artifact chunks are not contiguous."); + } + + if (reference.SizeBytes < 0) + { + throw new InvalidDataException("CDN artifact chunk has a negative size."); + } + + expectedOffset += reference.SizeBytes; + } + + if (expectedOffset != manifest.SizeBytes) + { + throw new InvalidDataException("CDN artifact chunk sizes do not sum to the manifest size."); + } + } + + private static void ValidateChunk(CultMeshCdnChunkRef reference, CultMeshCdnArtifactChunk chunk) + { + if (chunk.Payload.Length != reference.SizeBytes || + chunk.SizeBytes != reference.SizeBytes) + { + throw new InvalidDataException("CDN artifact chunk payload metadata does not match its manifest reference."); + } + + var actualHash = HashBytes(chunk.Payload); + if (!string.Equals(NormalizeHash(reference.ChunkHash, nameof(reference.ChunkHash)), actualHash, StringComparison.Ordinal) || + !string.Equals(NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash)), actualHash, StringComparison.Ordinal)) + { + throw new InvalidDataException("CDN artifact chunk hash does not match its payload."); + } + } + + internal static string NormalizeHash(string hash, string parameterName) + { + if (string.IsNullOrWhiteSpace(hash)) + { + throw new ArgumentException("Hash must be non-empty.", parameterName); + } + + var value = hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) + ? hash.Substring("sha256:".Length) + : hash; + return value.Trim().ToLowerInvariant(); + } + + internal static string StableToken(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value must be non-empty.", parameterName); + } + + var normalized = value.Trim().ToLowerInvariant(); + var builder = new StringBuilder(normalized.Length); + foreach (var c in normalized) + { + builder.Append(char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.' ? c : '-'); + } + + var token = builder.ToString().Trim('-'); + return string.IsNullOrWhiteSpace(token) + ? HashBytes(Encoding.UTF8.GetBytes(value)) + : token; + } + } +} diff --git a/src/GameCult.Mesh/CultMeshEntityPrefabs.cs b/src/GameCult.Mesh/CultMeshEntityPrefabs.cs new file mode 100644 index 00000000..194c9360 --- /dev/null +++ b/src/GameCult.Mesh/CultMeshEntityPrefabs.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; + +namespace GameCult.Mesh +{ + /// + /// Schema versions for portable entity prefab packages. + /// + public static class CultMeshEntityPrefabSchemaVersions + { + /// Portable prefab package schema id. + public const string Package = "gamecult.mesh.entity_prefab_package.v1"; + } + + /// + /// Well-known portable prefab node kinds. + /// + public static class CultMeshEntityPrefabNodeKinds + { + /// Transform-only node. + public const string Empty = "empty"; + /// Renderable mesh node. + public const string Mesh = "mesh"; + /// Collider node. + public const string Collider = "collider"; + /// Particle or projectile effect node. + public const string Effect = "effect"; + /// Camera or view anchor node. + public const string Camera = "camera"; + /// Runtime attachment point. + public const string Socket = "socket"; + } + + /// + /// Well-known prefab asset roles. + /// + public static class CultMeshEntityPrefabAssetRoles + { + /// Mesh geometry payload. + public const string Mesh = "mesh"; + /// Texture image payload. + public const string Texture = "texture"; + /// Material definition payload. + public const string Material = "material"; + /// Animation clip payload. + public const string Animation = "animation"; + /// Audio payload. + public const string Audio = "audio"; + } + + /// + /// Portable transform using Blender/CultMesh source coordinates before runtime lowering. + /// + [MessagePackObject] + public sealed class CultMeshEntityPrefabTransform + { + /// Local position vector. + [Key(0)] + public float[] Position { get; set; } = [0f, 0f, 0f]; + + /// Local rotation quaternion. + [Key(1)] + public float[] Rotation { get; set; } = [0f, 0f, 0f, 1f]; + + /// Local scale vector. + [Key(2)] + public float[] Scale { get; set; } = [1f, 1f, 1f]; + } + + /// + /// Runtime component intent attached to a portable prefab node. + /// + [MessagePackObject] + public sealed class CultMeshEntityPrefabComponent + { + /// Portable component kind or runtime adapter type. + [Key(0)] + public string Type { get; set; } = string.Empty; + + /// String-valued component properties for runtime-specific lowering. + [Key(1)] + public Dictionary Properties { get; set; } = new(); + } + + /// + /// Reference from a prefab package to a CDN artifact manifest. + /// + [MessagePackObject] + public sealed class CultMeshEntityPrefabAssetRef + { + /// Stable source asset id inside the prefab package. + [Key(0)] + public string AssetId { get; set; } = string.Empty; + + /// Semantic role, such as mesh, texture, material, or animation. + [Key(1)] + public string Role { get; set; } = string.Empty; + + /// CultCache record key for the referenced CDN artifact manifest. + [Key(2)] + public string CdnManifestRecordKey { get; set; } = string.Empty; + + /// Full artifact content hash copied from the referenced CDN manifest. + [Key(3)] + public string ContentHash { get; set; } = string.Empty; + + /// Artifact MIME type copied from the referenced CDN manifest. + [Key(4)] + public string MimeType { get; set; } = string.Empty; + + /// Runtime and source metadata for the asset reference. + [Key(5)] + public Dictionary Metadata { get; set; } = new(); + } + + /// + /// One node in a portable entity prefab graph. + /// + [MessagePackObject] + public sealed class CultMeshEntityPrefabNode + { + /// Stable node id inside the package. + [Key(0)] + public string NodeId { get; set; } = string.Empty; + + /// Source object name. + [Key(1)] + public string Name { get; set; } = string.Empty; + + /// Optional parent node id. + [Key(2)] + public string ParentNodeId { get; set; } = string.Empty; + + /// Node kind. + [Key(3)] + public string Kind { get; set; } = CultMeshEntityPrefabNodeKinds.Empty; + + /// Local transform. + [Key(4)] + public CultMeshEntityPrefabTransform Transform { get; set; } = new(); + + /// Optional mesh asset id used by this node. + [Key(5)] + public string MeshAssetId { get; set; } = string.Empty; + + /// Material asset ids used by this node. + [Key(6)] + public string[] MaterialAssetIds { get; set; } = Array.Empty(); + + /// Runtime component intents attached to this node. + [Key(7)] + public CultMeshEntityPrefabComponent[] Components { get; set; } = Array.Empty(); + + /// Runtime tags copied from source metadata. + [Key(8)] + public string[] Tags { get; set; } = Array.Empty(); + + /// Additional source metadata. + [Key(9)] + public Dictionary Metadata { get; set; } = new(); + } + + /// + /// Portable entity prefab package authored from a Blender collection and lowerable into multiple runtimes. + /// + [MessagePackObject] + [CultDocument("gamecult.mesh.entity_prefab_package", CultMeshEntityPrefabSchemaVersions.Package)] + public sealed class CultMeshEntityPrefabPackage + { + /// Stable logical prefab id. + [Key(0)] + [CultName] + public string PrefabId { get; set; } = string.Empty; + + /// Caller-defined package version. + [Key(1)] + [CultIndex] + public string Version { get; set; } = string.Empty; + + /// Tool that authored the package, such as Brokkr Blender. + [Key(2)] + [CultIndex] + public string SourceTool { get; set; } = string.Empty; + + /// Source scene name or path. + [Key(3)] + public string SourceScene { get; set; } = string.Empty; + + /// Source Blender collection name. + [Key(4)] + [CultIndex] + public string SourceCollection { get; set; } = string.Empty; + + /// Stable hash of graph structure and referenced assets. + [Key(5)] + [CultIndex] + public string ContentHash { get; set; } = string.Empty; + + /// Package creation timestamp in round-trip UTC format. + [Key(6)] + public string CreatedAtUtc { get; set; } = string.Empty; + + /// Referenced CDN assets. + [Key(7)] + public CultMeshEntityPrefabAssetRef[] Assets { get; set; } = Array.Empty(); + + /// Prefab hierarchy nodes. + [Key(8)] + public CultMeshEntityPrefabNode[] Nodes { get; set; } = Array.Empty(); + + /// Search and routing tags. + [Key(9)] + public string[] Tags { get; set; } = Array.Empty(); + + /// Package-level metadata. + [Key(10)] + public Dictionary Metadata { get; set; } = new(); + + /// Builds the deterministic package record key. + public static CultRecordKey CreateRecordKey(CultMeshEntityPrefabPackage package) + { + if (package == null) throw new ArgumentNullException(nameof(package)); + return CreateRecordKey(package.PrefabId, package.Version, package.ContentHash); + } + + /// Builds the deterministic package record key. + public static CultRecordKey CreateRecordKey(string prefabId, string version, string contentHash) + { + var prefabToken = CultMeshCdn.StableToken(prefabId, nameof(prefabId)); + var versionToken = string.IsNullOrWhiteSpace(version) + ? "unversioned" + : CultMeshCdn.StableToken(version, nameof(version)); + return new CultRecordKey( + "mesh:entity-prefab:" + prefabToken + ":" + versionToken + ":" + CultMeshCdn.NormalizeHash(contentHash, nameof(contentHash))); + } + } + + /// + /// Helpers for publishing and validating portable entity prefab packages. + /// + public static class CultMeshEntityPrefabs + { + /// + /// Computes a stable package content hash from graph structure and referenced asset hashes. + /// + public static string ComputeContentHash(CultMeshEntityPrefabPackage package) + { + if (package == null) throw new ArgumentNullException(nameof(package)); + var builder = new StringBuilder(); + builder.Append(package.PrefabId).Append('\u001F') + .Append(package.Version).Append('\u001F') + .Append(package.SourceTool).Append('\u001F') + .Append(package.SourceScene).Append('\u001F') + .Append(package.SourceCollection).Append('\u001F'); + + foreach (var asset in package.Assets.OrderBy(asset => asset.AssetId, StringComparer.Ordinal)) + { + builder.Append("asset").Append('\u001E') + .Append(asset.AssetId).Append('\u001E') + .Append(asset.Role).Append('\u001E') + .Append(asset.CdnManifestRecordKey).Append('\u001E') + .Append(CultMeshCdn.NormalizeHash(asset.ContentHash, nameof(asset.ContentHash))).Append('\u001E') + .Append(asset.MimeType).Append('\u001E') + .Append(StableMap(asset.Metadata)).Append('\u001F'); + } + + foreach (var node in package.Nodes.OrderBy(node => node.NodeId, StringComparer.Ordinal)) + { + builder.Append("node").Append('\u001E') + .Append(node.NodeId).Append('\u001E') + .Append(node.Name).Append('\u001E') + .Append(node.ParentNodeId).Append('\u001E') + .Append(node.Kind).Append('\u001E') + .Append(StableFloats(node.Transform.Position)).Append('\u001E') + .Append(StableFloats(node.Transform.Rotation)).Append('\u001E') + .Append(StableFloats(node.Transform.Scale)).Append('\u001E') + .Append(node.MeshAssetId).Append('\u001E') + .Append(string.Join(",", node.MaterialAssetIds.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001E') + .Append(StableComponents(node.Components)).Append('\u001E') + .Append(string.Join(",", node.Tags.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001E') + .Append(StableMap(node.Metadata)).Append('\u001F'); + } + + builder.Append(string.Join(",", package.Tags.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001F') + .Append(StableMap(package.Metadata)); + + return CultMeshCdn.HashBytes(Encoding.UTF8.GetBytes(builder.ToString())); + } + + /// + /// Stamps the current content hash and creation timestamp on a package. + /// + public static CultMeshEntityPrefabPackage FinalizePackage(CultMeshEntityPrefabPackage package) + { + if (package == null) throw new ArgumentNullException(nameof(package)); + package.CreatedAtUtc = string.IsNullOrWhiteSpace(package.CreatedAtUtc) + ? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture) + : package.CreatedAtUtc; + package.ContentHash = ComputeContentHash(package); + return package; + } + + /// + /// Publishes a finalized portable entity prefab package into a local cache. + /// + public static async Task PublishAsync( + CultCache cache, + CultMeshEntityPrefabPackage package) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (package == null) throw new ArgumentNullException(nameof(package)); + ValidatePackage(package); + await cache.UpsertAsync( + package, + new CultRecordHandle( + CultMeshEntityPrefabPackage.CreateRecordKey(package))).ConfigureAwait(false); + return package; + } + + /// + /// Publishes a finalized portable entity prefab package into a distributed CultNet database. + /// + public static async Task PublishAsync( + CultNetDatabase database, + CultMeshEntityPrefabPackage package) + { + if (database == null) throw new ArgumentNullException(nameof(database)); + if (package == null) throw new ArgumentNullException(nameof(package)); + ValidatePackage(package); + await database.PutAsync(CultMeshEntityPrefabPackage.CreateRecordKey(package), package) + .ConfigureAwait(false); + return package; + } + + /// + /// Creates a raw CultNet document registry for entity prefab packages. + /// + public static CultNetDocumentRegistry CreateDocumentRegistry(CultDocumentRegistry? documents = null) + { + documents ??= CultDocumentRegistry.Shared; + return new CultNetDocumentRegistry(documents) + .Register(CultNetDocumentBinding.ForDocument(documents)); + } + + /// + /// Creates a raw CultNet document registry for CDN assets and portable entity prefab packages. + /// + public static CultNetDocumentRegistry CreateAssetPipelineDocumentRegistry(CultDocumentRegistry? documents = null) + { + documents ??= CultDocumentRegistry.Shared; + return CultMeshCdn.CreateDocumentRegistry(documents) + .Register(CultNetDocumentBinding.ForDocument(documents)); + } + + private static void ValidatePackage(CultMeshEntityPrefabPackage package) + { + if (string.IsNullOrWhiteSpace(package.PrefabId)) + { + throw new ArgumentException("Prefab id must be non-empty.", nameof(package)); + } + + var expectedHash = ComputeContentHash(package); + if (!string.Equals(CultMeshCdn.NormalizeHash(package.ContentHash, nameof(package.ContentHash)), expectedHash, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Entity prefab package content hash does not match its graph."); + } + } + + private static string StableComponents(IEnumerable components) + { + return string.Join("|", components + .OrderBy(component => component.Type, StringComparer.Ordinal) + .Select(component => component.Type + "=" + StableMap(component.Properties))); + } + + private static string StableFloats(IEnumerable values) + { + return string.Join(",", values.Select(value => value.ToString("R", CultureInfo.InvariantCulture))); + } + + private static string StableMap(Dictionary values) + { + return string.Join("|", values + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key + "=" + pair.Value)); + } + } +} diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index 4689136a..3bf3beb7 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -45,6 +45,23 @@ using var node = await CultMesh.StartNodeAsync("world.ccmp", new CultMeshNodeOpt If `ShardLogPath` is omitted, CultMesh stores logs beside the cache under `world.cultmesh/shard-logs`. +## Distributed CDN + +CultMesh can distribute signed, content-addressed game assets through the same +typed document lane as gameplay state. Asset manifests, chunks, and portable +entity prefab packages are CultCache documents that can be replicated by +CultNet snapshots, shard logs, central servers, LAN peers, or opted-out-capable +internet peer caches. + +This is the canonical asset pipeline target for Aetheria. Brokkr-authored +Blender prefab snapshots deploy into `CultMeshEntityPrefabPackage` documents +that reference CDN artifact manifests for meshes, textures, materials, and +other payloads. Unity and TypeScript clients should unpack the same package +graph rather than maintaining separate Unity-prefab and Electron asset formats. + +See [docs/distributed-cdn.md](docs/distributed-cdn.md) for document shapes, +publish/read helpers, peer asset sharing policy, and the Brokkr prefab flow. + ## Verses A Verse is a rule-bearing consensus graph. Aetheria Main can be a @@ -378,5 +395,6 @@ hot frame bodies remain in the cheapest storage both endpoints can share. - [Public API](docs/public-api.md) - [Durable Node Quickstart](docs/durable-node-quickstart.md) - [Typed Document Path](docs/typed-document-path.md) +- [Distributed CDN Documents](docs/distributed-cdn.md) - [Research Notes](docs/research.md) - [Verse Model](docs/verses.md) diff --git a/src/GameCult.Mesh/docs/distributed-cdn.md b/src/GameCult.Mesh/docs/distributed-cdn.md new file mode 100644 index 00000000..fef4e276 --- /dev/null +++ b/src/GameCult.Mesh/docs/distributed-cdn.md @@ -0,0 +1,197 @@ +# Distributed CDN Documents + +CultMesh CDN artifacts are typed CultCache documents, not a separate asset +transport. The same manifest and chunk records can be written by an asset +pipeline daemon, replicated through CultNet raw snapshots or shard logs, and +read by Unity, TypeScript, or another runtime that implements the CultCache +document contract. + +This is the canonical path for moving Aetheria assets into CultLib. Starbridge +Unity and Starbridge Electron should consume the same records so UI textures, +map icons, environment layers, shaders, and build payloads do not fork by +client. + +## Feature Overview + +The CDN feature has four layers: + +1. `CultMeshCdnArtifactManifest` describes one versioned payload. +2. `CultMeshCdnArtifactChunk` stores content-addressed payload bytes. +3. `CultMeshEntityPrefabPackage` describes a portable entity/prefab graph and + references CDN artifact manifests for meshes, textures, materials, animation, + audio, or other runtime payloads. +4. Mesh asset sharing moves those signed manifests and chunks through central + servers, LAN peers, or internet peers without granting peers authority over + game state. + +This is not a Unity asset bundle system and not a raw FBX distribution system. +Blender is the authoring ground truth for entity prefabs. Brokkr can mirror a +legacy Unity prefab into Blender for migration, but the deployed CDN artifact is +the Brokkr/Blender-authored snapshot lowered into CultMesh documents. + +Typical flow: + +1. Unity publishes a one-time `brokkr.unity.prefab_mirror_snapshot.v0` for an + existing prefab. +2. Brokkr Blender imports that snapshot into a collection so artists can + configure renderable entity requirements in Blender. +3. Brokkr Blender publishes a `brokkr.prefab.snapshot.v0` deploy snapshot from + the collection. +4. A deployer packages referenced meshes, textures, and materials as CDN + artifacts. +5. The deployer writes a `CultMeshEntityPrefabPackage` that references those CDN + artifact manifests. +6. Unity, TypeScript, and later clients fetch the same package and lower it into + their runtime entity/component model. + +## Document Shape + +`CultMeshCdnArtifactManifest` is the versioned asset handle. It carries: + +- `ArtifactId`: logical id, such as `aetheria/ui/icons/minimap`. +- `Kind`: `asset`, `build`, or `package`. +- `Version`: caller-defined release or content version. +- `ContentHash`: SHA-256 of the full materialized payload. +- `SizeBytes`: full payload size. +- `MimeType`: byte interpretation hint. +- `Chunks`: ordered `CultMeshCdnChunkRef` placements. +- `Tags` and `Metadata`: routing and domain hints. + +`CultMeshCdnArtifactChunk` is content-addressed by SHA-256. It carries only the +chunk hash, byte count, and raw payload. Placement is deliberately in the +manifest reference, so equal chunk bytes can be reused across assets and build +updates. + +## Runtime Contract + +Clients should: + +1. Fetch or receive a manifest by record key, tag, or index. +2. Fetch every chunk record referenced by `manifest.Chunks`. +3. Verify each chunk hash before copying bytes. +4. Reassemble chunks by `Offset`. +5. Verify the full `ContentHash`. +6. Interpret bytes according to `MimeType` plus domain metadata. + +Single-channel data stays single-channel. For example, a gravity map can use +raw bytes with metadata such as `channels=r32` instead of pretending to be an +RGBA texture. + +## Mesh Asset Sharing Policy + +CultMesh asset distribution is allowed to use installed clients as a peer asset +cache because Aetheria cannot assume enough central infrastructure to serve all +game files authoritatively. This must be visible to players and configurable in +the client, but the initial product policy is opt-out rather than opt-in. + +The default policy should be: + +- Local cache is always enabled. +- LAN asset sharing is enabled by default. +- Internet peer asset sharing is enabled by default for signed asset chunks and + prefab packages. +- Executable or build-update seeding is disabled by default until the UX and + signing story is mature. +- Uploading is disabled or heavily throttled during gameplay. +- Uploading has conservative default rate and daily caps. +- Metered, cellular, battery-constrained, VPN, or corporate-network cases should + disable or throttle peer upload when detectable. +- The client must expose a clear `Do not upload game assets to other players` + control. +- The client must expose telemetry: cache size, current upload/download rate, + bytes served to peers, and bytes received from peers. + +Suggested runtime configuration: + +```csharp +var policy = new MeshAssetSharingPolicy +{ + EnabledByDefault = true, + AllowLanPeers = true, + AllowInternetPeers = true, + AllowBuildArtifacts = false, + DisableDuringGameplay = true, + DisableOnMeteredNetwork = true, + RequireSignedManifests = true, + ShowStatusTelemetry = true +}; +``` + +Peers are never trusted as authority. They only serve bytes. Every chunk is +content-addressed, every artifact is reconstructed from a manifest, and game +clients must verify hashes and signatures before using the result. + +## C# Publishing + +```csharp +var artifact = CultMesh.PackCdnArtifact( + "aetheria/environment/gravity/ragnarok", + gravityBytes, + new CultMeshCdnPackOptions + { + Kind = CultMeshCdnArtifactKinds.Asset, + Version = "2026.06.27", + MimeType = "application/octet-stream", + Tags = ["aetheria", "environment", "gravity"], + Metadata = + { + ["channels"] = "r32", + ["width"] = "1024", + ["height"] = "1024" + } + }); + +await CultMesh.PublishCdnArtifactAsync(node.Cache, artifact); +``` + +For build distribution, use the same API with `Kind = CultMeshCdnArtifactKinds.Build` +and metadata such as `platform=windows-x64` or `channel=internal`. + +## CultNet Replication + +CDN documents use the regular raw document lane: + +```csharp +var registry = CultMesh.CreateCdnDocumentRegistry(node.Cache.Registry); +var request = registry.CreateSnapshotRequest( + "cdn-request", + schemaIds: + [ + node.Cache.Registry.GetRequired().SchemaId, + node.Cache.Registry.GetRequired().SchemaId + ], + recordKeys: artifact.RecordKeys.Select(key => key.Value)); + +var response = registry.CreateRawSnapshotResponse(node.Cache, "cdn-response", request); +``` + +The TypeScript client should mirror the C# schema names, schema versions, +record-key rules, chunk hashing, and verification order. C# remains the +reference implementation. + +## Entity Prefabs + +Entity prefabs are distributed as Brokkr-authored snapshots, then deployed into +portable CultMesh prefab packages. Brokkr's Blender UI publishes +`brokkr.prefab.snapshot.v0` records for a collection-scoped prefab. A deployer +can consume that snapshot, publish meshes/textures/material payloads as CDN +artifacts, and write a `CultMeshEntityPrefabPackage` that references those +artifact manifests. + +That keeps Blender as the authoring ground truth while avoiding Unity prefab +files as the interchange format. Unity, TypeScript, and later runtimes unpack +the same package graph into their native entity/component model. + +## Client Responsibilities + +Unity and TypeScript clients should implement the same rules: + +- Fetch prefab packages by record key, tag, or version catalog. +- Fetch every referenced artifact manifest and chunk. +- Verify signed manifests before trusting package membership. +- Verify chunk hashes and full artifact hashes before import. +- Cache artifacts locally by content hash. +- Lower package nodes into local runtime objects without writing back Unity-owned + or Electron-owned prefab truth. +- Respect `MeshAssetSharingPolicy` for peer upload, status telemetry, and + opt-out behavior. diff --git a/tests/GameCult.Mesh.Tests/CultMeshCdnTests.cs b/tests/GameCult.Mesh.Tests/CultMeshCdnTests.cs new file mode 100644 index 00000000..9a28a164 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshCdnTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Networking; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +public sealed class CultMeshCdnTests +{ + [Test] + public async Task Artifact_PacksPublishesAndReassembles_FromCultCache() + { + var cache = new CultCache(); + var payload = Encoding.UTF8.GetBytes("abcdefghijklmnopqrstuvwxyz"); + var artifact = CultMesh.PackCdnArtifact( + "aetheria/ui/icons/minimap-pack", + payload, + new CultMeshCdnPackOptions + { + ChunkSizeBytes = 8, + Version = "2026.06.27", + MimeType = "application/octet-stream", + Tags = ["aetheria", "ui"], + Metadata = new Dictionary + { + ["channel"] = "dev" + } + }); + + artifact.Manifest.Kind.Should().Be(CultMeshCdnArtifactKinds.Asset); + artifact.Manifest.SizeBytes.Should().Be(payload.Length); + artifact.Manifest.Chunks.Should().HaveCount(4); + artifact.Manifest.Chunks.Select(chunk => chunk.Offset).Should().Equal(0, 8, 16, 24); + artifact.Manifest.Chunks.Select(chunk => chunk.SizeBytes).Should().Equal(8, 8, 8, 2); + + var manifest = await CultMesh.PublishCdnArtifactAsync(cache, artifact); + var manifestKey = CultMeshCdnArtifactManifest.CreateRecordKey(manifest); + cache.Get(manifestKey).Should().NotBeNull(); + + var roundTrip = CultMesh.ReadCdnArtifact(cache, manifest); + roundTrip.Should().Equal(payload); + } + + [Test] + public async Task BuildArtifact_Replicates_ThroughCultNetRawSnapshot() + { + var sourceCache = new CultCache(); + var targetCache = new CultCache(); + var registry = CultMesh.CreateCdnDocumentRegistry(sourceCache.Registry); + var payload = Encoding.UTF8.GetBytes("starbridge-build-update"); + var artifact = CultMesh.PackCdnArtifact( + "starbridge/windows/client", + payload, + new CultMeshCdnPackOptions + { + ChunkSizeBytes = 7, + Kind = CultMeshCdnArtifactKinds.Build, + Version = "0.4.0-preview", + Tags = ["starbridge", "build"], + Metadata = new Dictionary + { + ["platform"] = "windows-x64" + } + }); + var manifest = await CultMesh.PublishCdnArtifactAsync(sourceCache, artifact); + var manifestSchemaId = sourceCache.Registry.GetRequired().SchemaId; + var chunkSchemaId = sourceCache.Registry.GetRequired().SchemaId; + var recordKeys = artifact.RecordKeys.Select(key => key.Value).ToArray(); + + var request = registry.CreateSnapshotRequest( + "cdn-snapshot-request", + schemaIds: [manifestSchemaId, chunkSchemaId], + recordKeys: recordKeys); + var response = registry.CreateRawSnapshotResponse(sourceCache, "cdn-snapshot", request); + + response.Documents.Should().HaveCount(artifact.Chunks.Count + 1); + response.Documents.Select(document => document.RecordKey).Should().BeEquivalentTo(recordKeys); + + await registry.ApplyRawSnapshotResponseAsync(targetCache, response); + var replicatedManifest = targetCache.Get( + CultMeshCdnArtifactManifest.CreateRecordKey(manifest)); + replicatedManifest.Should().NotBeNull(); + replicatedManifest!.Kind.Should().Be(CultMeshCdnArtifactKinds.Build); + replicatedManifest.Metadata["platform"].Should().Be("windows-x64"); + + var roundTrip = CultMesh.ReadCdnArtifact(targetCache, replicatedManifest); + roundTrip.Should().Equal(payload); + } + + [Test] + public async Task ArtifactRead_RejectsTamperedChunkPayload() + { + var cache = new CultCache(); + var artifact = CultMesh.PackCdnArtifact( + "aetheria/gravity/layer0", + [1, 1, 2, 3, 5, 8], + new CultMeshCdnPackOptions + { + ChunkSizeBytes = 3, + Metadata = new Dictionary + { + ["channels"] = "r32" + } + }); + var manifest = await CultMesh.PublishCdnArtifactAsync(cache, artifact); + var firstChunk = artifact.Chunks[0]; + firstChunk.Payload[0] = 99; + await cache.UpsertAsync( + firstChunk, + new CultRecordHandle( + CultMeshCdnArtifactChunk.CreateRecordKey(firstChunk))); + + Action read = () => CultMesh.ReadCdnArtifact(cache, manifest); + read.Should().Throw() + .WithMessage("*chunk hash*"); + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshEntityPrefabTests.cs b/tests/GameCult.Mesh.Tests/CultMeshEntityPrefabTests.cs new file mode 100644 index 00000000..ec8bbcf7 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshEntityPrefabTests.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using GameCult.Caching; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +public sealed class CultMeshEntityPrefabTests +{ + [Test] + public async Task EntityPrefabPackage_PublishesBrokkrAuthoredGraph_WithCdnAssetRefs() + { + var cache = new CultCache(); + var meshArtifact = CultMesh.PackCdnArtifact( + "aetheria/prefabs/ships/scout/mesh/body", + Encoding.UTF8.GetBytes("portable-mesh-bytes"), + new CultMeshCdnPackOptions + { + Version = "authoring-1", + MimeType = "model/gltf-binary", + Tags = ["brokkr", "mesh"], + }); + var textureArtifact = CultMesh.PackCdnArtifact( + "aetheria/prefabs/ships/scout/texture/albedo", + Encoding.UTF8.GetBytes("texture-bytes"), + new CultMeshCdnPackOptions + { + Version = "authoring-1", + MimeType = "image/png", + Tags = ["brokkr", "texture"], + }); + await CultMesh.PublishCdnArtifactAsync(cache, meshArtifact); + await CultMesh.PublishCdnArtifactAsync(cache, textureArtifact); + + var package = CultMesh.FinalizeEntityPrefabPackage(new CultMeshEntityPrefabPackage + { + PrefabId = "aetheria/entity/ship/scout", + Version = "authoring-1", + SourceTool = "brokkr.blender", + SourceScene = "ships.blend", + SourceCollection = "prefab.ship.scout", + Tags = ["aetheria", "ship", "brokkr"], + Assets = + [ + AssetRef("mesh.body", CultMeshEntityPrefabAssetRoles.Mesh, meshArtifact.Manifest), + AssetRef("texture.albedo", CultMeshEntityPrefabAssetRoles.Texture, textureArtifact.Manifest) + ], + Nodes = + [ + new CultMeshEntityPrefabNode + { + NodeId = "root", + Name = "Scout", + Kind = CultMeshEntityPrefabNodeKinds.Empty, + Tags = ["entity-root"], + Components = + [ + new CultMeshEntityPrefabComponent + { + Type = "aetheria.ship", + Properties = new Dictionary + { + ["hullClass"] = "scout" + } + } + ] + }, + new CultMeshEntityPrefabNode + { + NodeId = "body", + Name = "Body", + ParentNodeId = "root", + Kind = CultMeshEntityPrefabNodeKinds.Mesh, + MeshAssetId = "mesh.body", + MaterialAssetIds = ["texture.albedo"], + Transform = new CultMeshEntityPrefabTransform + { + Position = [0f, 0f, 0f], + Rotation = [0f, 0f, 0f, 1f], + Scale = [1f, 1f, 1f] + } + } + ], + Metadata = new Dictionary + { + ["authoringAxis"] = "blender", + ["runtimeLowering"] = "unity,ts" + } + }); + + await CultMesh.PublishEntityPrefabPackageAsync(cache, package); + + var key = CultMeshEntityPrefabPackage.CreateRecordKey(package); + var stored = cache.Get(key); + stored.Should().NotBeNull(); + stored!.SourceTool.Should().Be("brokkr.blender"); + stored.SourceCollection.Should().Be("prefab.ship.scout"); + stored.Nodes.Should().Contain(node => node.Kind == CultMeshEntityPrefabNodeKinds.Mesh); + stored.Assets.Select(asset => asset.CdnManifestRecordKey) + .Should() + .Contain(meshArtifact.ManifestKey.Value) + .And.Contain(textureArtifact.ManifestKey.Value); + } + + [Test] + public async Task EntityPrefabPackage_ReplicatesWithReferencedCdnArtifacts_ThroughRawSnapshot() + { + var sourceCache = new CultCache(); + var targetCache = new CultCache(); + var registry = CultMesh.CreateAssetPipelineDocumentRegistry(sourceCache.Registry); + var meshArtifact = CultMesh.PackCdnArtifact( + "aetheria/prefabs/projectiles/bolt/mesh", + Encoding.UTF8.GetBytes("bolt-mesh"), + new CultMeshCdnPackOptions { Version = "v1", MimeType = "model/gltf-binary" }); + await CultMesh.PublishCdnArtifactAsync(sourceCache, meshArtifact); + + var package = CultMesh.FinalizeEntityPrefabPackage(new CultMeshEntityPrefabPackage + { + PrefabId = "aetheria/entity/projectile/bolt", + Version = "v1", + SourceTool = "brokkr.blender", + SourceScene = "projectiles.blend", + SourceCollection = "prefab.projectile.bolt", + Assets = [AssetRef("mesh", CultMeshEntityPrefabAssetRoles.Mesh, meshArtifact.Manifest)], + Nodes = + [ + new CultMeshEntityPrefabNode + { + NodeId = "bolt", + Name = "Bolt", + Kind = CultMeshEntityPrefabNodeKinds.Mesh, + MeshAssetId = "mesh", + Components = + [ + new CultMeshEntityPrefabComponent + { + Type = "aetheria.projectile", + Properties = new Dictionary + { + ["damage"] = "10" + } + } + ] + } + ] + }); + await CultMesh.PublishEntityPrefabPackageAsync(sourceCache, package); + + var packageSchema = sourceCache.Registry.GetRequired().SchemaId; + var manifestSchema = sourceCache.Registry.GetRequired().SchemaId; + var chunkSchema = sourceCache.Registry.GetRequired().SchemaId; + var recordKeys = new[] { CultMeshEntityPrefabPackage.CreateRecordKey(package) } + .Concat(meshArtifact.RecordKeys) + .Select(key => key.Value) + .ToArray(); + + var request = registry.CreateSnapshotRequest( + "entity-prefab-request", + schemaIds: [packageSchema, manifestSchema, chunkSchema], + recordKeys: recordKeys); + var response = registry.CreateRawSnapshotResponse(sourceCache, "entity-prefab-response", request); + + response.Documents.Should().HaveCount(meshArtifact.Chunks.Count + 2); + await registry.ApplyRawSnapshotResponseAsync(targetCache, response); + + var replicatedPackage = targetCache.Get( + CultMeshEntityPrefabPackage.CreateRecordKey(package)); + replicatedPackage.Should().NotBeNull(); + replicatedPackage!.Nodes.Single().Components.Single().Type.Should().Be("aetheria.projectile"); + + var meshManifest = targetCache.Get(meshArtifact.ManifestKey); + meshManifest.Should().NotBeNull(); + CultMesh.ReadCdnArtifact(targetCache, meshManifest!).Should().Equal(Encoding.UTF8.GetBytes("bolt-mesh")); + } + + private static CultMeshEntityPrefabAssetRef AssetRef( + string assetId, + string role, + CultMeshCdnArtifactManifest manifest) + { + return new CultMeshEntityPrefabAssetRef + { + AssetId = assetId, + Role = role, + CdnManifestRecordKey = CultMeshCdnArtifactManifest.CreateRecordKey(manifest).Value, + ContentHash = manifest.ContentHash, + MimeType = manifest.MimeType, + Metadata = new Dictionary + { + ["sourceArtifactId"] = manifest.ArtifactId + } + }; + } +} From 396a70565441859285cde5016d1897b4b1e854ff Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 1 Jul 2026 11:51:02 +0100 Subject: [PATCH 107/498] Add portable Eve surface builder --- src/GameCult.Mesh/CultMeshUiSurfaces.cs | 735 ++++++++++++++++++ src/GameCult.Mesh/README.md | 48 ++ .../EveSurfaceBuilderTests.cs | 145 ++++ 3 files changed, 928 insertions(+) create mode 100644 src/GameCult.Mesh/CultMeshUiSurfaces.cs create mode 100644 tests/GameCult.Mesh.Tests/EveSurfaceBuilderTests.cs diff --git a/src/GameCult.Mesh/CultMeshUiSurfaces.cs b/src/GameCult.Mesh/CultMeshUiSurfaces.cs new file mode 100644 index 00000000..246ebe4b --- /dev/null +++ b/src/GameCult.Mesh/CultMeshUiSurfaces.cs @@ -0,0 +1,735 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using MessagePack; + +#pragma warning disable CS1591 + +namespace GameCult.Mesh +{ + /// + /// Portable CultUI/Eve surface document published through CultMesh. + /// + [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] + [MessagePackObject] + public sealed class EveSurfaceDocument + { + [SerializationConstructor] + public EveSurfaceDocument( + string providerId, + string providerKind, + string title, + long version, + string updatedAtUtc, + EveSurfaceTree surface, + IReadOnlyList commands) + { + ProviderId = providerId ?? ""; + ProviderKind = providerKind ?? ""; + Title = title ?? ""; + Version = version; + UpdatedAtUtc = updatedAtUtc ?? ""; + Surface = surface ?? throw new ArgumentNullException(nameof(surface)); + Commands = commands ?? Array.Empty(); + } + + [Key(0)] + public string ProviderId { get; } + + [Key(1)] + public string ProviderKind { get; } + + [Key(2)] + public string Title { get; } + + [Key(3)] + public long Version { get; } + + [Key(4)] + public string UpdatedAtUtc { get; } + + [Key(5)] + public EveSurfaceTree Surface { get; } + + [Key(6)] + public IReadOnlyList Commands { get; } + } + + [MessagePackObject] + public sealed class EveSurfaceTree + { + [SerializationConstructor] + public EveSurfaceTree( + string id, + EveSurfaceComponent root, + IReadOnlyList styles) + { + Id = id ?? ""; + Root = root ?? throw new ArgumentNullException(nameof(root)); + Styles = styles ?? Array.Empty(); + } + + [Key(0)] + public string Id { get; } + + [Key(1)] + public EveSurfaceComponent Root { get; } + + [Key(2)] + public IReadOnlyList Styles { get; } + } + + [MessagePackObject] + public sealed class EveSurfaceComponent + { + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary? props = null, + IReadOnlyList? children = null) + : this(id, kind, props, children, StateBindingsFromProps(props), Array.Empty()) + { + } + + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary? props, + IReadOnlyList? children, + IReadOnlyList? stateBindings) + : this(id, kind, props, children, stateBindings, Array.Empty()) + { + } + + [SerializationConstructor] + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary? props, + IReadOnlyList? children, + IReadOnlyList? stateBindings, + IReadOnlyList? embeddedDocuments) + { + Id = id ?? ""; + Kind = kind ?? ""; + Props = props ?? new Dictionary(StringComparer.Ordinal); + Children = children ?? Array.Empty(); + StateBindings = stateBindings ?? Array.Empty(); + EmbeddedDocuments = embeddedDocuments ?? Array.Empty(); + } + + [Key(0)] + public string Id { get; } + + [Key(1)] + public string Kind { get; } + + [Key(2)] + public IReadOnlyDictionary Props { get; } + + [Key(3)] + public IReadOnlyList Children { get; } + + [Key(4)] + public IReadOnlyList StateBindings { get; } + + [Key(5)] + public IReadOnlyList EmbeddedDocuments { get; } + + private static IReadOnlyList StateBindingsFromProps( + IReadOnlyDictionary? props) + { + if (props == null || props.Count == 0) + return Array.Empty(); + + var bindings = new List(); + foreach (var prop in props) + { + if (string.IsNullOrWhiteSpace(prop.Value) || + !prop.Key.EndsWith("PointerId", StringComparison.Ordinal)) + { + continue; + } + + var targetProp = prop.Key.Substring(0, prop.Key.Length - "PointerId".Length); + if (targetProp.Length == 0) + targetProp = "value"; + bindings.Add(EveSurfaceStateBinding.FromDescriptor(new CultMeshStateBindingDescriptor(targetProp, prop.Value))); + } + + return bindings; + } + } + + [MessagePackObject] + public sealed class EveSurfaceStateBinding + { + [SerializationConstructor] + public EveSurfaceStateBinding( + string targetProp, + string pointerId, + string sourceId, + string schemaId, + EveSurfaceRouteHint routeHint) + { + TargetProp = string.IsNullOrWhiteSpace(targetProp) ? "value" : targetProp; + PointerId = pointerId ?? ""; + SourceId = sourceId ?? ""; + SchemaId = schemaId ?? ""; + RouteHint = routeHint ?? EveSurfaceRouteHint.Automatic; + } + + [Key(0)] + public string TargetProp { get; } + + [Key(1)] + public string PointerId { get; } + + [Key(2)] + public string SourceId { get; } + + [Key(3)] + public string SchemaId { get; } + + [Key(4)] + public EveSurfaceRouteHint RouteHint { get; } + + public static EveSurfaceStateBinding FromDescriptor(CultMeshStateBindingDescriptor descriptor) + { + if (descriptor == null) throw new ArgumentNullException(nameof(descriptor)); + return new EveSurfaceStateBinding( + descriptor.TargetProp, + descriptor.PointerId, + descriptor.SourceId ?? "", + descriptor.SchemaId ?? "", + EveSurfaceRouteHint.FromRoute(descriptor.RouteHint)); + } + } + + [MessagePackObject] + public sealed class EveSurfaceOperationBinding + { + [SerializationConstructor] + public EveSurfaceOperationBinding( + string operationId, + string label, + string schemaId, + EveSurfaceRouteHint routeHint) + { + OperationId = operationId ?? ""; + Label = label ?? ""; + SchemaId = schemaId ?? ""; + RouteHint = routeHint ?? EveSurfaceRouteHint.Automatic; + } + + [Key(0)] + public string OperationId { get; } + + [Key(1)] + public string Label { get; } + + [Key(2)] + public string SchemaId { get; } + + [Key(3)] + public EveSurfaceRouteHint RouteHint { get; } + + public static EveSurfaceOperationBinding FromDescriptor(CultMeshOperationBindingDescriptor descriptor) + { + if (descriptor == null) throw new ArgumentNullException(nameof(descriptor)); + return new EveSurfaceOperationBinding( + descriptor.OperationId, + descriptor.Label, + descriptor.SchemaId, + EveSurfaceRouteHint.FromRoute(descriptor.RouteHint)); + } + } + + [MessagePackObject] + public sealed class EveSurfaceRouteHint + { + public static readonly EveSurfaceRouteHint Automatic = + new(nameof(CultMeshLocalityKind.Automatic), ""); + + [SerializationConstructor] + public EveSurfaceRouteHint(string kind, string description) + { + Kind = string.IsNullOrWhiteSpace(kind) ? nameof(CultMeshLocalityKind.Automatic) : kind; + Description = description ?? ""; + } + + [Key(0)] + public string Kind { get; } + + [Key(1)] + public string Description { get; } + + public static EveSurfaceRouteHint FromRoute(CultMeshRouteHint routeHint) + { + var record = CultMeshRouteRecord.FromRoute(routeHint); + return new EveSurfaceRouteHint(record.Kind, record.Description); + } + } + + [MessagePackObject] + public sealed class EveEmbeddedDocumentSlot + { + [SerializationConstructor] + public EveEmbeddedDocumentSlot( + string slotId, + string documentId, + string schemaId, + string presentationKind, + EveSurfaceRouteHint routeHint) + { + SlotId = slotId ?? ""; + DocumentId = documentId ?? ""; + SchemaId = schemaId ?? ""; + PresentationKind = presentationKind ?? ""; + RouteHint = routeHint ?? EveSurfaceRouteHint.Automatic; + } + + public EveEmbeddedDocumentSlot( + string slotId, + string documentId, + string schemaId, + string presentationKind) + : this(slotId, documentId, schemaId, presentationKind, EveSurfaceRouteHint.Automatic) + { + } + + [Key(0)] + public string SlotId { get; } + + [Key(1)] + public string DocumentId { get; } + + [Key(2)] + public string SchemaId { get; } + + [Key(3)] + public string PresentationKind { get; } + + [Key(4)] + public EveSurfaceRouteHint RouteHint { get; } + } + + [MessagePackObject] + public sealed class EveSurfaceStyleToken + { + [SerializationConstructor] + public EveSurfaceStyleToken(string name, string value) + { + Name = name ?? ""; + Value = value ?? ""; + } + + [Key(0)] + public string Name { get; } + + [Key(1)] + public string Value { get; } + } + + [MessagePackObject] + public sealed class EveSurfaceCommandTemplate + { + public EveSurfaceCommandTemplate(string command, string label, string transport = "cultmesh") + : this(new EveSurfaceOperationBinding( + command, + label, + "", + new EveSurfaceRouteHint(nameof(CultMeshLocalityKind.Automatic), transport))) + { + } + + [SerializationConstructor] + public EveSurfaceCommandTemplate(EveSurfaceOperationBinding operation) + { + Operation = operation ?? throw new ArgumentNullException(nameof(operation)); + } + + public EveSurfaceCommandTemplate(CultMeshOperationBindingDescriptor operation) + : this(EveSurfaceOperationBinding.FromDescriptor(operation)) + { + } + + [Key(0)] + public EveSurfaceOperationBinding Operation { get; } + + [IgnoreMember] + public string Command => Operation.OperationId; + + [IgnoreMember] + public string Label => Operation.Label; + } + + /// + /// Fluent CultUI/Eve surface builder. It preserves the old CultUI panel ergonomics while emitting portable documents. + /// + public sealed class EveSurfaceBuilder + { + private readonly string _surfaceId; + private readonly List _children = new(); + private readonly List _styles = new(); + private readonly List _commands = new(); + private long _version = 1; + private string _providerId = "gamecult"; + private string _providerKind = "cultui.surface"; + private string _title = ""; + private string _updatedAtUtc = ""; + + public EveSurfaceBuilder(string surfaceId) + { + _surfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId)); + } + + public EveSurfaceBuilder Provider(string providerId, string providerKind) + { + _providerId = providerId ?? ""; + _providerKind = providerKind ?? ""; + return this; + } + + public EveSurfaceBuilder Title(string title) + { + _title = title ?? ""; + _children.Add(Text($"{_surfaceId}.title", _title, "text.title")); + return this; + } + + public EveSurfaceBuilder TitleSubtitle(string title, string subtitle) + { + _title = string.IsNullOrWhiteSpace(subtitle) ? title ?? "" : $"{title} {subtitle}"; + _children.Add(Text($"{_surfaceId}.title", title ?? "", "text.title")); + _children.Add(Text($"{_surfaceId}.subtitle", subtitle ?? "", "text.subtitle")); + return this; + } + + public EveSurfaceBuilder Text(string text, string? id = null) + { + _children.Add(Text(id ?? $"{_surfaceId}.text.{_children.Count}", text, "text")); + return this; + } + + public EveSurfaceBuilder Button(string label, string command) + { + var operation = CultMesh.OperationBinding(command, label); + _commands.Add(new EveSurfaceCommandTemplate(operation)); + _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); + return this; + } + + public EveSurfaceBuilder Button(string label, CultMeshOperationBindingDescriptor operation) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _commands.Add(new EveSurfaceCommandTemplate(operation)); + _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); + return this; + } + + public EveSurfaceBuilder ButtonColumn(string id, Action build) + { + return Group(id, "column", build); + } + + public EveSurfaceBuilder ButtonRow(string id, Action build) + { + return Group(id, "row", build); + } + + public EveSurfaceBuilder Form(string id, Action build) + { + if (build == null) throw new ArgumentNullException(nameof(build)); + var form = new EveSurfaceFormBuilder(id, AddCommand); + build(form); + _children.Add(form.Build()); + return this; + } + + public EveSurfaceBuilder EmbeddedDocument( + string slotId, + string documentId, + string schemaId, + string presentationKind, + CultMeshRouteHint? routeHint = null) + { + _children.Add(new EveSurfaceComponent( + $"{_surfaceId}.slot.{Slug(slotId)}", + "surface.slot", + new Dictionary(StringComparer.Ordinal) + { + ["slotId"] = slotId ?? "", + ["documentId"] = documentId ?? "", + ["schemaId"] = schemaId ?? "", + ["presentationKind"] = presentationKind ?? "" + }, + Array.Empty(), + Array.Empty(), + new[] + { + new EveEmbeddedDocumentSlot( + slotId ?? "", + documentId ?? "", + schemaId ?? "", + presentationKind ?? "", + routeHint == null ? EveSurfaceRouteHint.Automatic : EveSurfaceRouteHint.FromRoute(routeHint)) + })); + return this; + } + + public EveSurfaceBuilder Style(string name, string value) + { + _styles.Add(new EveSurfaceStyleToken(name, value)); + return this; + } + + public EveSurfaceBuilder Version(long version) + { + _version = version; + return this; + } + + public EveSurfaceBuilder UpdatedAtUtc(string updatedAtUtc) + { + _updatedAtUtc = updatedAtUtc ?? ""; + return this; + } + + public EveSurfaceDocument Build() + { + return new EveSurfaceDocument( + _providerId, + _providerKind, + _title, + _version, + string.IsNullOrWhiteSpace(_updatedAtUtc) ? DateTime.UtcNow.ToString("O") : _updatedAtUtc, + new EveSurfaceTree( + _surfaceId, + new EveSurfaceComponent( + $"{_surfaceId}.root", + "surface", + EmptyProps(), + _children.ToArray()), + _styles.ToArray()), + _commands.ToArray()); + } + + private EveSurfaceBuilder Group(string id, string kind, Action build) + { + if (build == null) throw new ArgumentNullException(nameof(build)); + var group = new EveSurfaceGroupBuilder(id, kind, AddCommand); + build(group); + _children.Add(group.Build()); + return this; + } + + private void AddCommand(EveSurfaceCommandTemplate command) + { + if (command != null) + _commands.Add(command); + } + + internal static EveSurfaceComponent Text(string id, string value, string kind) + { + return new EveSurfaceComponent( + id, + kind, + new Dictionary(StringComparer.Ordinal) + { + ["value"] = value ?? "" + }, + Array.Empty()); + } + + internal static EveSurfaceComponent ButtonComponent( + string id, + string label, + CultMeshOperationBindingDescriptor operation) + { + return new EveSurfaceComponent( + id, + "control.button", + new Dictionary(StringComparer.Ordinal) + { + ["label"] = label ?? operation.Label, + ["command"] = operation.OperationId, + ["operationId"] = operation.OperationId, + ["schemaId"] = operation.SchemaId + }, + Array.Empty()); + } + + internal static Dictionary EmptyProps() + { + return new Dictionary(StringComparer.Ordinal); + } + + internal static string Slug(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "unnamed"; + + var chars = value + .Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.') + .ToArray(); + var slug = new string(chars).Trim('.'); + while (slug.Contains("..", StringComparison.Ordinal)) + slug = slug.Replace("..", ".", StringComparison.Ordinal); + return string.IsNullOrWhiteSpace(slug) ? "unnamed" : slug; + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + public sealed class EveSurfaceGroupBuilder + { + private readonly string _id; + private readonly string _kind; + private readonly Action _addCommand; + private readonly List _children = new(); + + internal EveSurfaceGroupBuilder( + string id, + string kind, + Action addCommand) + { + _id = id ?? ""; + _kind = kind ?? "column"; + _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); + } + + public EveSurfaceGroupBuilder Button(string label, string command) + { + var operation = CultMesh.OperationBinding(command, label); + _addCommand(new EveSurfaceCommandTemplate(operation)); + _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); + return this; + } + + public EveSurfaceGroupBuilder Button(string label, CultMeshOperationBindingDescriptor operation) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveSurfaceCommandTemplate(operation)); + _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); + return this; + } + + internal EveSurfaceComponent Build() + { + return new EveSurfaceComponent(_id, _kind, EveSurfaceBuilder.EmptyProps(), _children.ToArray()); + } + } + + public sealed class EveSurfaceFormBuilder + { + private readonly string _id; + private readonly Action _addCommand; + private readonly List _children = new(); + + internal EveSurfaceFormBuilder(string id, Action addCommand) + { + _id = id ?? ""; + _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); + } + + public EveSurfaceFormBuilder Text( + string label, + string value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding = null) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveSurfaceCommandTemplate(operation)); + _children.Add(Control( + "control.text", + label, + value, + operation, + binding)); + return this; + } + + public EveSurfaceFormBuilder Toggle( + string label, + bool value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding = null) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveSurfaceCommandTemplate(operation)); + _children.Add(Control( + "control.toggle", + label, + value ? "true" : "false", + operation, + binding)); + return this; + } + + public EveSurfaceFormBuilder Metric( + string label, + string value, + CultMeshStateBindingDescriptor? binding = null) + { + var bindings = binding == null + ? Array.Empty() + : new[] { EveSurfaceStateBinding.FromDescriptor(binding) }; + _children.Add(new EveSurfaceComponent( + $"{_id}.metric.{EveSurfaceBuilder.Slug(label)}", + "metric", + new Dictionary(StringComparer.Ordinal) + { + ["label"] = label ?? "", + ["value"] = value ?? "" + }, + Array.Empty(), + bindings)); + return this; + } + + internal EveSurfaceComponent Build() + { + return new EveSurfaceComponent(_id, "form", EveSurfaceBuilder.EmptyProps(), _children.ToArray()); + } + + private EveSurfaceComponent Control( + string kind, + string label, + string value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding) + { + var bindings = binding == null + ? Array.Empty() + : new[] { EveSurfaceStateBinding.FromDescriptor(binding) }; + return new EveSurfaceComponent( + $"{_id}.{EveSurfaceBuilder.Slug(label)}", + kind, + new Dictionary(StringComparer.Ordinal) + { + ["label"] = label ?? "", + ["value"] = value ?? "", + ["command"] = operation.OperationId, + ["operationId"] = operation.OperationId, + ["schemaId"] = operation.SchemaId + }, + Array.Empty(), + bindings); + } + } + + public static class EveSurface + { + public static EveSurfaceBuilder Create(string surfaceId) + { + return new EveSurfaceBuilder(surfaceId); + } + } +} diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index 3bf3beb7..2f80d345 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -32,6 +32,54 @@ ends with a typed read/write through the public CultMesh surface, see If you want the lower-level typed-to-raw handoff that CultNet moves across the wire, see [docs/typed-document-path.md](docs/typed-document-path.md). +## Eve / CultUI Surfaces + +CultMesh includes the portable Eve/CultUI surface document contract and a fluent +builder for daemon-published UI. The builder is deliberately shaped like the +legacy CultUI/Unity properties panel API: compose menus and forms in readable +imperative code, while the emitted document carries typed CultMesh operation +bindings, state bindings, style tokens, and embedded surface slots. + +```csharp +var menu = EveSurface.Create("aetheria.main_menu.root") + .Provider("aetheria", "game.menu") + .TitleSubtitle("AETHERIA", "TERMINUS") + .ButtonColumn("aetheria.main_menu.root.actions", actions => actions + .Button("Continue", "aetheria.main_menu.root.continue") + .Button("New Game", "aetheria.main_menu.root.new_game") + .Button("Settings", "aetheria.main_menu.root.show_settings") + .Button("Quit", "aetheria.main_menu.root.quit")) + .Style("font.title.family", "Montserrat") + .Style("font.body.family", "Ubuntu") + .Build(); +``` + +Forms accept typed CultMesh descriptors at authoring time and flatten them into +MessagePack-safe Eve records in the document: + +```csharp +var setName = CultMesh.OperationBinding( + "settings.player.name.set", + "Set Player Name", + "gamecult.settings.name.v1"); + +var playerName = new CultMeshStateBindingDescriptor( + "value", + "player.settings.name", + "global:aetheria.player_settings.v1", + "gamecult.aetheria.player_settings.v1"); + +var settings = EveSurface.Create("aetheria.main_menu.player_settings") + .Form("aetheria.main_menu.player_settings.form", form => form + .Text("Name", "Meta", setName, playerName)) + .Build(); +``` + +Nested UI is expressed with `surface.slot` components and +`EveEmbeddedDocumentSlot` records. A runtime lowers the parent surface, resolves +the child document through CultMesh, and renders the embedded document without +the renderer becoming the owner of that UI. + Enable durable authoritative shard logs when a node should serve replica catch-up after restart: diff --git a/tests/GameCult.Mesh.Tests/EveSurfaceBuilderTests.cs b/tests/GameCult.Mesh.Tests/EveSurfaceBuilderTests.cs new file mode 100644 index 00000000..02a857d4 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/EveSurfaceBuilderTests.cs @@ -0,0 +1,145 @@ +using System.Linq; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +public sealed class EveSurfaceBuilderTests +{ + [Test] + public void Builder_ComposesMainMenuLikeLegacyCultUiPanel() + { + var surface = EveSurface.Create("aetheria.main_menu.root") + .Provider("aetheria", "game.menu") + .TitleSubtitle("AETHERIA", "TERMINUS") + .ButtonColumn("aetheria.main_menu.root.actions", actions => actions + .Button("Continue", "aetheria.main_menu.root.continue") + .Button("New Game", "aetheria.main_menu.root.new_game") + .Button("Settings", "aetheria.main_menu.root.show_settings") + .Button("Quit", "aetheria.main_menu.root.quit")) + .Style("font.title.family", "Montserrat") + .Style("font.body.family", "Ubuntu") + .UpdatedAtUtc("2026-07-01T00:00:00.0000000Z") + .Build(); + + surface.ProviderId.Should().Be("aetheria"); + surface.ProviderKind.Should().Be("game.menu"); + surface.Title.Should().Be("AETHERIA TERMINUS"); + surface.UpdatedAtUtc.Should().Be("2026-07-01T00:00:00.0000000Z"); + surface.Surface.Id.Should().Be("aetheria.main_menu.root"); + surface.Surface.Root.Kind.Should().Be("surface"); + surface.Surface.Styles.Select(style => style.Name).Should().Contain(["font.title.family", "font.body.family"]); + + var rootChildren = surface.Surface.Root.Children; + rootChildren[0].Kind.Should().Be("text.title"); + rootChildren[0].Props["value"].Should().Be("AETHERIA"); + rootChildren[1].Kind.Should().Be("text.subtitle"); + rootChildren[1].Props["value"].Should().Be("TERMINUS"); + + var actions = rootChildren[2]; + actions.Kind.Should().Be("column"); + actions.Children.Should().HaveCount(4); + actions.Children[2].Kind.Should().Be("control.button"); + actions.Children[2].Props["label"].Should().Be("Settings"); + actions.Children[2].Props["command"].Should().Be("aetheria.main_menu.root.show_settings"); + + surface.Commands.Select(command => command.Command).Should().Equal( + "aetheria.main_menu.root.continue", + "aetheria.main_menu.root.new_game", + "aetheria.main_menu.root.show_settings", + "aetheria.main_menu.root.quit"); + } + + [Test] + public void Form_ControlsCarryOperationAndStateBindings() + { + var nameOperation = CultMesh.OperationBinding("settings.player.name.set", "Set Player Name", "gamecult.settings.name.v1"); + var asteroidOperation = CultMesh.OperationBinding("settings.graphics.asteroids.toggle", "Toggle Asteroids"); + var nameBinding = new CultMeshStateBindingDescriptor( + "value", + "player.settings.name", + "global:aetheria.player_settings.v1", + "gamecult.aetheria.player_settings.v1"); + var asteroidBinding = new CultMeshStateBindingDescriptor( + "value", + "player.settings.show_asteroids", + "global:aetheria.player_settings.v1", + "gamecult.aetheria.player_settings.v1"); + + var surface = EveSurface.Create("aetheria.main_menu.player_settings") + .Form("aetheria.main_menu.player_settings.form", form => form + .Text("Name", "Meta", nameOperation, nameBinding) + .Toggle("Show Asteroids In Minimap", true, asteroidOperation, asteroidBinding) + .Metric("Significant Digits", "3")) + .Build(); + + var form = surface.Surface.Root.Children.Single(); + form.Kind.Should().Be("form"); + form.Children.Should().HaveCount(3); + + var name = form.Children[0]; + name.Kind.Should().Be("control.text"); + name.Props["label"].Should().Be("Name"); + name.Props["value"].Should().Be("Meta"); + name.Props["command"].Should().Be("settings.player.name.set"); + name.StateBindings.Single().PointerId.Should().Be("player.settings.name"); + + var toggle = form.Children[1]; + toggle.Kind.Should().Be("control.toggle"); + toggle.Props["value"].Should().Be("true"); + toggle.StateBindings.Single().PointerId.Should().Be("player.settings.show_asteroids"); + + surface.Commands.Select(command => command.Command).Should().Equal( + "settings.player.name.set", + "settings.graphics.asteroids.toggle"); + } + + [Test] + public void EmbeddedDocumentSlot_CarriesNestedSurfaceContract() + { + var surface = EveSurface.Create("station.inventory") + .EmbeddedDocument( + "cargo", + "daemon:aetheria.inventory.station.v1", + "gamecult.eve.surface.v1", + "inventory.grid", + new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "daemon-published CultUI surface")) + .Build(); + + var slot = surface.Surface.Root.Children.Single(); + slot.Kind.Should().Be("surface.slot"); + slot.EmbeddedDocuments.Should().ContainSingle(); + slot.EmbeddedDocuments[0].SlotId.Should().Be("cargo"); + slot.EmbeddedDocuments[0].DocumentId.Should().Be("daemon:aetheria.inventory.station.v1"); + slot.EmbeddedDocuments[0].SchemaId.Should().Be("gamecult.eve.surface.v1"); + slot.EmbeddedDocuments[0].RouteHint.Kind.Should().Be(nameof(CultMeshLocalityKind.SharedMemory)); + } + + [Test] + public void SurfaceDocument_RoundTripsThroughCultCacheMessagePack() + { + var surface = EveSurface.Create("aetheria.main_menu.player_settings") + .Provider("aetheria", "game.menu") + .TitleSubtitle("Gameplay", "Settings") + .Form("aetheria.main_menu.player_settings.form", form => form + .Text( + "Name", + "Meta", + CultMesh.OperationBinding("settings.player.name.set", "Set Player Name"), + new CultMeshStateBindingDescriptor( + "value", + "player.settings.name", + "global:aetheria.player_settings.v1", + "gamecult.aetheria.player_settings.v1"))) + .Build(); + + var payload = CultDocumentMessagePackSerialization.Serialize(surface); + var roundTrip = CultDocumentMessagePackSerialization.Deserialize(payload); + + roundTrip.Surface.Id.Should().Be(surface.Surface.Id); + roundTrip.Surface.Root.Children[2].Children.Single().StateBindings.Single().PointerId + .Should().Be("player.settings.name"); + roundTrip.Commands.Single().Command.Should().Be("settings.player.name.set"); + } +} From 5ce64c69e2c35fc0db163a6447f792f67f6ab3f4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 1 Jul 2026 15:26:48 +0100 Subject: [PATCH 108/498] Remove Eve surface DTOs from CultMesh --- src/GameCult.Mesh/CultMeshUiSurfaces.cs | 735 ------------------ .../EveSurfaceBuilderTests.cs | 145 ---- 2 files changed, 880 deletions(-) delete mode 100644 src/GameCult.Mesh/CultMeshUiSurfaces.cs delete mode 100644 tests/GameCult.Mesh.Tests/EveSurfaceBuilderTests.cs diff --git a/src/GameCult.Mesh/CultMeshUiSurfaces.cs b/src/GameCult.Mesh/CultMeshUiSurfaces.cs deleted file mode 100644 index 246ebe4b..00000000 --- a/src/GameCult.Mesh/CultMeshUiSurfaces.cs +++ /dev/null @@ -1,735 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using GameCult.Caching; -using MessagePack; - -#pragma warning disable CS1591 - -namespace GameCult.Mesh -{ - /// - /// Portable CultUI/Eve surface document published through CultMesh. - /// - [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] - [MessagePackObject] - public sealed class EveSurfaceDocument - { - [SerializationConstructor] - public EveSurfaceDocument( - string providerId, - string providerKind, - string title, - long version, - string updatedAtUtc, - EveSurfaceTree surface, - IReadOnlyList commands) - { - ProviderId = providerId ?? ""; - ProviderKind = providerKind ?? ""; - Title = title ?? ""; - Version = version; - UpdatedAtUtc = updatedAtUtc ?? ""; - Surface = surface ?? throw new ArgumentNullException(nameof(surface)); - Commands = commands ?? Array.Empty(); - } - - [Key(0)] - public string ProviderId { get; } - - [Key(1)] - public string ProviderKind { get; } - - [Key(2)] - public string Title { get; } - - [Key(3)] - public long Version { get; } - - [Key(4)] - public string UpdatedAtUtc { get; } - - [Key(5)] - public EveSurfaceTree Surface { get; } - - [Key(6)] - public IReadOnlyList Commands { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceTree - { - [SerializationConstructor] - public EveSurfaceTree( - string id, - EveSurfaceComponent root, - IReadOnlyList styles) - { - Id = id ?? ""; - Root = root ?? throw new ArgumentNullException(nameof(root)); - Styles = styles ?? Array.Empty(); - } - - [Key(0)] - public string Id { get; } - - [Key(1)] - public EveSurfaceComponent Root { get; } - - [Key(2)] - public IReadOnlyList Styles { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceComponent - { - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary? props = null, - IReadOnlyList? children = null) - : this(id, kind, props, children, StateBindingsFromProps(props), Array.Empty()) - { - } - - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary? props, - IReadOnlyList? children, - IReadOnlyList? stateBindings) - : this(id, kind, props, children, stateBindings, Array.Empty()) - { - } - - [SerializationConstructor] - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary? props, - IReadOnlyList? children, - IReadOnlyList? stateBindings, - IReadOnlyList? embeddedDocuments) - { - Id = id ?? ""; - Kind = kind ?? ""; - Props = props ?? new Dictionary(StringComparer.Ordinal); - Children = children ?? Array.Empty(); - StateBindings = stateBindings ?? Array.Empty(); - EmbeddedDocuments = embeddedDocuments ?? Array.Empty(); - } - - [Key(0)] - public string Id { get; } - - [Key(1)] - public string Kind { get; } - - [Key(2)] - public IReadOnlyDictionary Props { get; } - - [Key(3)] - public IReadOnlyList Children { get; } - - [Key(4)] - public IReadOnlyList StateBindings { get; } - - [Key(5)] - public IReadOnlyList EmbeddedDocuments { get; } - - private static IReadOnlyList StateBindingsFromProps( - IReadOnlyDictionary? props) - { - if (props == null || props.Count == 0) - return Array.Empty(); - - var bindings = new List(); - foreach (var prop in props) - { - if (string.IsNullOrWhiteSpace(prop.Value) || - !prop.Key.EndsWith("PointerId", StringComparison.Ordinal)) - { - continue; - } - - var targetProp = prop.Key.Substring(0, prop.Key.Length - "PointerId".Length); - if (targetProp.Length == 0) - targetProp = "value"; - bindings.Add(EveSurfaceStateBinding.FromDescriptor(new CultMeshStateBindingDescriptor(targetProp, prop.Value))); - } - - return bindings; - } - } - - [MessagePackObject] - public sealed class EveSurfaceStateBinding - { - [SerializationConstructor] - public EveSurfaceStateBinding( - string targetProp, - string pointerId, - string sourceId, - string schemaId, - EveSurfaceRouteHint routeHint) - { - TargetProp = string.IsNullOrWhiteSpace(targetProp) ? "value" : targetProp; - PointerId = pointerId ?? ""; - SourceId = sourceId ?? ""; - SchemaId = schemaId ?? ""; - RouteHint = routeHint ?? EveSurfaceRouteHint.Automatic; - } - - [Key(0)] - public string TargetProp { get; } - - [Key(1)] - public string PointerId { get; } - - [Key(2)] - public string SourceId { get; } - - [Key(3)] - public string SchemaId { get; } - - [Key(4)] - public EveSurfaceRouteHint RouteHint { get; } - - public static EveSurfaceStateBinding FromDescriptor(CultMeshStateBindingDescriptor descriptor) - { - if (descriptor == null) throw new ArgumentNullException(nameof(descriptor)); - return new EveSurfaceStateBinding( - descriptor.TargetProp, - descriptor.PointerId, - descriptor.SourceId ?? "", - descriptor.SchemaId ?? "", - EveSurfaceRouteHint.FromRoute(descriptor.RouteHint)); - } - } - - [MessagePackObject] - public sealed class EveSurfaceOperationBinding - { - [SerializationConstructor] - public EveSurfaceOperationBinding( - string operationId, - string label, - string schemaId, - EveSurfaceRouteHint routeHint) - { - OperationId = operationId ?? ""; - Label = label ?? ""; - SchemaId = schemaId ?? ""; - RouteHint = routeHint ?? EveSurfaceRouteHint.Automatic; - } - - [Key(0)] - public string OperationId { get; } - - [Key(1)] - public string Label { get; } - - [Key(2)] - public string SchemaId { get; } - - [Key(3)] - public EveSurfaceRouteHint RouteHint { get; } - - public static EveSurfaceOperationBinding FromDescriptor(CultMeshOperationBindingDescriptor descriptor) - { - if (descriptor == null) throw new ArgumentNullException(nameof(descriptor)); - return new EveSurfaceOperationBinding( - descriptor.OperationId, - descriptor.Label, - descriptor.SchemaId, - EveSurfaceRouteHint.FromRoute(descriptor.RouteHint)); - } - } - - [MessagePackObject] - public sealed class EveSurfaceRouteHint - { - public static readonly EveSurfaceRouteHint Automatic = - new(nameof(CultMeshLocalityKind.Automatic), ""); - - [SerializationConstructor] - public EveSurfaceRouteHint(string kind, string description) - { - Kind = string.IsNullOrWhiteSpace(kind) ? nameof(CultMeshLocalityKind.Automatic) : kind; - Description = description ?? ""; - } - - [Key(0)] - public string Kind { get; } - - [Key(1)] - public string Description { get; } - - public static EveSurfaceRouteHint FromRoute(CultMeshRouteHint routeHint) - { - var record = CultMeshRouteRecord.FromRoute(routeHint); - return new EveSurfaceRouteHint(record.Kind, record.Description); - } - } - - [MessagePackObject] - public sealed class EveEmbeddedDocumentSlot - { - [SerializationConstructor] - public EveEmbeddedDocumentSlot( - string slotId, - string documentId, - string schemaId, - string presentationKind, - EveSurfaceRouteHint routeHint) - { - SlotId = slotId ?? ""; - DocumentId = documentId ?? ""; - SchemaId = schemaId ?? ""; - PresentationKind = presentationKind ?? ""; - RouteHint = routeHint ?? EveSurfaceRouteHint.Automatic; - } - - public EveEmbeddedDocumentSlot( - string slotId, - string documentId, - string schemaId, - string presentationKind) - : this(slotId, documentId, schemaId, presentationKind, EveSurfaceRouteHint.Automatic) - { - } - - [Key(0)] - public string SlotId { get; } - - [Key(1)] - public string DocumentId { get; } - - [Key(2)] - public string SchemaId { get; } - - [Key(3)] - public string PresentationKind { get; } - - [Key(4)] - public EveSurfaceRouteHint RouteHint { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceStyleToken - { - [SerializationConstructor] - public EveSurfaceStyleToken(string name, string value) - { - Name = name ?? ""; - Value = value ?? ""; - } - - [Key(0)] - public string Name { get; } - - [Key(1)] - public string Value { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceCommandTemplate - { - public EveSurfaceCommandTemplate(string command, string label, string transport = "cultmesh") - : this(new EveSurfaceOperationBinding( - command, - label, - "", - new EveSurfaceRouteHint(nameof(CultMeshLocalityKind.Automatic), transport))) - { - } - - [SerializationConstructor] - public EveSurfaceCommandTemplate(EveSurfaceOperationBinding operation) - { - Operation = operation ?? throw new ArgumentNullException(nameof(operation)); - } - - public EveSurfaceCommandTemplate(CultMeshOperationBindingDescriptor operation) - : this(EveSurfaceOperationBinding.FromDescriptor(operation)) - { - } - - [Key(0)] - public EveSurfaceOperationBinding Operation { get; } - - [IgnoreMember] - public string Command => Operation.OperationId; - - [IgnoreMember] - public string Label => Operation.Label; - } - - /// - /// Fluent CultUI/Eve surface builder. It preserves the old CultUI panel ergonomics while emitting portable documents. - /// - public sealed class EveSurfaceBuilder - { - private readonly string _surfaceId; - private readonly List _children = new(); - private readonly List _styles = new(); - private readonly List _commands = new(); - private long _version = 1; - private string _providerId = "gamecult"; - private string _providerKind = "cultui.surface"; - private string _title = ""; - private string _updatedAtUtc = ""; - - public EveSurfaceBuilder(string surfaceId) - { - _surfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId)); - } - - public EveSurfaceBuilder Provider(string providerId, string providerKind) - { - _providerId = providerId ?? ""; - _providerKind = providerKind ?? ""; - return this; - } - - public EveSurfaceBuilder Title(string title) - { - _title = title ?? ""; - _children.Add(Text($"{_surfaceId}.title", _title, "text.title")); - return this; - } - - public EveSurfaceBuilder TitleSubtitle(string title, string subtitle) - { - _title = string.IsNullOrWhiteSpace(subtitle) ? title ?? "" : $"{title} {subtitle}"; - _children.Add(Text($"{_surfaceId}.title", title ?? "", "text.title")); - _children.Add(Text($"{_surfaceId}.subtitle", subtitle ?? "", "text.subtitle")); - return this; - } - - public EveSurfaceBuilder Text(string text, string? id = null) - { - _children.Add(Text(id ?? $"{_surfaceId}.text.{_children.Count}", text, "text")); - return this; - } - - public EveSurfaceBuilder Button(string label, string command) - { - var operation = CultMesh.OperationBinding(command, label); - _commands.Add(new EveSurfaceCommandTemplate(operation)); - _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceBuilder Button(string label, CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _commands.Add(new EveSurfaceCommandTemplate(operation)); - _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceBuilder ButtonColumn(string id, Action build) - { - return Group(id, "column", build); - } - - public EveSurfaceBuilder ButtonRow(string id, Action build) - { - return Group(id, "row", build); - } - - public EveSurfaceBuilder Form(string id, Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var form = new EveSurfaceFormBuilder(id, AddCommand); - build(form); - _children.Add(form.Build()); - return this; - } - - public EveSurfaceBuilder EmbeddedDocument( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - { - _children.Add(new EveSurfaceComponent( - $"{_surfaceId}.slot.{Slug(slotId)}", - "surface.slot", - new Dictionary(StringComparer.Ordinal) - { - ["slotId"] = slotId ?? "", - ["documentId"] = documentId ?? "", - ["schemaId"] = schemaId ?? "", - ["presentationKind"] = presentationKind ?? "" - }, - Array.Empty(), - Array.Empty(), - new[] - { - new EveEmbeddedDocumentSlot( - slotId ?? "", - documentId ?? "", - schemaId ?? "", - presentationKind ?? "", - routeHint == null ? EveSurfaceRouteHint.Automatic : EveSurfaceRouteHint.FromRoute(routeHint)) - })); - return this; - } - - public EveSurfaceBuilder Style(string name, string value) - { - _styles.Add(new EveSurfaceStyleToken(name, value)); - return this; - } - - public EveSurfaceBuilder Version(long version) - { - _version = version; - return this; - } - - public EveSurfaceBuilder UpdatedAtUtc(string updatedAtUtc) - { - _updatedAtUtc = updatedAtUtc ?? ""; - return this; - } - - public EveSurfaceDocument Build() - { - return new EveSurfaceDocument( - _providerId, - _providerKind, - _title, - _version, - string.IsNullOrWhiteSpace(_updatedAtUtc) ? DateTime.UtcNow.ToString("O") : _updatedAtUtc, - new EveSurfaceTree( - _surfaceId, - new EveSurfaceComponent( - $"{_surfaceId}.root", - "surface", - EmptyProps(), - _children.ToArray()), - _styles.ToArray()), - _commands.ToArray()); - } - - private EveSurfaceBuilder Group(string id, string kind, Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var group = new EveSurfaceGroupBuilder(id, kind, AddCommand); - build(group); - _children.Add(group.Build()); - return this; - } - - private void AddCommand(EveSurfaceCommandTemplate command) - { - if (command != null) - _commands.Add(command); - } - - internal static EveSurfaceComponent Text(string id, string value, string kind) - { - return new EveSurfaceComponent( - id, - kind, - new Dictionary(StringComparer.Ordinal) - { - ["value"] = value ?? "" - }, - Array.Empty()); - } - - internal static EveSurfaceComponent ButtonComponent( - string id, - string label, - CultMeshOperationBindingDescriptor operation) - { - return new EveSurfaceComponent( - id, - "control.button", - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? operation.Label, - ["command"] = operation.OperationId, - ["operationId"] = operation.OperationId, - ["schemaId"] = operation.SchemaId - }, - Array.Empty()); - } - - internal static Dictionary EmptyProps() - { - return new Dictionary(StringComparer.Ordinal); - } - - internal static string Slug(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return "unnamed"; - - var chars = value - .Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.') - .ToArray(); - var slug = new string(chars).Trim('.'); - while (slug.Contains("..", StringComparison.Ordinal)) - slug = slug.Replace("..", ".", StringComparison.Ordinal); - return string.IsNullOrWhiteSpace(slug) ? "unnamed" : slug; - } - - private static string RequireNonEmpty(string value, string paramName) - { - return string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Value must be non-empty.", paramName) - : value; - } - } - - public sealed class EveSurfaceGroupBuilder - { - private readonly string _id; - private readonly string _kind; - private readonly Action _addCommand; - private readonly List _children = new(); - - internal EveSurfaceGroupBuilder( - string id, - string kind, - Action addCommand) - { - _id = id ?? ""; - _kind = kind ?? "column"; - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceGroupBuilder Button(string label, string command) - { - var operation = CultMesh.OperationBinding(command, label); - _addCommand(new EveSurfaceCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceGroupBuilder Button(string label, CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveSurfaceCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent(_id, _kind, EveSurfaceBuilder.EmptyProps(), _children.ToArray()); - } - } - - public sealed class EveSurfaceFormBuilder - { - private readonly string _id; - private readonly Action _addCommand; - private readonly List _children = new(); - - internal EveSurfaceFormBuilder(string id, Action addCommand) - { - _id = id ?? ""; - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceFormBuilder Text( - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveSurfaceCommandTemplate(operation)); - _children.Add(Control( - "control.text", - label, - value, - operation, - binding)); - return this; - } - - public EveSurfaceFormBuilder Toggle( - string label, - bool value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveSurfaceCommandTemplate(operation)); - _children.Add(Control( - "control.toggle", - label, - value ? "true" : "false", - operation, - binding)); - return this; - } - - public EveSurfaceFormBuilder Metric( - string label, - string value, - CultMeshStateBindingDescriptor? binding = null) - { - var bindings = binding == null - ? Array.Empty() - : new[] { EveSurfaceStateBinding.FromDescriptor(binding) }; - _children.Add(new EveSurfaceComponent( - $"{_id}.metric.{EveSurfaceBuilder.Slug(label)}", - "metric", - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? "", - ["value"] = value ?? "" - }, - Array.Empty(), - bindings)); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent(_id, "form", EveSurfaceBuilder.EmptyProps(), _children.ToArray()); - } - - private EveSurfaceComponent Control( - string kind, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding) - { - var bindings = binding == null - ? Array.Empty() - : new[] { EveSurfaceStateBinding.FromDescriptor(binding) }; - return new EveSurfaceComponent( - $"{_id}.{EveSurfaceBuilder.Slug(label)}", - kind, - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? "", - ["value"] = value ?? "", - ["command"] = operation.OperationId, - ["operationId"] = operation.OperationId, - ["schemaId"] = operation.SchemaId - }, - Array.Empty(), - bindings); - } - } - - public static class EveSurface - { - public static EveSurfaceBuilder Create(string surfaceId) - { - return new EveSurfaceBuilder(surfaceId); - } - } -} diff --git a/tests/GameCult.Mesh.Tests/EveSurfaceBuilderTests.cs b/tests/GameCult.Mesh.Tests/EveSurfaceBuilderTests.cs deleted file mode 100644 index 02a857d4..00000000 --- a/tests/GameCult.Mesh.Tests/EveSurfaceBuilderTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.Linq; -using FluentAssertions; -using GameCult.Caching.MessagePack; -using NUnit.Framework; - -namespace GameCult.Mesh.Tests; - -public sealed class EveSurfaceBuilderTests -{ - [Test] - public void Builder_ComposesMainMenuLikeLegacyCultUiPanel() - { - var surface = EveSurface.Create("aetheria.main_menu.root") - .Provider("aetheria", "game.menu") - .TitleSubtitle("AETHERIA", "TERMINUS") - .ButtonColumn("aetheria.main_menu.root.actions", actions => actions - .Button("Continue", "aetheria.main_menu.root.continue") - .Button("New Game", "aetheria.main_menu.root.new_game") - .Button("Settings", "aetheria.main_menu.root.show_settings") - .Button("Quit", "aetheria.main_menu.root.quit")) - .Style("font.title.family", "Montserrat") - .Style("font.body.family", "Ubuntu") - .UpdatedAtUtc("2026-07-01T00:00:00.0000000Z") - .Build(); - - surface.ProviderId.Should().Be("aetheria"); - surface.ProviderKind.Should().Be("game.menu"); - surface.Title.Should().Be("AETHERIA TERMINUS"); - surface.UpdatedAtUtc.Should().Be("2026-07-01T00:00:00.0000000Z"); - surface.Surface.Id.Should().Be("aetheria.main_menu.root"); - surface.Surface.Root.Kind.Should().Be("surface"); - surface.Surface.Styles.Select(style => style.Name).Should().Contain(["font.title.family", "font.body.family"]); - - var rootChildren = surface.Surface.Root.Children; - rootChildren[0].Kind.Should().Be("text.title"); - rootChildren[0].Props["value"].Should().Be("AETHERIA"); - rootChildren[1].Kind.Should().Be("text.subtitle"); - rootChildren[1].Props["value"].Should().Be("TERMINUS"); - - var actions = rootChildren[2]; - actions.Kind.Should().Be("column"); - actions.Children.Should().HaveCount(4); - actions.Children[2].Kind.Should().Be("control.button"); - actions.Children[2].Props["label"].Should().Be("Settings"); - actions.Children[2].Props["command"].Should().Be("aetheria.main_menu.root.show_settings"); - - surface.Commands.Select(command => command.Command).Should().Equal( - "aetheria.main_menu.root.continue", - "aetheria.main_menu.root.new_game", - "aetheria.main_menu.root.show_settings", - "aetheria.main_menu.root.quit"); - } - - [Test] - public void Form_ControlsCarryOperationAndStateBindings() - { - var nameOperation = CultMesh.OperationBinding("settings.player.name.set", "Set Player Name", "gamecult.settings.name.v1"); - var asteroidOperation = CultMesh.OperationBinding("settings.graphics.asteroids.toggle", "Toggle Asteroids"); - var nameBinding = new CultMeshStateBindingDescriptor( - "value", - "player.settings.name", - "global:aetheria.player_settings.v1", - "gamecult.aetheria.player_settings.v1"); - var asteroidBinding = new CultMeshStateBindingDescriptor( - "value", - "player.settings.show_asteroids", - "global:aetheria.player_settings.v1", - "gamecult.aetheria.player_settings.v1"); - - var surface = EveSurface.Create("aetheria.main_menu.player_settings") - .Form("aetheria.main_menu.player_settings.form", form => form - .Text("Name", "Meta", nameOperation, nameBinding) - .Toggle("Show Asteroids In Minimap", true, asteroidOperation, asteroidBinding) - .Metric("Significant Digits", "3")) - .Build(); - - var form = surface.Surface.Root.Children.Single(); - form.Kind.Should().Be("form"); - form.Children.Should().HaveCount(3); - - var name = form.Children[0]; - name.Kind.Should().Be("control.text"); - name.Props["label"].Should().Be("Name"); - name.Props["value"].Should().Be("Meta"); - name.Props["command"].Should().Be("settings.player.name.set"); - name.StateBindings.Single().PointerId.Should().Be("player.settings.name"); - - var toggle = form.Children[1]; - toggle.Kind.Should().Be("control.toggle"); - toggle.Props["value"].Should().Be("true"); - toggle.StateBindings.Single().PointerId.Should().Be("player.settings.show_asteroids"); - - surface.Commands.Select(command => command.Command).Should().Equal( - "settings.player.name.set", - "settings.graphics.asteroids.toggle"); - } - - [Test] - public void EmbeddedDocumentSlot_CarriesNestedSurfaceContract() - { - var surface = EveSurface.Create("station.inventory") - .EmbeddedDocument( - "cargo", - "daemon:aetheria.inventory.station.v1", - "gamecult.eve.surface.v1", - "inventory.grid", - new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "daemon-published CultUI surface")) - .Build(); - - var slot = surface.Surface.Root.Children.Single(); - slot.Kind.Should().Be("surface.slot"); - slot.EmbeddedDocuments.Should().ContainSingle(); - slot.EmbeddedDocuments[0].SlotId.Should().Be("cargo"); - slot.EmbeddedDocuments[0].DocumentId.Should().Be("daemon:aetheria.inventory.station.v1"); - slot.EmbeddedDocuments[0].SchemaId.Should().Be("gamecult.eve.surface.v1"); - slot.EmbeddedDocuments[0].RouteHint.Kind.Should().Be(nameof(CultMeshLocalityKind.SharedMemory)); - } - - [Test] - public void SurfaceDocument_RoundTripsThroughCultCacheMessagePack() - { - var surface = EveSurface.Create("aetheria.main_menu.player_settings") - .Provider("aetheria", "game.menu") - .TitleSubtitle("Gameplay", "Settings") - .Form("aetheria.main_menu.player_settings.form", form => form - .Text( - "Name", - "Meta", - CultMesh.OperationBinding("settings.player.name.set", "Set Player Name"), - new CultMeshStateBindingDescriptor( - "value", - "player.settings.name", - "global:aetheria.player_settings.v1", - "gamecult.aetheria.player_settings.v1"))) - .Build(); - - var payload = CultDocumentMessagePackSerialization.Serialize(surface); - var roundTrip = CultDocumentMessagePackSerialization.Deserialize(payload); - - roundTrip.Surface.Id.Should().Be(surface.Surface.Id); - roundTrip.Surface.Root.Children[2].Children.Single().StateBindings.Single().PointerId - .Should().Be("player.settings.name"); - roundTrip.Commands.Single().Command.Should().Be("settings.player.name.set"); - } -} From 14a4bc021f9b9ad5b7d5c4db2d2b7a4deff0c953 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 2 Jul 2026 21:00:36 +0100 Subject: [PATCH 109/498] Harden directory MessagePack record replacement --- .../DirectoryMessagePackBackingStore.cs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 4792310f..f46e3ee2 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; +using System.Threading; using MessagePack; namespace GameCult.Caching.MessagePack; @@ -274,7 +275,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore if (File.Exists(path)) { - File.Replace(tempPath, path, null); + ReplaceExistingFile(tempPath, path); } else { @@ -289,4 +290,26 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore } } } + + private static void ReplaceExistingFile(string sourcePath, string destinationPath) + { + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + File.Replace(sourcePath, destinationPath, null); + return; + } + catch (IOException) when (attempt < 2) + { + Thread.Sleep(10 * (attempt + 1)); + } + catch (UnauthorizedAccessException) when (attempt < 2) + { + Thread.Sleep(10 * (attempt + 1)); + } + } + + File.Copy(sourcePath, destinationPath, overwrite: true); + } } From 8a4a457dad489b6ece9fa3ba296193b5ff017cf9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 03:34:49 +0100 Subject: [PATCH 110/498] Build CultLib Unity package --- scripts/build-unity-package.ps1 | 84 +++++++++++++++++++ unity/org.gamecult.cultlib/README.md | 8 ++ .../Runtime/GameCult.CultLib.asmdef | 38 +++++++++ unity/org.gamecult.cultlib/package.json | 11 +++ 4 files changed, 141 insertions(+) create mode 100644 scripts/build-unity-package.ps1 create mode 100644 unity/org.gamecult.cultlib/README.md create mode 100644 unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef create mode 100644 unity/org.gamecult.cultlib/package.json diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 new file mode 100644 index 00000000..e5cc82aa --- /dev/null +++ b/scripts/build-unity-package.ps1 @@ -0,0 +1,84 @@ +param( + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\unity\org.gamecult.cultlib" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\GameCult.Mesh\GameCult.Mesh.csproj" +$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultlib" +$publishRoot = Join-Path $repoRoot "artifacts\unity-publish" +$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} +$pluginRoot = Join-Path $outputRoot "Runtime\Plugins" + +if (Test-Path -LiteralPath $publishRoot) { + Remove-Item -LiteralPath $publishRoot -Recurse -Force +} +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} + +dotnet publish $projectPath -c $Configuration -o $publishRoot +if ($LASTEXITCODE -ne 0) { + throw "CultLib publish failed with exit code $LASTEXITCODE" +} + +New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null +Copy-Item -LiteralPath (Join-Path $templateRoot "package.json") -Destination $outputRoot +Copy-Item -LiteralPath (Join-Path $templateRoot "README.md") -Destination $outputRoot + +$expectedAssemblies = @( + "ConcurrentCollections.dll", + "GameCult.Caching.dll", + "GameCult.Caching.MessagePack.dll", + "GameCult.Logging.dll", + "GameCult.Mesh.dll", + "GameCult.Networking.dll", + "Isopoh.Cryptography.Argon2.dll", + "Isopoh.Cryptography.Blake2b.dll", + "Isopoh.Cryptography.SecureArray.dll", + "LiteNetLib.dll", + "MessagePack.Annotations.dll", + "MessagePack.dll", + "Microsoft.Bcl.AsyncInterfaces.dll", + "Microsoft.Bcl.TimeProvider.dll", + "Microsoft.NET.StringTools.dll", + "R3.dll", + "System.Collections.Immutable.dll", + "System.ComponentModel.Annotations.dll", + "System.IO.Pipelines.dll", + "System.Runtime.CompilerServices.Unsafe.dll", + "System.Text.Encodings.Web.dll", + "System.Text.Json.dll", + "System.Threading.Channels.dll" +) +$publishedByName = @{} +foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { + $publishedByName[$assembly.Name] = $assembly +} +foreach ($assemblyName in $expectedAssemblies) { + if (-not $publishedByName.ContainsKey($assemblyName)) { + throw "CultLib Unity package is missing expected assembly: $assemblyName" + } +} + +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") ` + -Destination (Join-Path $outputRoot "Runtime") +foreach ($assemblyName in $expectedAssemblies) { + $assembly = $publishedByName[$assemblyName] + Copy-Item -LiteralPath $assembly.FullName -Destination $pluginRoot + $pdb = [System.IO.Path]::ChangeExtension($assembly.FullName, ".pdb") + if (Test-Path -LiteralPath $pdb) { + Copy-Item -LiteralPath $pdb -Destination $pluginRoot + } +} + +$manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw | ConvertFrom-Json +Write-Host "CultLib Unity package: $outputRoot" +Write-Host "Package: $($manifest.name)@$($manifest.version)" +Write-Host "Managed assemblies: $($expectedAssemblies.Count)" diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md new file mode 100644 index 00000000..20fd4597 --- /dev/null +++ b/unity/org.gamecult.cultlib/README.md @@ -0,0 +1,8 @@ +# CultLib for Unity + +This package is generated from the CultLib .NET projects. It carries the same +CultCache, CultNet, and CultMesh assemblies consumed through NuGet by normal +.NET applications, staged as Unity-compatible managed plugins. + +Build it with `scripts/build-unity-package.ps1`. Do not edit generated plugin +assemblies in the package output. diff --git a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef new file mode 100644 index 00000000..6a2d7707 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef @@ -0,0 +1,38 @@ +{ + "name": "GameCult.CultLib", + "rootNamespace": "GameCult", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "ConcurrentCollections.dll", + "GameCult.Caching.dll", + "GameCult.Caching.MessagePack.dll", + "GameCult.Logging.dll", + "GameCult.Mesh.dll", + "GameCult.Networking.dll", + "Isopoh.Cryptography.Argon2.dll", + "Isopoh.Cryptography.Blake2b.dll", + "Isopoh.Cryptography.SecureArray.dll", + "LiteNetLib.dll", + "MessagePack.Annotations.dll", + "MessagePack.dll", + "Microsoft.Bcl.AsyncInterfaces.dll", + "Microsoft.Bcl.TimeProvider.dll", + "Microsoft.NET.StringTools.dll", + "R3.dll", + "System.Collections.Immutable.dll", + "System.ComponentModel.Annotations.dll", + "System.IO.Pipelines.dll", + "System.Runtime.CompilerServices.Unsafe.dll", + "System.Text.Encodings.Web.dll", + "System.Text.Json.dll", + "System.Threading.Channels.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json new file mode 100644 index 00000000..12e13589 --- /dev/null +++ b/unity/org.gamecult.cultlib/package.json @@ -0,0 +1,11 @@ +{ + "name": "org.gamecult.cultlib", + "displayName": "CultLib", + "version": "1.0.3", + "unity": "2021.3", + "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", + "license": "MIT", + "author": { + "name": "GameCult" + } +} From 36323c71547d9718b75bd41db107b02e6df4f631 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 05:05:27 +0100 Subject: [PATCH 111/498] Make CultMesh transport records serializable --- src/GameCult.Mesh/CultMeshPrimitives.cs | 32 +++++++++++++++---------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index b6705f20..a7345ef2 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -57,9 +57,11 @@ namespace GameCult.Mesh /// /// Flat, transport-friendly fields for a CultMesh route hint. /// + [MessagePackObject] public sealed class CultMeshRouteRecord { /// Creates flat route fields. + [SerializationConstructor] public CultMeshRouteRecord(string? kind = null, string? description = null) { Kind = kind ?? ""; @@ -67,10 +69,10 @@ namespace GameCult.Mesh } /// Gets the serialized route locality kind. - public string Kind { get; } + [Key(0)] public string Kind { get; } /// Gets the serialized route description. - public string Description { get; } + [Key(1)] public string Description { get; } /// Creates flat route fields from a route hint. public static CultMeshRouteRecord FromRoute(CultMeshRouteHint? routeHint) @@ -3171,9 +3173,11 @@ namespace GameCult.Mesh /// /// Transport-friendly fields for a typed state binding descriptor. /// + [MessagePackObject] public sealed class CultMeshStateBindingRecord { /// Creates a state binding record. + [SerializationConstructor] public CultMeshStateBindingRecord( string? targetProp = null, string? pointerId = null, @@ -3191,22 +3195,22 @@ namespace GameCult.Mesh } /// Gets the component property receiving the resolved state value. - public string TargetProp { get; } + [Key(0)] public string TargetProp { get; } /// Gets the semantic CultMesh state pointer id. - public string PointerId { get; } + [Key(1)] public string PointerId { get; } /// Gets the source document, cache, native view, or daemon source id. - public string SourceId { get; } + [Key(2)] public string SourceId { get; } /// Gets the source schema id. - public string SchemaId { get; } + [Key(3)] public string SchemaId { get; } /// Gets the flattened route kind. - public string RouteKind { get; } + [Key(4)] public string RouteKind { get; } /// Gets the flattened route description. - public string RouteDescription { get; } + [Key(5)] public string RouteDescription { get; } /// Creates transport-friendly fields from a state binding descriptor. public static CultMeshStateBindingRecord FromBinding(CultMeshStateBindingDescriptor? binding) @@ -3300,9 +3304,11 @@ namespace GameCult.Mesh /// /// Transport-friendly fields for a typed operation binding descriptor. /// + [MessagePackObject] public sealed class CultMeshOperationBindingRecord { /// Creates an operation binding record. + [SerializationConstructor] public CultMeshOperationBindingRecord( string? operationId = null, string? label = null, @@ -3318,19 +3324,19 @@ namespace GameCult.Mesh } /// Gets the semantic CultMesh operation id. - public string OperationId { get; } + [Key(0)] public string OperationId { get; } /// Gets the human-facing command label. - public string Label { get; } + [Key(1)] public string Label { get; } /// Gets the optional request schema id for the operation. - public string SchemaId { get; } + [Key(2)] public string SchemaId { get; } /// Gets the flattened route kind. - public string RouteKind { get; } + [Key(3)] public string RouteKind { get; } /// Gets the flattened route description. - public string RouteDescription { get; } + [Key(4)] public string RouteDescription { get; } /// Creates transport-friendly fields from an operation binding descriptor. public static CultMeshOperationBindingRecord FromBinding(CultMeshOperationBindingDescriptor? binding) From 905c5254b41e5d8af75e3fcbe57ff9882628fce2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 06:37:10 +0100 Subject: [PATCH 112/498] Reuse CultMesh snapshot connections --- src/GameCult.Mesh/CultMeshSnapshots.cs | 176 +++++++++++++++++- src/GameCult.Networking/CultNetTransport.cs | 31 ++- .../CultMeshStreamingTests.cs | 31 +++ .../NetworkingTests.cs | 52 ++++++ 4 files changed, 282 insertions(+), 8 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 5b01c8b2..8926ec50 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -60,6 +61,133 @@ namespace GameCult.Mesh public long RudpResendDelayMs { get; set; } = 25; } + /// + /// Reuses one connected CultNet schema client for an ordered sequence of snapshot requests. + /// + public sealed class CultMeshSnapshotSession : IDisposable + { + private readonly string _endpoint; + private readonly CultMeshSnapshotRequestOptions _defaults; + private readonly ICultNetSchemaClient _client; + private readonly CultNetDocumentRegistry _registry; + private readonly SemaphoreSlim _requests = new(1, 1); + private readonly object _completionLock = new(); + private TaskCompletionSource? _completion; + private string? _messageId; + private bool _disposed; + + internal CultMeshSnapshotSession( + string endpoint, + CultMeshSnapshotRequestOptions options, + CultNetDocumentRegistry? registry) + { + _endpoint = string.IsNullOrWhiteSpace(endpoint) + ? throw new ArgumentException("Value must be non-empty.", nameof(endpoint)) + : endpoint; + _defaults = CultMesh.CloneSnapshotRequestOptions(options); + _registry = registry ?? new CultNetDocumentRegistry(); + _client = CultMesh.CreateSnapshotClient(endpoint, _defaults)(); + _client.OnCultNet(OnResponse); + _client.OnCultNet(OnError); + var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint); + _client.Connect(host, port); + } + + /// Fetches one raw snapshot while retaining the underlying endpoint connection. + public async Task FetchSnapshotAsync( + IReadOnlyList? schemaIds = null, + IReadOnlyList? recordKeys = null) + { + ThrowIfDisposed(); + await _requests.WaitAsync().ConfigureAwait(false); + try + { + await CultMesh.WaitForSnapshotClientConnectionAsync( + _client, + _endpoint, + _defaults.ConnectTimeout) + .ConfigureAwait(false); + var options = CultMesh.CloneSnapshotRequestOptions(_defaults); + options.SchemaIds = schemaIds ?? options.SchemaIds; + options.RecordKeys = recordKeys ?? options.RecordKeys; + var messageId = CultMesh.CreateSnapshotMessageId(options); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + lock (_completionLock) + { + _messageId = messageId; + _completion = completion; + } + _client.SendCultNet(new CultNetSnapshotRequestMessage + { + MessageId = messageId, + SchemaIds = CultMesh.CleanSnapshotFilter(options.SchemaIds), + RecordKeys = CultMesh.CleanSnapshotFilter(options.RecordKeys), + ShardId = string.IsNullOrWhiteSpace(options.ShardId) ? null : options.ShardId, + ShardEpoch = options.ShardEpoch + }); + return await CultMesh.WaitForSnapshotResponseAsync( + completion.Task, + _endpoint, + options, + messageId) + .ConfigureAwait(false); + } + finally + { + lock (_completionLock) + { + _messageId = null; + _completion = null; + } + _requests.Release(); + } + } + + /// Fetches and decodes typed documents over the retained endpoint connection. + public async Task> FetchDocumentsAsync( + IReadOnlyList? recordKeys = null, + IReadOnlyList? schemaIds = null) + where TDocument : class + { + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var resolvedSchemas = schemaIds ?? (recordKeys is { Count: > 0 } ? null : new[] { descriptor.SchemaId }); + var snapshot = await FetchSnapshotAsync(resolvedSchemas, recordKeys).ConfigureAwait(false); + return CultMesh.DecodeSnapshotDocuments(snapshot, _registry); + } + + /// Closes the retained endpoint connection and rejects any active request. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + lock (_completionLock) + _completion?.TrySetException(new ObjectDisposedException(nameof(CultMeshSnapshotSession))); + _client.Dispose(); + _requests.Dispose(); + } + + private void OnResponse(CultNetSnapshotResponseRawMessage response) + { + lock (_completionLock) + { + if (string.Equals(response.MessageId, _messageId, StringComparison.Ordinal)) + _completion?.TrySetResult(response); + } + } + + private void OnError(CultNetErrorMessage error) + { + lock (_completionLock) + _completion?.TrySetException(new InvalidOperationException(error.Error)); + } + + private void ThrowIfDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(CultMeshSnapshotSession)); + } + } + /// /// Options for a typed remote snapshot surface bound to one CultNet endpoint. /// @@ -499,6 +627,20 @@ namespace GameCult.Mesh return new CultMeshSnapshotEndpoint(endpoint, options); } + /// + /// Opens one reusable CultNet snapshot connection for ordered bulk document transfer. + /// + public static CultMeshSnapshotSession SnapshotSession( + string endpoint, + CultMeshSnapshotRequestOptions? options = null, + CultNetDocumentRegistry? registry = null) + { + return new CultMeshSnapshotSession( + endpoint, + options ?? new CultMeshSnapshotRequestOptions(), + registry); + } + /// /// Describes one typed document to bind from a CultMesh snapshot endpoint. /// @@ -667,7 +809,7 @@ namespace GameCult.Mesh return DecodeSnapshotDocuments(snapshot, registry ?? new CultNetDocumentRegistry()); } - private static Func CreateSnapshotClient( + internal static Func CreateSnapshotClient( string endpoint, CultMeshSnapshotRequestOptions options) { @@ -693,7 +835,31 @@ namespace GameCult.Mesh }; } - private static string CreateSnapshotMessageId(CultMeshSnapshotRequestOptions options) + internal static CultMeshSnapshotRequestOptions CloneSnapshotRequestOptions( + CultMeshSnapshotRequestOptions? source) + { + if (source == null) return new CultMeshSnapshotRequestOptions(); + return new CultMeshSnapshotRequestOptions + { + SchemaIds = source.SchemaIds, + RecordKeys = source.RecordKeys, + ShardId = source.ShardId, + ShardEpoch = source.ShardEpoch, + ResponseTimeout = source.ResponseTimeout, + ConnectTimeout = source.ConnectTimeout, + MessageIdPrefix = source.MessageIdPrefix, + Security = source.Security, + ConfigureClient = source.ConfigureClient, + CreateClient = source.CreateClient, + RudpRuntimeId = source.RudpRuntimeId, + RudpConnectionId = source.RudpConnectionId, + RudpConnectPayload = source.RudpConnectPayload, + RudpMaxFragmentBytes = source.RudpMaxFragmentBytes, + RudpResendDelayMs = source.RudpResendDelayMs + }; + } + + internal static string CreateSnapshotMessageId(CultMeshSnapshotRequestOptions options) { var prefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) ? "cultmesh:snapshot" @@ -701,7 +867,7 @@ namespace GameCult.Mesh return $"{prefix}:{Guid.NewGuid():N}"; } - private static async Task WaitForSnapshotClientConnectionAsync( + internal static async Task WaitForSnapshotClientConnectionAsync( ICultNetSchemaClient client, string endpoint, TimeSpan timeout) @@ -716,7 +882,7 @@ namespace GameCult.Mesh } } - private static async Task WaitForSnapshotResponseAsync( + internal static async Task WaitForSnapshotResponseAsync( Task responseTask, string endpoint, CultMeshSnapshotRequestOptions options, @@ -735,7 +901,7 @@ namespace GameCult.Mesh return await responseTask.ConfigureAwait(false); } - private static string[]? CleanSnapshotFilter(IReadOnlyList? values) + internal static string[]? CleanSnapshotFilter(IReadOnlyList? values) { if (values is not { Count: > 0 }) return null; diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index 98da8562..a17d7e78 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -1079,9 +1079,11 @@ namespace GameCult.Networking if (packet.PacketType == CultNetRudpPacketType.Ack || packet.PacketType == CultNetRudpPacketType.Pong) { - RememberReceived(packet.Sequence); + if (packet.PacketType == CultNetRudpPacketType.Pong) + RememberReceived(packet.Sequence); return new CultNetRudpReceiveResult { + Delivered = DrainUnblockedOrdered(), Pong = packet.PacketType == CultNetRudpPacketType.Pong, PongPayload = packet.PacketType == CultNetRudpPacketType.Pong ? packet.Payload ?? Array.Empty() @@ -1131,7 +1133,17 @@ namespace GameCult.Networking /// public CultNetRudpPacket CreateAck() { - return CreatePacket(CultNetRudpPacketType.Ack, "control", Array.Empty(), reliable: false, ordered: false, sequenced: false); + var (ack, ackMask) = AckState(); + return new CultNetRudpPacket + { + PacketType = CultNetRudpPacketType.Ack, + ConnectionId = ConnectionId, + Sequence = 0, + Ack = ack, + AckMask = ackMask, + ChannelId = "control", + Payload = Array.Empty() + }; } /// @@ -1425,6 +1437,17 @@ namespace GameCult.Networking return delivered; } + private IReadOnlyList DrainUnblockedOrdered() + { + var delivered = new List(); + foreach (var channelId in _orderedBuffers.Keys.ToArray()) + { + SkipReceivedNonChannelSequences(channelId); + delivered.AddRange(DrainOrdered(channelId)); + } + return delivered; + } + private void SkipReceivedNonChannelSequences(string channelId) { if (!_orderedNextSequenceByChannel.TryGetValue(channelId, out var next)) @@ -1891,7 +1914,9 @@ namespace GameCult.Networking } var delivered = _deliveredFrames.Count > 0 ? _deliveredFrames.Dequeue() : null; - if (packet.PacketType == CultNetRudpPacketType.Accept || delivered != null) + if (packet.PacketType == CultNetRudpPacketType.Accept || + packet.PacketType == CultNetRudpPacketType.Data || + delivered != null) { SendPacket(_session.CreateAck()); } diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index dca59b42..e7f20bd9 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2820,6 +2820,35 @@ public sealed class CultMeshStreamingTests requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value); } + [Test] + public async Task SnapshotSession_ReusesOneConnectedClientAcrossOrderedRequests() + { + var sourceCache = new CultCache(); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); + registry.Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); + var firstKey = new CultRecordKey("mesh-note:session-first"); + var secondKey = new CultRecordKey("mesh-note:session-second"); + await sourceCache.UpsertAsync( + new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "first", Revision = 1 }, + new CultRecordHandle(firstKey)); + await sourceCache.UpsertAsync( + new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "second", Revision = 2 }, + new CultRecordHandle(secondKey)); + var client = new MeshSnapshotSchemaClient(request => + registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request)); + + using var session = CultMesh.SnapshotSession( + "cultnet://session.test:3075", + new CultMeshSnapshotRequestOptions { CreateClient = () => client }, + registry); + var first = await session.FetchDocumentsAsync(new[] { firstKey.Value }); + var second = await session.FetchDocumentsAsync(new[] { secondKey.Value }); + + client.ConnectCount.Should().Be(1); + first.Should().ContainSingle().Which.Text.Should().Be("first"); + second.Should().ContainSingle().Which.Text.Should().Be("second"); + } + [Test] public async Task DocumentRegistryHelpers_CreateTypedNetworkRegistriesForAliases() { @@ -3743,9 +3772,11 @@ internal sealed class MeshSnapshotSchemaClient : ICultNetSchemaClient } public bool Connected { get; private set; } + public int ConnectCount { get; private set; } public void Connect(string host, int port) { + ConnectCount++; Connected = true; } diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 95282196..526c3fa6 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -682,9 +682,12 @@ namespace GameCult.Networking.Tests Assert.That(client.PendingReliableSequences, Is.Empty); var ack = client.CreateAck(); + Assert.That(ack.Sequence, Is.Zero); Assert.That(ack.Ack, Is.EqualTo(100)); server.Receive(ack, 30); Assert.That(server.PendingReliableSequences, Is.Empty); + var firstData = client.Send("schema", Encoding.UTF8.GetBytes("after-ack")); + Assert.That(firstData.Sequence, Is.EqualTo(2)); } [Test] @@ -842,6 +845,55 @@ namespace GameCult.Networking.Tests Is.EqualTo(new[] { "second", "third" })); } + [Test] + public void RudpSession_SequencedControlPacketReleasesBufferedOrderedFrame() + { + var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions + { + ConnectionId = 199, + InitialSequence = 1 + }); + receiver.Receive(new CultNetRudpPacket + { + PacketType = CultNetRudpPacketType.Accept, + ConnectionId = 199, + Sequence = 100, + ChannelId = "control" + }); + var first = receiver.Receive(new CultNetRudpPacket + { + PacketType = CultNetRudpPacketType.Data, + ConnectionId = 199, + Sequence = 101, + ChannelId = "schema", + Reliable = true, + Ordered = true, + Payload = Encoding.UTF8.GetBytes("first") + }); + var buffered = receiver.Receive(new CultNetRudpPacket + { + PacketType = CultNetRudpPacketType.Data, + ConnectionId = 199, + Sequence = 103, + ChannelId = "schema", + Reliable = true, + Ordered = true, + Payload = Encoding.UTF8.GetBytes("second") + }); + var released = receiver.Receive(new CultNetRudpPacket + { + PacketType = CultNetRudpPacketType.Pong, + ConnectionId = 199, + Sequence = 102, + ChannelId = "control" + }); + + Assert.That(first.Delivered, Has.Count.EqualTo(1)); + Assert.That(buffered.Delivered, Is.Empty); + Assert.That(released.Delivered, Has.Count.EqualTo(1)); + Assert.That(Encoding.UTF8.GetString(released.Delivered[0].Payload), Is.EqualTo("second")); + } + [Test] public void RudpSession_SkipsControlPacketsWhileOrderingSchemaPayloads() { From 340df225d4fac5a0505e5b3f8d496c9d64b7994b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 06:56:49 +0100 Subject: [PATCH 113/498] Align TypeScript RUDP acknowledgement sequencing --- packages/cultnet-ts/src/rudp.ts | 67 +++++++++++++++++++++--- packages/cultnet-ts/test/cultnet.test.ts | 19 +++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index 54b85e83..06c09bc3 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -177,6 +177,17 @@ export class CultNetRudpSession { return this.#lastReceivedAtMs; } + resetPeerState(): void { + this.#connected = false; + this.#lastReceivedAtMs = undefined; + this.#highestReceivedSequence = undefined; + this.#receivedSequences.clear(); + this.#pendingReliable.clear(); + this.#orderedNextSequenceByChannel.clear(); + this.#orderedBuffers.clear(); + this.#fragmentBuffers.clear(); + } + createConnect(nowMs = 0, payload = new Uint8Array()): CultNetRudpPacket { this.#ensureReliableCapacity(1); const packet = this.#createPacket({ @@ -196,8 +207,12 @@ export class CultNetRudpSession { throw new Error(`Expected RUDP connect packet, got ${packet.packetType}.`); } - this.#ensureReliableCapacity(1); + const wasConnected = this.#connected; + if (!wasConnected) { + this.#ensureReliableCapacity(1); + } this.#rememberReceived(packet.sequence); + this.#lastReceivedAtMs = nowMs; this.#connected = true; const response = this.#createPacket({ packetType: "accept", @@ -206,7 +221,9 @@ export class CultNetRudpSession { ordered: true, payload, }); - this.#trackReliable(response, nowMs); + if (!wasConnected) { + this.#trackReliable(response, nowMs); + } return response; } @@ -310,9 +327,9 @@ export class CultNetRudpSession { } if (packet.packetType === "ack" || packet.packetType === "pong") { - this.#rememberReceived(packet.sequence); + if (packet.packetType === "pong") this.#rememberReceived(packet.sequence); return { - delivered: [], + delivered: this.#drainUnblockedOrdered(), pong: packet.packetType === "pong", pongPayload: packet.packetType === "pong" ? packet.payload ?? new Uint8Array() : undefined, }; @@ -351,10 +368,22 @@ export class CultNetRudpSession { } createAck(): CultNetRudpPacket { - return this.#createPacket({ + const { ack, ackMask } = this.#ackState(); + return { packetType: "ack", + connectionId: this.connectionId, + sequence: 0, + ack, + ackMask, channelId: "control", - }); + reliable: false, + ordered: false, + sequenced: false, + fragmentId: 0, + fragmentIndex: 0, + fragmentCount: 0, + payload: new Uint8Array(), + }; } createPing(payload = new Uint8Array()): CultNetRudpPacket { @@ -606,6 +635,15 @@ export class CultNetRudpSession { return delivered; } + #drainUnblockedOrdered(): CultNetRudpDeliveredFrame[] { + const delivered: CultNetRudpDeliveredFrame[] = []; + for (const channelId of this.#orderedBuffers.keys()) { + this.#skipReceivedNonChannelSequences(channelId); + delivered.push(...this.#drainOrdered(channelId)); + } + return delivered; + } + #skipReceivedNonChannelSequences(channelId: string): void { let next = this.#orderedNextSequenceByChannel.get(channelId); while (next !== undefined && this.#receivedSequences.has(next) && !this.#orderedBuffers.get(channelId)?.has(next)) { @@ -727,6 +765,13 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement close(): void { clearInterval(this.#resendTimer); if (!this.#closed) { + if (this.#session.connected && this.#remoteHost && this.#remotePort !== undefined) { + try { + this.#sendPacket(this.#session.createDisconnect()); + } catch { + // Best-effort shutdown: close must still release the UDP socket. + } + } this.#closed = true; this.#socket.close(); } @@ -746,11 +791,17 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement this.#remoteHost = remote.address; this.#remotePort = remote.port; } else if (remote.address !== this.#remoteHost || remote.port !== this.#remotePort) { - return; + if (this.#mode === "server" && packet.packetType === "connect") { + this.#remoteHost = remote.address; + this.#remotePort = remote.port; + } else { + return; + } } try { if (this.#mode === "server" && packet.packetType === "connect") { + this.#session.resetPeerState(); this.#sendPacket(this.#session.acceptConnect(packet, Date.now())); return; } @@ -774,7 +825,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement this.emit("close"); return; } - if (packet.packetType === "accept" || result.delivered.length > 0) { + if (packet.packetType === "accept" || packet.packetType === "data" || result.delivered.length > 0) { this.#sendPacket(this.#session.createAck()); } } catch (error) { diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 1d78072c..04107a0e 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -418,6 +418,7 @@ test("rudp session handshake acks reliable connect and accept packets", () => { assert.deepEqual(client.pendingReliableSequences, []); const ack = client.createAck(); + assert.equal(ack.sequence, 0); assert.equal(ack.ack, 100); server.receive(ack, 30); assert.deepEqual(server.pendingReliableSequences, []); @@ -533,6 +534,9 @@ test("rudp session skips received control packets while ordering schema payloads const control = sender.createAck(); const second = sender.send("schema", Buffer.from("second"), { reliable: true, ordered: true }); + assert.equal(control.sequence, 0); + assert.equal(second.sequence, first.sequence + 1); + assert.deepEqual(receiver.receive(first).delivered.map((frame) => Buffer.from(frame.payload).toString("utf8")), ["first"]); assert.deepEqual(receiver.receive(control).delivered, []); assert.deepEqual(receiver.receive(second).delivered.map((frame) => Buffer.from(frame.payload).toString("utf8")), ["second"]); @@ -1217,6 +1221,21 @@ test("CultNet raw replication preserves CultCache payload bytes for bit-compatib } }); +test("rudp sequence-neutral acknowledgements interoperate with ordered receivers", () => { + const sender = new CultNetRudpSession({ connectionId: 125, initialSequence: 1 }); + const receiver = new CultNetRudpSession({ connectionId: 125, initialSequence: 100 }); + sender.receive({ packetType: "accept", connectionId: 125, sequence: 90, ack: 0, ackMask: 0, channelId: "control" }); + receiver.receive({ packetType: "accept", connectionId: 125, sequence: 91, ack: 0, ackMask: 0, channelId: "control" }); + + const ack = sender.createAck(); + const request = sender.send("schema", Buffer.from("snapshot"), { reliable: true, ordered: true }); + + assert.equal(ack.sequence, 0); + assert.equal(request.sequence, 1); + assert.deepEqual(receiver.receive(ack).delivered, []); + assert.deepEqual(receiver.receive(request).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["snapshot"]); +}); + test("CultNet raw replication applies compatible foreign-schema snapshots", async () => { const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-foreign-schema-raw-")); From bf9629000ae889f9d741fb938d23ce04e9cf57e7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 10:41:39 +0100 Subject: [PATCH 114/498] Package CultMesh NuGet dependency closure --- docs/nuget-packaging.md | 17 ++++++++ scripts/pack-nuget.ps1 | 93 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 docs/nuget-packaging.md create mode 100644 scripts/pack-nuget.ps1 diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md new file mode 100644 index 00000000..88eb4a2d --- /dev/null +++ b/docs/nuget-packaging.md @@ -0,0 +1,17 @@ +# NuGet Packaging + +CultLib publishes the managed dependency graph as separate packages. The leaf +package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh, +CultNet, and CultCache ownership boundaries. + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\pack-nuget.ps1 +``` + +The command creates a local feed under `artifacts/nuget`, verifies that every +internal dependency package exists at the same version, then restores and runs +a clean .NET consumer using only `PackageReference Include="GameCult.Mesh"`. + +The Unity distribution remains `org.gamecult.cultlib`, built by +`scripts/build-unity-package.ps1`, because Unity consumes the managed assembly +closure through UPM rather than NuGet restore. diff --git a/scripts/pack-nuget.ps1 b/scripts/pack-nuget.ps1 new file mode 100644 index 00000000..b027c492 --- /dev/null +++ b/scripts/pack-nuget.ps1 @@ -0,0 +1,93 @@ +param( + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\nuget", + [switch] $SkipConsumerSmoke +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} + +$projects = @( + "src\GameCult.Logging\GameCult.Logging.csproj", + "src\GameCult.Caching\GameCult.Caching.csproj", + "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", + "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", + "src\GameCult.Networking\GameCult.Networking.csproj", + "src\GameCult.Mesh\GameCult.Mesh.csproj" +) + +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null + +foreach ($project in $projects) { + dotnet pack (Join-Path $repoRoot $project) -c $Configuration -o $outputRoot + if ($LASTEXITCODE -ne 0) { + throw "NuGet pack failed for $project with exit code $LASTEXITCODE" + } +} + +$meshPackage = Get-ChildItem -LiteralPath $outputRoot -Filter "GameCult.Mesh.*.nupkg" | + Where-Object { $_.Name -notlike "*.snupkg" } | + Select-Object -First 1 +if (-not $meshPackage) { + throw "GameCult.Mesh package was not produced." +} + +$version = [regex]::Match($meshPackage.Name, '^GameCult\.Mesh\.(.+)\.nupkg$').Groups[1].Value +$expectedPackages = @( + "GameCult.Logging.$version.nupkg", + "GameCult.Caching.$version.nupkg", + "GameCult.Caching.MessagePack.Analyzers.$version.nupkg", + "GameCult.Caching.MessagePack.$version.nupkg", + "GameCult.Networking.$version.nupkg", + "GameCult.Mesh.$version.nupkg" +) +foreach ($package in $expectedPackages) { + if (-not (Test-Path -LiteralPath (Join-Path $outputRoot $package))) { + throw "NuGet dependency closure is missing $package" + } +} + +if (-not $SkipConsumerSmoke) { + $smokeRoot = Join-Path $repoRoot "artifacts\nuget-consumer-smoke" + if (Test-Path -LiteralPath $smokeRoot) { + Remove-Item -LiteralPath $smokeRoot -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null + $projectDocument = @" + + + net10.0 + Exe + enable + $outputRoot;https://api.nuget.org/v3/index.json + + + + + +"@ + $program = @" +using GameCult.Mesh; + +var verse = CultMesh.Verse("nuget-smoke", "cultlib.pack"); +Console.WriteLine(verse.Context.VerseId); +"@ + [IO.File]::WriteAllText((Join-Path $smokeRoot "Consumer.csproj"), $projectDocument, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $smokeRoot "Program.cs"), $program, [Text.UTF8Encoding]::new($false)) + dotnet run --project (Join-Path $smokeRoot "Consumer.csproj") -c Release + if ($LASTEXITCODE -ne 0) { + throw "GameCult.Mesh NuGet consumer smoke failed with exit code $LASTEXITCODE" + } +} + +Write-Host "CultLib NuGet feed: $outputRoot" +Write-Host "GameCult.Mesh: $version" +Write-Host "Public package closure: $($expectedPackages.Count) packages" From 2272aeff0834e92f09d9c86fccb503c29a3f070d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 11:11:28 +0100 Subject: [PATCH 115/498] Add CultNet operation service envelopes --- .../cultnet.operation-request.schema.json | 19 +++++++++ .../cultnet.operation-response.schema.json | 20 ++++++++++ packages/cultnet-ts/src/contracts.ts | 39 ++++++++++++++++++- packages/cultnet-ts/test/cultnet.test.ts | 30 ++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 packages/cultnet-ts/contracts/cultnet.operation-request.schema.json create mode 100644 packages/cultnet-ts/contracts/cultnet.operation-response.schema.json diff --git a/packages/cultnet-ts/contracts/cultnet.operation-request.schema.json b/packages/cultnet-ts/contracts/cultnet.operation-request.schema.json new file mode 100644 index 00000000..07cd9d8d --- /dev/null +++ b/packages/cultnet-ts/contracts/cultnet.operation-request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.operation-request.schema.json", + "title": "CultNet Operation Request", + "type": "object", + "required": ["schemaVersion", "messageId", "serviceId", "operation", "payloadSchema", "payload"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.operation_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "serviceId": { "type": "string", "minLength": 1 }, + "operation": { "type": "string", "minLength": 1 }, + "payloadSchema": { "type": "string", "minLength": 1 }, + "payloadEncoding": { "const": "messagepack-base64" }, + "payload": { "type": "string", "minLength": 1, "contentEncoding": "base64" }, + "sourceRuntimeId": { "type": "string", "minLength": 1 }, + "targetRuntimeId": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/contracts/cultnet.operation-response.schema.json b/packages/cultnet-ts/contracts/cultnet.operation-response.schema.json new file mode 100644 index 00000000..c3be917d --- /dev/null +++ b/packages/cultnet-ts/contracts/cultnet.operation-response.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.operation-response.schema.json", + "title": "CultNet Operation Response", + "type": "object", + "required": ["schemaVersion", "messageId", "serviceId", "operation", "status", "payloadSchema", "payload"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.operation_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "serviceId": { "type": "string", "minLength": 1 }, + "operation": { "type": "string", "minLength": 1 }, + "status": { "type": "string", "minLength": 1 }, + "payloadSchema": { "type": "string", "minLength": 1 }, + "payloadEncoding": { "const": "messagepack-base64" }, + "payload": { "type": "string", "minLength": 1, "contentEncoding": "base64" }, + "diagnostics": { "type": "array", "items": { "type": "string" } }, + "sourceRuntimeId": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 2e89c025..8795b06a 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -23,6 +23,8 @@ import schemaCatalogRequestSchema from "../contracts/cultnet.schema-catalog-requ import schemaCatalogResponseSchema from "../contracts/cultnet.schema-catalog-response.schema.json"; import shardCatalogRequestSchema from "../contracts/cultnet.shard-catalog-request.schema.json"; import shardCatalogResponseSchema from "../contracts/cultnet.shard-catalog-response.schema.json"; +import operationRequestSchema from "../contracts/cultnet.operation-request.schema.json"; +import operationResponseSchema from "../contracts/cultnet.operation-response.schema.json"; import ghostlightAgentStateSchema from "../contracts/ghostlight.agent-state.schema.json"; export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0"; @@ -45,7 +47,9 @@ export type CultNetSchemaVersion = | "cultnet.schema_catalog_request.v0" | "cultnet.schema_catalog_response.v0" | "cultnet.shard_catalog_request.v0" - | "cultnet.shard_catalog_response.v0"; + | "cultnet.shard_catalog_response.v0" + | "cultnet.operation_request.v0" + | "cultnet.operation_response.v0"; export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract"; export type CultNetRawPayloadEncoding = "messagepack"; @@ -265,6 +269,31 @@ export interface CultNetShardCatalogResponseMessage { shards: CultNetShardDescriptor[]; } +export interface CultNetOperationRequestMessage { + schemaVersion: "cultnet.operation_request.v0"; + messageId: string; + serviceId: string; + operation: string; + payloadSchema: string; + payloadEncoding?: "messagepack-base64"; + payload: string; + sourceRuntimeId?: string; + targetRuntimeId?: string; +} + +export interface CultNetOperationResponseMessage { + schemaVersion: "cultnet.operation_response.v0"; + messageId: string; + serviceId: string; + operation: string; + status: string; + payloadSchema: string; + payloadEncoding?: "messagepack-base64"; + payload: string; + diagnostics?: string[]; + sourceRuntimeId?: string; +} + export interface GhostlightPressure { pressure_id: string; label: string; @@ -509,7 +538,9 @@ export type CultNetMessage = | CultNetSchemaCatalogRequestMessage | CultNetSchemaCatalogResponseMessage | CultNetShardCatalogRequestMessage - | CultNetShardCatalogResponseMessage; + | CultNetShardCatalogResponseMessage + | CultNetOperationRequestMessage + | CultNetOperationResponseMessage; const ajv = new Ajv2020({ allErrors: true, @@ -535,6 +566,8 @@ const CULTNET_MESSAGE_SCHEMAS = [ schemaCatalogResponseSchema, shardCatalogRequestSchema, shardCatalogResponseSchema, + operationRequestSchema, + operationResponseSchema, ] as const; const cultNetValidators = new Map(); @@ -1064,5 +1097,7 @@ export const cultNetSchemas = { schemaCatalogResponseSchema, shardCatalogRequestSchema, shardCatalogResponseSchema, + operationRequestSchema, + operationResponseSchema, ghostlightAgentStateSchema, } as const; diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 04107a0e..7f370c5c 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1221,6 +1221,36 @@ test("CultNet raw replication preserves CultCache payload bytes for bit-compatib } }); +test("operation envelopes preserve typed service routing and payload correlation", () => { + const request = parseCultNetMessage({ + schemaVersion: "cultnet.operation_request.v0", + messageId: "plugin-42", + serviceId: "sai.vn", + operation: "project", + payloadSchema: "gamecult.eve.plugin_abi.request.v1", + payloadEncoding: "messagepack-base64", + payload: "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=", + sourceRuntimeId: "eve-unity", + }); + assert.equal(request.schemaVersion, "cultnet.operation_request.v0"); + assert.equal(request.messageId, "plugin-42"); + assert.equal(request.serviceId, "sai.vn"); + + const response = parseCultNetMessage({ + schemaVersion: "cultnet.operation_response.v0", + messageId: "plugin-42", + serviceId: "sai.vn", + operation: "project", + status: "ok", + payloadSchema: "gamecult.eve.plugin_abi.response.v1", + payloadEncoding: "messagepack-base64", + payload: "gaZzdGF0dXOkb2s=", + diagnostics: [], + }); + assert.equal(response.schemaVersion, "cultnet.operation_response.v0"); + assert.equal(response.messageId, request.messageId); +}); + test("rudp sequence-neutral acknowledgements interoperate with ordered receivers", () => { const sender = new CultNetRudpSession({ connectionId: 125, initialSequence: 1 }); const receiver = new CultNetRudpSession({ connectionId: 125, initialSequence: 100 }); From 631d9bd3ab0bb5c85da6e35a5d7ba04f2cbe30db Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 11:14:00 +0100 Subject: [PATCH 116/498] Mirror operation envelopes in CultNet Rust --- packages/cultnet-rs/src/contracts.rs | 79 ++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index cf828e15..e637883c 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -354,6 +354,39 @@ pub enum CultNetMessage { message_id: String, shards: Vec, }, + #[serde(rename = "cultnet.operation_request.v0", rename_all = "camelCase")] + OperationRequest { + message_id: String, + service_id: String, + operation: String, + payload_schema: String, + #[serde(default = "messagepack_base64_encoding")] + payload_encoding: String, + payload: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + target_runtime_id: Option, + }, + #[serde(rename = "cultnet.operation_response.v0", rename_all = "camelCase")] + OperationResponse { + message_id: String, + service_id: String, + operation: String, + status: String, + payload_schema: String, + #[serde(default = "messagepack_base64_encoding")] + payload_encoding: String, + payload: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + diagnostics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_runtime_id: Option, + }, +} + +fn messagepack_base64_encoding() -> String { + "messagepack-base64".to_string() } pub fn parse_cultnet_message( @@ -560,6 +593,45 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { validate_shard_descriptor(shard)?; } } + CultNetMessage::OperationRequest { + message_id, + service_id, + operation, + payload_schema, + payload_encoding, + payload, + source_runtime_id, + target_runtime_id, + } => { + require_non_empty(message_id, "messageId")?; + require_non_empty(service_id, "serviceId")?; + require_non_empty(operation, "operation")?; + require_non_empty(payload_schema, "payloadSchema")?; + require_exact(payload_encoding, "messagepack-base64", "payloadEncoding")?; + require_non_empty(payload, "payload")?; + require_optional_non_empty(source_runtime_id.as_deref(), "sourceRuntimeId")?; + require_optional_non_empty(target_runtime_id.as_deref(), "targetRuntimeId")?; + } + CultNetMessage::OperationResponse { + message_id, + service_id, + operation, + status, + payload_schema, + payload_encoding, + payload, + diagnostics: _, + source_runtime_id, + } => { + require_non_empty(message_id, "messageId")?; + require_non_empty(service_id, "serviceId")?; + require_non_empty(operation, "operation")?; + require_non_empty(status, "status")?; + require_non_empty(payload_schema, "payloadSchema")?; + require_exact(payload_encoding, "messagepack-base64", "payloadEncoding")?; + require_non_empty(payload, "payload")?; + require_optional_non_empty(source_runtime_id.as_deref(), "sourceRuntimeId")?; + } } Ok(()) } @@ -655,6 +727,13 @@ fn require_non_empty(value: &str, field: &str) -> Result<()> { Ok(()) } +fn require_exact(value: &str, expected: &str, field: &str) -> Result<()> { + if value != expected { + return Err(anyhow!("{field} must be {expected}")); + } + Ok(()) +} + fn require_optional_non_empty(value: Option<&str>, field: &str) -> Result<()> { if let Some(value) = value { require_non_empty(value, field)?; From 94f95e380d188970cdc889390695299df3e37d04 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 11:17:33 +0100 Subject: [PATCH 117/498] Expose operation envelopes in CultNet CSharp --- .../cultnet.operation-request.schema.json | 19 ++++++ .../cultnet.operation-response.schema.json | 20 ++++++ .../CultNetSchemaMessageSerialization.cs | 2 + .../CultNetSchemaMessages.cs | 62 +++++++++++++++++++ .../CultNetSchemaRegistry.cs | 14 +++++ .../NetworkingTests.cs | 21 +++++++ 6 files changed, 138 insertions(+) create mode 100644 src/GameCult.Networking/Contracts/cultnet.operation-request.schema.json create mode 100644 src/GameCult.Networking/Contracts/cultnet.operation-response.schema.json diff --git a/src/GameCult.Networking/Contracts/cultnet.operation-request.schema.json b/src/GameCult.Networking/Contracts/cultnet.operation-request.schema.json new file mode 100644 index 00000000..07cd9d8d --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.operation-request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.operation-request.schema.json", + "title": "CultNet Operation Request", + "type": "object", + "required": ["schemaVersion", "messageId", "serviceId", "operation", "payloadSchema", "payload"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.operation_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "serviceId": { "type": "string", "minLength": 1 }, + "operation": { "type": "string", "minLength": 1 }, + "payloadSchema": { "type": "string", "minLength": 1 }, + "payloadEncoding": { "const": "messagepack-base64" }, + "payload": { "type": "string", "minLength": 1, "contentEncoding": "base64" }, + "sourceRuntimeId": { "type": "string", "minLength": 1 }, + "targetRuntimeId": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/Contracts/cultnet.operation-response.schema.json b/src/GameCult.Networking/Contracts/cultnet.operation-response.schema.json new file mode 100644 index 00000000..c3be917d --- /dev/null +++ b/src/GameCult.Networking/Contracts/cultnet.operation-response.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.operation-response.schema.json", + "title": "CultNet Operation Response", + "type": "object", + "required": ["schemaVersion", "messageId", "serviceId", "operation", "status", "payloadSchema", "payload"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.operation_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "serviceId": { "type": "string", "minLength": 1 }, + "operation": { "type": "string", "minLength": 1 }, + "status": { "type": "string", "minLength": 1 }, + "payloadSchema": { "type": "string", "minLength": 1 }, + "payloadEncoding": { "const": "messagepack-base64" }, + "payload": { "type": "string", "minLength": 1, "contentEncoding": "base64" }, + "diagnostics": { "type": "array", "items": { "type": "string" } }, + "sourceRuntimeId": { "type": "string", "minLength": 1 } + } +} diff --git a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs index 5e9431b4..a8487665 100644 --- a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs +++ b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs @@ -55,6 +55,8 @@ namespace GameCult.Networking CultNetSchemaVersions.Error => Deserialize(payload), CultNetSchemaVersions.SampleChangeName => Deserialize(payload), CultNetSchemaVersions.SampleChat => Deserialize(payload), + CultNetSchemaVersions.OperationRequest => Deserialize(payload), + CultNetSchemaVersions.OperationResponse => Deserialize(payload), CultNetSchemaVersions.DocumentDelete => Deserialize(payload), CultNetSchemaVersions.DocumentPutRaw => Deserialize(payload), CultNetSchemaVersions.SnapshotRequest => Deserialize(payload), diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index ccac3dbf..a37c2bc0 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -56,6 +56,14 @@ namespace GameCult.Networking /// public const string SampleChat = "cultnet.sample.chat.v0"; /// + /// Generic typed service operation request contract identifier. + /// + public const string OperationRequest = "cultnet.operation_request.v0"; + /// + /// Generic typed service operation response contract identifier. + /// + public const string OperationResponse = "cultnet.operation_response.v0"; + /// /// document delete contract identifier. /// public const string DocumentDelete = "cultnet.document_delete.v0"; @@ -519,6 +527,60 @@ namespace GameCult.Networking [Key("text")] public string Text { get; set; } = string.Empty; } + /// + /// Invokes one typed operation on an advertised service. + /// + [MessagePackObject] + public class CultNetOperationRequestMessage : ICultNetSchemaMessage + { + /// Gets or sets the wire schema version. + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.OperationRequest; + /// Gets or sets the correlation id. + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + /// Gets or sets the advertised service id. + [Key("serviceId")] public string ServiceId { get; set; } = string.Empty; + /// Gets or sets the requested operation. + [Key("operation")] public string Operation { get; set; } = string.Empty; + /// Gets or sets the inner payload schema. + [Key("payloadSchema")] public string PayloadSchema { get; set; } = string.Empty; + /// Gets or sets the inner payload encoding. + [Key("payloadEncoding")] public string PayloadEncoding { get; set; } = "messagepack-base64"; + /// Gets or sets the encoded inner payload. + [Key("payload")] public string Payload { get; set; } = string.Empty; + /// Gets or sets the calling runtime id. + [Key("sourceRuntimeId")] public string? SourceRuntimeId { get; set; } + /// Gets or sets an optional target runtime id. + [Key("targetRuntimeId")] public string? TargetRuntimeId { get; set; } + } + + /// + /// Returns the correlated result of one typed service operation. + /// + [MessagePackObject] + public class CultNetOperationResponseMessage : ICultNetSchemaMessage + { + /// Gets or sets the wire schema version. + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.OperationResponse; + /// Gets or sets the request correlation id. + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + /// Gets or sets the advertised service id. + [Key("serviceId")] public string ServiceId { get; set; } = string.Empty; + /// Gets or sets the completed operation. + [Key("operation")] public string Operation { get; set; } = string.Empty; + /// Gets or sets the operation status. + [Key("status")] public string Status { get; set; } = string.Empty; + /// Gets or sets the inner payload schema. + [Key("payloadSchema")] public string PayloadSchema { get; set; } = string.Empty; + /// Gets or sets the inner payload encoding. + [Key("payloadEncoding")] public string PayloadEncoding { get; set; } = "messagepack-base64"; + /// Gets or sets the encoded inner payload. + [Key("payload")] public string Payload { get; set; } = string.Empty; + /// Gets or sets transport-level diagnostics. + [Key("diagnostics")] public string[] Diagnostics { get; set; } = Array.Empty(); + /// Gets or sets the responding runtime id. + [Key("sourceRuntimeId")] public string? SourceRuntimeId { get; set; } + } + /// /// Raw document record carried over CultNet. /// diff --git a/src/GameCult.Networking/CultNetSchemaRegistry.cs b/src/GameCult.Networking/CultNetSchemaRegistry.cs index baa20b5b..037c0d84 100644 --- a/src/GameCult.Networking/CultNetSchemaRegistry.cs +++ b/src/GameCult.Networking/CultNetSchemaRegistry.cs @@ -322,6 +322,20 @@ namespace GameCult.Networking WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 } }, new SchemaResourceSpec + { + FileName = "cultnet.operation-request.schema.json", + Kind = "wire_message", + SchemaVersion = CultNetSchemaVersions.OperationRequest, + WireContracts = new[] { CultNetWireContracts.SchemaV0 } + }, + new SchemaResourceSpec + { + FileName = "cultnet.operation-response.schema.json", + Kind = "wire_message", + SchemaVersion = CultNetSchemaVersions.OperationResponse, + WireContracts = new[] { CultNetWireContracts.SchemaV0 } + }, + new SchemaResourceSpec { FileName = "cultnet.document-delete.schema.json", Kind = "wire_message", diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 526c3fa6..3b54be31 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -431,6 +431,27 @@ namespace GameCult.Networking.Tests Is.EqualTo(new int?[] { 64, 64, 64 })); } + [Test] + public void CultNetSchemaMessageSerialization_RoundTrips_OperationEnvelope() + { + var request = new CultNetOperationRequestMessage + { + MessageId = "plugin-42", + ServiceId = "sai.vn", + Operation = "project", + PayloadSchema = "gamecult.eve.plugin_abi.request.v1", + Payload = "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=", + SourceRuntimeId = "eve-unity" + }; + + var payload = CultNetSchemaMessageSerialization.Serialize(request); + var roundTrip = (CultNetOperationRequestMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(roundTrip.MessageId, Is.EqualTo(request.MessageId)); + Assert.That(roundTrip.ServiceId, Is.EqualTo("sai.vn")); + Assert.That(roundTrip.PayloadSchema, Is.EqualTo("gamecult.eve.plugin_abi.request.v1")); + } + [Test] public void LiteNetLibTransportProfile_AdvertisesLegacyAndSchemaChannels() { From ddb5418e04f893335c016f0a4af9c39f11e62b64 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 11:20:51 +0100 Subject: [PATCH 118/498] Add CultNet RUDP operation service host --- packages/cultnet-ts/src/index.ts | 9 + packages/cultnet-ts/src/operation-service.ts | 199 +++++++++++++++++++ packages/cultnet-ts/test/cultnet.test.ts | 36 ++++ 3 files changed, 244 insertions(+) create mode 100644 packages/cultnet-ts/src/operation-service.ts diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 5d0ecf99..cd602952 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -14,6 +14,8 @@ export { type CultNetHelloMessage, type CultNetLoginMessage, type CultNetLoginSuccessMessage, + type CultNetOperationRequestMessage, + type CultNetOperationResponseMessage, type CultNetMessage, type CultNetRegisterMessage, type CultNetSampleChangeNameMessage, @@ -91,6 +93,13 @@ export { type CultNetRudpSessionOptions, type RudpTransportProfileOptions, } from "./rudp"; +export { + invokeCultNetOperation, + startCultNetOperationServer, + type CultNetOperationClientOptions, + type CultNetOperationServer, + type CultNetOperationServerOptions, +} from "./operation-service"; export { defineJsonSchemaContract, type JsonSchemaContract, diff --git a/packages/cultnet-ts/src/operation-service.ts b/packages/cultnet-ts/src/operation-service.ts new file mode 100644 index 00000000..362e4a06 --- /dev/null +++ b/packages/cultnet-ts/src/operation-service.ts @@ -0,0 +1,199 @@ +import { createSocket, type RemoteInfo, type Socket } from "node:dgram"; +import { decode, encode } from "@msgpack/msgpack"; + +import { + encodeCultNetMessageForWire, + parseCultNetMessage, + type CultNetOperationRequestMessage, + type CultNetOperationResponseMessage, +} from "./contracts"; +import { + CultNetRudpSession, + CultNetRudpSocketTransportConnection, + decodeRudpPacket, + encodeRudpPacket, + type CultNetRudpPacket, +} from "./rudp"; +import { CultNetPeer } from "./peer"; + +const DEFAULT_CONNECTION_ID = 0x43554c54; + +export interface CultNetOperationServerOptions { + runtimeId: string; + host?: string; + port?: number; + connectionId?: number; + maxFragmentBytes?: number; + handler: (request: CultNetOperationRequestMessage) => + CultNetOperationResponseMessage | Promise; +} + +export interface CultNetOperationServer { + readonly endpoint: string; + close(): Promise; +} + +export interface CultNetOperationClientOptions { + runtimeId: string; + connectionId?: number; + timeoutMs?: number; + maxFragmentBytes?: number; +} + +interface RemoteSession { + session: CultNetRudpSession; + remote: RemoteInfo; +} + +export async function startCultNetOperationServer( + options: CultNetOperationServerOptions, +): Promise { + if (!options.runtimeId) throw new Error("CultNet operation server requires runtimeId."); + if (!options.handler) throw new Error("CultNet operation server requires a handler."); + const socket = createSocket("udp4"); + const connectionId = options.connectionId ?? DEFAULT_CONNECTION_ID; + const sessions = new Map(); + const sendPacket = (remote: RemoteInfo, packet: CultNetRudpPacket): void => { + const wire = encodeRudpPacket(packet); + socket.send(wire, remote.port, remote.address); + }; + socket.on("message", (wire, remote) => { + void handleServerDatagram(socket, sessions, connectionId, options, wire, remote, sendPacket); + }); + await bindSocket(socket, options.port ?? 0, options.host ?? "127.0.0.1"); + const resendTimer = setInterval(() => { + for (const peer of sessions.values()) { + for (const packet of peer.session.dueResends(Date.now())) sendPacket(peer.remote, packet); + } + }, 25); + resendTimer.unref?.(); + const address = socket.address(); + const endpoint = `rudp://${address.address}:${address.port}`; + return { + endpoint, + close: async () => { + clearInterval(resendTimer); + await closeSocket(socket); + }, + }; +} + +export async function invokeCultNetOperation( + endpoint: string, + request: CultNetOperationRequestMessage, + options: CultNetOperationClientOptions, +): Promise { + const target = parseRudpEndpoint(endpoint); + const socket = createSocket("udp4"); + await bindSocket(socket, 0, "127.0.0.1"); + const transport = new CultNetRudpSocketTransportConnection({ + mode: "client", + runtimeId: options.runtimeId, + transportId: `${options.runtimeId}.operations`, + socket, + remoteHost: target.host, + remotePort: target.port, + connectionId: options.connectionId ?? DEFAULT_CONNECTION_ID, + maxFragmentBytes: options.maxFragmentBytes ?? 2048, + }); + const peer = new CultNetPeer(transport, { wireContract: "cultnet.schema.v0" }); + const timeoutMs = options.timeoutMs ?? 10_000; + try { + transport.connect(); + await waitUntil(() => transport.connected, timeoutMs, "CultNet operation connection timed out."); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("CultNet operation response timed out.")), timeoutMs); + const onMessage = (message: unknown): void => { + const candidate = message as CultNetOperationResponseMessage; + if (candidate.schemaVersion !== "cultnet.operation_response.v0" || candidate.messageId !== request.messageId) return; + clearTimeout(timer); + peer.off("message", onMessage); + resolve(candidate); + }; + peer.on("message", onMessage); + peer.send(request); + }); + } finally { + transport.close(); + } +} + +async function handleServerDatagram( + socket: Socket, + sessions: Map, + connectionId: number, + options: CultNetOperationServerOptions, + wire: Buffer, + remote: RemoteInfo, + sendPacket: (remote: RemoteInfo, packet: CultNetRudpPacket) => void, +): Promise { + let packet: CultNetRudpPacket; + try { + packet = decodeRudpPacket(wire); + } catch { + return; + } + if (packet.connectionId !== connectionId) return; + const key = `${remote.address}:${remote.port}`; + let peer = sessions.get(key); + if (packet.packetType === "connect") { + peer = { session: new CultNetRudpSession({ connectionId, resendDelayMs: 25 }), remote }; + sessions.set(key, peer); + sendPacket(remote, peer.session.acceptConnect(packet, Date.now(), encode("cultnet-operation-service"))); + return; + } + if (!peer) return; + const result = peer.session.receive(packet, Date.now()); + if (result.reply) sendPacket(remote, result.reply); + if (result.disconnected) { + sessions.delete(key); + return; + } + for (const frame of result.delivered) { + if (frame.channelId !== "schema") continue; + const message = parseCultNetMessage(decode(frame.payload)); + if (message.schemaVersion !== "cultnet.operation_request.v0") continue; + const response = await options.handler(message); + const payload = encode(encodeCultNetMessageForWire(response, "cultnet.schema.v0")); + for (const responsePacket of peer.session.sendMany("schema", payload, { + reliable: true, + ordered: true, + nowMs: Date.now(), + maxFragmentBytes: options.maxFragmentBytes ?? 2048, + })) sendPacket(remote, responsePacket); + } + if (packet.packetType === "data" || result.delivered.length > 0) sendPacket(remote, peer.session.createAck()); +} + +function parseRudpEndpoint(endpoint: string): { host: string; port: number } { + const url = new URL(endpoint); + if (url.protocol !== "rudp:") throw new Error(`CultNet operation endpoint must use rudp://: ${endpoint}`); + const port = Number(url.port); + if (!url.hostname || !Number.isInteger(port) || port <= 0) throw new Error(`Invalid CultNet operation endpoint: ${endpoint}`); + return { host: url.hostname, port }; +} + +function bindSocket(socket: Socket, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(port, host, () => { + socket.off("error", reject); + resolve(); + }); + }); +} + +function closeSocket(socket: Socket): Promise { + return new Promise(resolve => { + if (!socket.address()) return resolve(); + socket.close(() => resolve()); + }); +} + +async function waitUntil(check: () => boolean, timeoutMs: number, message: string): Promise { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() >= deadline) throw new Error(message); + await new Promise(resolve => setTimeout(resolve, 5)); + } +} diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 7f370c5c..0677d421 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -41,6 +41,8 @@ import { encodeRudpPacket, ghostlightAgentStateGeneratedContract, parseCultNetMessage, + invokeCultNetOperation, + startCultNetOperationServer, validateGhostlightAgentStateGenerated, validateGhostlightAgentState, shardServes, @@ -1251,6 +1253,40 @@ test("operation envelopes preserve typed service routing and payload correlation assert.equal(response.messageId, request.messageId); }); +test("RUDP operation service correlates typed payload envelopes", async () => { + const server = await startCultNetOperationServer({ + runtimeId: "sai-sidecar", + handler: request => ({ + schemaVersion: "cultnet.operation_response.v0", + messageId: request.messageId, + serviceId: request.serviceId, + operation: request.operation, + status: "ok", + payloadSchema: "gamecult.eve.plugin_abi.response.v1", + payloadEncoding: "messagepack-base64", + payload: request.payload, + diagnostics: [], + sourceRuntimeId: "sai-sidecar", + }), + }); + try { + const response = await invokeCultNetOperation(server.endpoint, { + schemaVersion: "cultnet.operation_request.v0", + messageId: "service-1", + serviceId: "sai.vn", + operation: "describe", + payloadSchema: "gamecult.eve.plugin_abi.request.v1", + payloadEncoding: "messagepack-base64", + payload: "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=", + }, { runtimeId: "eve-test" }); + assert.equal(response.messageId, "service-1"); + assert.equal(response.serviceId, "sai.vn"); + assert.equal(response.status, "ok"); + } finally { + await server.close(); + } +}); + test("rudp sequence-neutral acknowledgements interoperate with ordered receivers", () => { const sender = new CultNetRudpSession({ connectionId: 125, initialSequence: 1 }); const receiver = new CultNetRudpSession({ connectionId: 125, initialSequence: 100 }); From aff424e5b6ccaaf5f2d43efe531861cf06fd1e8a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 11:33:11 +0100 Subject: [PATCH 119/498] Reset Rust RUDP sessions for successive peers --- packages/cultnet-rs/src/rudp.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 8566c38c..c4343a56 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -127,6 +127,7 @@ pub struct CultNetRudpSession { connection_id: u32, resend_delay_ms: u64, max_pending_reliable_packets: Option, + initial_sequence: u32, next_sequence: u32, next_fragment_id: u16, connected: bool, @@ -145,6 +146,7 @@ impl CultNetRudpSession { connection_id: options.connection_id, resend_delay_ms: options.resend_delay_ms, max_pending_reliable_packets: options.max_pending_reliable_packets, + initial_sequence: options.initial_sequence, next_sequence: options.initial_sequence, next_fragment_id: 1, connected: false, @@ -178,6 +180,19 @@ impl CultNetRudpSession { self.last_received_at_ms } + pub fn reset_peer_state(&mut self) { + self.next_sequence = self.initial_sequence; + self.next_fragment_id = 1; + self.connected = false; + self.last_received_at_ms = None; + self.highest_received_sequence = None; + self.received_sequences.clear(); + self.pending_reliable.clear(); + self.ordered_next_sequence_by_channel.clear(); + self.ordered_buffers.clear(); + self.fragment_buffers.clear(); + } + pub fn create_connect(&mut self, now_ms: u64, payload: Vec) -> Result { self.ensure_reliable_capacity(1)?; let packet = self.create_packet( @@ -992,18 +1007,29 @@ impl CultNetRudpSocketTransportConnection { wire.truncate(received); self.stats.bytes_received += received as u64; + let packet = decode_rudp_packet(&wire)?; if let Some(expected) = self.remote_addr { if expected != remote_addr { - return Ok(None); + if self.mode == CultNetRudpSocketMode::Server + && packet.packet_type == CultNetRudpPacketType::Connect + { + self.remote_addr = Some(remote_addr); + } else { + return Ok(None); + } } } else { + if self.mode == CultNetRudpSocketMode::Server + && packet.packet_type != CultNetRudpPacketType::Connect + { + return Ok(None); + } self.remote_addr = Some(remote_addr); } - - let packet = decode_rudp_packet(&wire)?; if self.mode == CultNetRudpSocketMode::Server && packet.packet_type == CultNetRudpPacketType::Connect { + self.session.reset_peer_state(); let accept = self.session.accept_connect(&packet, now_ms(), Vec::new())?; self.send_packet(&accept)?; return Ok(None); From 78383e03f6b8e7b3da8c9f4984b33ca9b60ac9c2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 11:35:31 +0100 Subject: [PATCH 120/498] Add Rust CultNet operation service host --- packages/cultnet-rs/src/lib.rs | 2 + packages/cultnet-rs/src/operation_service.rs | 60 ++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 packages/cultnet-rs/src/operation_service.rs diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index 6519ef24..0b9fbb55 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -1,6 +1,7 @@ mod contracts; mod cultmesh; mod framing; +mod operation_service; mod replication; mod rudp; mod schema_discovery; @@ -11,6 +12,7 @@ mod transport; pub use contracts::*; pub use cultmesh::*; pub use framing::*; +pub use operation_service::*; pub use replication::*; pub use rudp::*; pub use schema_discovery::*; diff --git a/packages/cultnet-rs/src/operation_service.rs b/packages/cultnet-rs/src/operation_service.rs new file mode 100644 index 00000000..84d60cfa --- /dev/null +++ b/packages/cultnet-rs/src/operation_service.rs @@ -0,0 +1,60 @@ +use std::net::UdpSocket; +use std::time::Duration; + +use anyhow::{Result, anyhow}; + +use crate::{ + CultNetMessage, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, +}; + +pub const CULTNET_OPERATION_CONNECTION_ID: u32 = 0x4355_4c54; + +pub struct CultNetOperationServer { + transport: CultNetRudpSocketTransportConnection, + endpoint: String, +} + +impl CultNetOperationServer { + pub fn bind(runtime_id: impl Into, host: &str, port: u16) -> Result { + let socket = UdpSocket::bind((host, port))?; + socket.set_read_timeout(Some(Duration::from_millis(25)))?; + let address = socket.local_addr()?; + let mut options = CultNetRudpSocketTransportOptions::server( + runtime_id, + socket, + CULTNET_OPERATION_CONNECTION_ID, + ); + options.max_fragment_bytes = Some(2048); + Ok(Self { + transport: CultNetRudpSocketTransportConnection::new(options)?, + endpoint: format!("rudp://{}:{}", address.ip(), address.port()), + }) + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn serve_once( + &mut self, + mut handler: impl FnMut(CultNetMessage) -> Result, + ) -> Result { + let Some(request) = self.transport.receive_schema_message_once()? else { + self.transport.poll_resends()?; + return Ok(false); + }; + if !matches!(request, CultNetMessage::OperationRequest { .. }) { + return Err(anyhow!( + "CultNet operation service received a non-operation message" + )); + } + let response = handler(request)?; + if !matches!(response, CultNetMessage::OperationResponse { .. }) { + return Err(anyhow!( + "CultNet operation handler returned a non-operation response" + )); + } + self.transport.send_schema_message(&response)?; + Ok(true) + } +} From 5a57e5e8a97364bc83e3498b3f00fc2226529612 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 11:44:04 +0100 Subject: [PATCH 121/498] Keep RUDP acknowledgements sequence-neutral --- packages/cultnet-rs/src/rudp.rs | 28 +++++++++++++++++++--------- packages/cultnet-rs/tests/cultnet.rs | 2 ++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index c4343a56..8b47f9e7 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -359,7 +359,9 @@ impl CultNetRudpSession { if packet.packet_type == CultNetRudpPacketType::Ack || packet.packet_type == CultNetRudpPacketType::Pong { - self.remember_received(packet.sequence); + if packet.packet_type == CultNetRudpPacketType::Pong { + self.remember_received(packet.sequence); + } return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), reply: None, @@ -437,14 +439,22 @@ impl CultNetRudpSession { } pub fn create_ack(&mut self) -> CultNetRudpPacket { - self.create_packet( - CultNetRudpPacketType::Ack, - "control", - Vec::new(), - false, - false, - false, - ) + let (ack, ack_mask) = self.ack_state(); + CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Ack, + connection_id: self.connection_id, + sequence: 0, + ack, + ack_mask, + channel_id: "control".to_string(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + } } pub fn create_ping(&mut self, payload: Vec) -> CultNetRudpPacket { diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index e72a93a1..6511fdbd 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -992,6 +992,8 @@ fn rudp_session_skips_control_packets_while_ordering_schema_payloads() -> Result let first = sender.send("schema", b"first".to_vec(), options.clone())?; let control = sender.create_ack(); let second = sender.send("schema", b"second".to_vec(), options)?; + assert_eq!(control.sequence, 0); + assert_eq!(second.sequence, first.sequence + 1); assert_eq!( receiver From eba856cb84df98719c3b0e41ce2e48d06ca11e2c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 12:02:52 +0100 Subject: [PATCH 122/498] Preserve Unity package import metadata --- unity/org.gamecult.cultlib/README.md.meta | 7 +++++++ unity/org.gamecult.cultlib/Runtime.meta | 8 ++++++++ .../Runtime/GameCult.CultLib.asmdef.meta | 7 +++++++ unity/org.gamecult.cultlib/Runtime/Plugins.meta | 8 ++++++++ .../Runtime/Plugins/ConcurrentCollections.dll.meta | 2 ++ .../Runtime/Plugins/GameCult.Caching.MessagePack.dll.meta | 2 ++ .../Runtime/Plugins/GameCult.Caching.MessagePack.pdb.meta | 7 +++++++ .../Runtime/Plugins/GameCult.Caching.dll.meta | 2 ++ .../Runtime/Plugins/GameCult.Caching.pdb.meta | 7 +++++++ .../Runtime/Plugins/GameCult.Logging.dll.meta | 2 ++ .../Runtime/Plugins/GameCult.Logging.pdb.meta | 7 +++++++ .../Runtime/Plugins/GameCult.Mesh.dll.meta | 2 ++ .../Runtime/Plugins/GameCult.Mesh.pdb.meta | 7 +++++++ .../Runtime/Plugins/GameCult.Networking.dll.meta | 2 ++ .../Runtime/Plugins/GameCult.Networking.pdb.meta | 7 +++++++ .../Runtime/Plugins/Isopoh.Cryptography.Argon2.dll.meta | 2 ++ .../Runtime/Plugins/Isopoh.Cryptography.Blake2b.dll.meta | 2 ++ .../Plugins/Isopoh.Cryptography.SecureArray.dll.meta | 2 ++ .../Runtime/Plugins/LiteNetLib.dll.meta | 2 ++ .../Runtime/Plugins/MessagePack.Annotations.dll.meta | 2 ++ .../Runtime/Plugins/MessagePack.dll.meta | 2 ++ .../Plugins/Microsoft.Bcl.AsyncInterfaces.dll.meta | 2 ++ .../Runtime/Plugins/Microsoft.Bcl.TimeProvider.dll.meta | 2 ++ .../Runtime/Plugins/Microsoft.NET.StringTools.dll.meta | 2 ++ unity/org.gamecult.cultlib/Runtime/Plugins/R3.dll.meta | 2 ++ .../Runtime/Plugins/System.Collections.Immutable.dll.meta | 2 ++ .../Plugins/System.ComponentModel.Annotations.dll.meta | 2 ++ .../Runtime/Plugins/System.IO.Pipelines.dll.meta | 2 ++ .../System.Runtime.CompilerServices.Unsafe.dll.meta | 2 ++ .../Runtime/Plugins/System.Text.Encodings.Web.dll.meta | 2 ++ .../Runtime/Plugins/System.Text.Json.dll.meta | 2 ++ .../Runtime/Plugins/System.Threading.Channels.dll.meta | 2 ++ unity/org.gamecult.cultlib/package.json.meta | 7 +++++++ 33 files changed, 118 insertions(+) create mode 100644 unity/org.gamecult.cultlib/README.md.meta create mode 100644 unity/org.gamecult.cultlib/Runtime.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/ConcurrentCollections.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Argon2.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Blake2b.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.SecureArray.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/LiteNetLib.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.Annotations.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.AsyncInterfaces.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.TimeProvider.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.NET.StringTools.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/R3.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Collections.Immutable.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.ComponentModel.Annotations.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.IO.Pipelines.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Runtime.CompilerServices.Unsafe.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Encodings.Web.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Json.dll.meta create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Threading.Channels.dll.meta create mode 100644 unity/org.gamecult.cultlib/package.json.meta diff --git a/unity/org.gamecult.cultlib/README.md.meta b/unity/org.gamecult.cultlib/README.md.meta new file mode 100644 index 00000000..922e4cf5 --- /dev/null +++ b/unity/org.gamecult.cultlib/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2b8db24da98fc9f4598cf8b817e105f2 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime.meta b/unity/org.gamecult.cultlib/Runtime.meta new file mode 100644 index 00000000..4d0c8a68 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: edb94eed225101a4db752a0d16cea921 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef.meta b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef.meta new file mode 100644 index 00000000..c13b8090 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3e407de226115f94ba908048a746bc6a +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins.meta b/unity/org.gamecult.cultlib/Runtime/Plugins.meta new file mode 100644 index 00000000..a8a10739 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b9f82503ba2895e4ebd479843e1166d4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/ConcurrentCollections.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/ConcurrentCollections.dll.meta new file mode 100644 index 00000000..f9560315 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/ConcurrentCollections.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f880b96b8e22ac1408906f489576a000 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll.meta new file mode 100644 index 00000000..bee72153 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 23257a51a272a664bb0c26b1e0a6e7dd \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb.meta new file mode 100644 index 00000000..d9371c35 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8ca1876fa3785214a8a783952674d3a5 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll.meta new file mode 100644 index 00000000..eccf738b --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ef416d2e8fd6634aa3e6c68e37d5c3b \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb.meta new file mode 100644 index 00000000..012b54ff --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b79cc22ab985ab24e930f69fad16a319 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll.meta new file mode 100644 index 00000000..9fb0bc47 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4a1456bd88290184e953dd63b4744746 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb.meta new file mode 100644 index 00000000..8a0a8b47 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 852ef8a785d7ca246b8f20402cc823af +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll.meta new file mode 100644 index 00000000..ddb4324a --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c7a76cd922e31d4583312019df184cd \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb.meta new file mode 100644 index 00000000..82222234 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 48679dc4ff2c05349a2985a7fc05f055 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll.meta new file mode 100644 index 00000000..bce59270 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8fda0421bc995bc42af3f81e76f4d007 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb.meta new file mode 100644 index 00000000..c9d40d66 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 596077e93808e9f49bdf7ebac67de766 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Argon2.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Argon2.dll.meta new file mode 100644 index 00000000..b9cf54b7 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Argon2.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 17b2e6bd24808de45ad3b8e54edc7323 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Blake2b.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Blake2b.dll.meta new file mode 100644 index 00000000..d3346285 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Blake2b.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f83afb6d59b7cff4c811e8143551a4b3 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.SecureArray.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.SecureArray.dll.meta new file mode 100644 index 00000000..e52456eb --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.SecureArray.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6dcfab1f2b97d23488edbf115900b75b \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/LiteNetLib.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/LiteNetLib.dll.meta new file mode 100644 index 00000000..ca771147 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/LiteNetLib.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e508a094e7b02144fb4411478dcf4896 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.Annotations.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.Annotations.dll.meta new file mode 100644 index 00000000..f6e9a62d --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.Annotations.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 17cc07dfceb06bd49a729f38ff4c3f5e \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.dll.meta new file mode 100644 index 00000000..46c36f26 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d57884c5b8f53f7419f9ba9f4f2fb664 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.AsyncInterfaces.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.AsyncInterfaces.dll.meta new file mode 100644 index 00000000..434ac3d1 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.AsyncInterfaces.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 10d2556f4688f824a9765c16e4eedb7f \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.TimeProvider.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.TimeProvider.dll.meta new file mode 100644 index 00000000..0836ae97 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.TimeProvider.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 715cbdde1cc7f2145999fc2384c30d42 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.NET.StringTools.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.NET.StringTools.dll.meta new file mode 100644 index 00000000..d955eae2 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.NET.StringTools.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3db2e13fd5852ae41a07b3f3b353b4fe \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/R3.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/R3.dll.meta new file mode 100644 index 00000000..fe59858f --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/R3.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1cf850b088f3e0449910470efe36d45e \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/System.Collections.Immutable.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Collections.Immutable.dll.meta new file mode 100644 index 00000000..e0a6e951 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Collections.Immutable.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 05a638a393287b645bc025be25b70e52 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/System.ComponentModel.Annotations.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/System.ComponentModel.Annotations.dll.meta new file mode 100644 index 00000000..3de3c413 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/System.ComponentModel.Annotations.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ad5cea3c2c2da9445bf53ebd31ecd0dd \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/System.IO.Pipelines.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/System.IO.Pipelines.dll.meta new file mode 100644 index 00000000..f8b4b136 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/System.IO.Pipelines.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 79183527f5bbdc344aa9a20251f06ec0 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/System.Runtime.CompilerServices.Unsafe.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Runtime.CompilerServices.Unsafe.dll.meta new file mode 100644 index 00000000..e7ac5b7b --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Runtime.CompilerServices.Unsafe.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38e3f463e2e9d29439283b08c4e65582 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Encodings.Web.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Encodings.Web.dll.meta new file mode 100644 index 00000000..5f6acdb8 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Encodings.Web.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d3d961cf372840746b08ad34ae89b9db \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Json.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Json.dll.meta new file mode 100644 index 00000000..2ed620fd --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Json.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a1420294c34762748bb0a2f0b555ed67 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/System.Threading.Channels.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Threading.Channels.dll.meta new file mode 100644 index 00000000..05b73ea3 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/System.Threading.Channels.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: efdbf01b8e904904989d4ac4ac55a471 \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/package.json.meta b/unity/org.gamecult.cultlib/package.json.meta new file mode 100644 index 00000000..e012cbe0 --- /dev/null +++ b/unity/org.gamecult.cultlib/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3ab8a5e0e58ddab49b1f95b542806037 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From c398bad1868ef94f79195026fa7504d845593245 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 08:42:08 +0100 Subject: [PATCH 123/498] Deliver large scoped snapshots reliably --- .../CultNetDocumentRegistry.cs | 52 +++++++++----- .../CultNetSchemaClient.cs | 2 +- src/GameCult.Networking/CultNetTransport.cs | 44 ++++++++---- .../NetworkingTests.cs | 69 +++++++++++++++++++ 4 files changed, 133 insertions(+), 34 deletions(-) diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index e2633768..9d176556 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -270,7 +270,7 @@ namespace GameCult.Networking var storedAt = ResolveStoredAt(options); var documents = new List(); - foreach (var document in cache.AllEntries) + void AddDocument(object document, string key) { var descriptor = _documents.GetRequired(document.GetType()); var binding = GetByDocumentType(document.GetType()) ?? @@ -282,27 +282,11 @@ namespace GameCult.Networking if (requestedSchemaIds != null && !MatchesRequestedSchema(descriptor, binding, requestedSchemaIds)) { - continue; + return; } - - var handleMethod = typeof(CultCache) - .GetMethod(nameof(CultCache.TryGetHandle))! - .MakeGenericMethod(document.GetType()); - var handleObject = handleMethod.Invoke(cache, new[] { document }); - if (handleObject == null) - { - continue; - } - - var keyProperty = handleObject.GetType().GetProperty("Value"); - var handleValue = keyProperty?.GetValue(handleObject) ?? handleObject; - var recordKeyProperty = handleValue.GetType().GetProperty("Key"); - var recordKey = recordKeyProperty?.GetValue(handleValue); - var valueProperty = recordKey?.GetType().GetProperty("Value"); - var key = (string?)(valueProperty?.GetValue(recordKey)) ?? string.Empty; if (requestedRecordKeys != null && !requestedRecordKeys.Contains(key)) { - continue; + return; } documents.Add(new CultNetRawDocumentRecord @@ -319,6 +303,36 @@ namespace GameCult.Networking }); } + if (requestedRecordKeys != null) + { + foreach (var key in requestedRecordKeys) + { + var document = cache.Get(new CultRecordKey(key)); + if (document != null) + AddDocument(document, key); + } + } + else + { + foreach (var document in cache.AllEntries) + { + var handleMethod = typeof(CultCache) + .GetMethod(nameof(CultCache.TryGetHandle))! + .MakeGenericMethod(document.GetType()); + var handleObject = handleMethod.Invoke(cache, new[] { document }); + if (handleObject == null) + continue; + + var keyProperty = handleObject.GetType().GetProperty("Value"); + var handleValue = keyProperty?.GetValue(handleObject) ?? handleObject; + var recordKeyProperty = handleValue.GetType().GetProperty("Key"); + var recordKey = recordKeyProperty?.GetValue(handleValue); + var valueProperty = recordKey?.GetType().GetProperty("Value"); + var key = (string?)(valueProperty?.GetValue(recordKey)) ?? string.Empty; + AddDocument(document, key); + } + } + return new CultNetSnapshotResponseRawMessage { MessageId = RequireNonEmpty(messageId, nameof(messageId)), diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs index 7cc87537..6aa1accc 100644 --- a/src/GameCult.Networking/CultNetSchemaClient.cs +++ b/src/GameCult.Networking/CultNetSchemaClient.cs @@ -216,7 +216,7 @@ namespace GameCult.Networking Dispatch(message); } - Thread.Sleep(5); + Thread.Sleep(1); } } diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index a17d7e78..5483983b 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -1508,6 +1508,8 @@ namespace GameCult.Networking /// public sealed class CultNetRudpSocketTransportConnection : IDisposable { + private const int MinimumReceiveBufferBytes = 4 * 1024 * 1024; + private const int WireBurstPackets = 1; private readonly Socket _socket; private readonly CultNetRudpSession _session; private readonly CultNetRudpSocketMode _mode; @@ -1657,10 +1659,7 @@ namespace GameCult.Networking /// public void Send(string channelId, byte[] payload) { - foreach (var packet in _session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes)) - { - SendPacket(packet); - } + SendPackets(_session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes)); _stats.FramesSent++; } @@ -1929,9 +1928,16 @@ namespace GameCult.Networking /// public void PollResends() { - foreach (var packet in _session.DueResends(NowMs())) + SendPackets(_session.DueResends(NowMs())); + } + + private void SendPackets(IReadOnlyList packets) + { + for (var index = 0; index < packets.Count; index++) { - SendPacket(packet); + SendPacket(packets[index]); + if ((index + 1) % WireBurstPackets == 0) + Thread.Sleep(1); } } @@ -2072,6 +2078,8 @@ namespace GameCult.Networking /// public sealed class CultNetRudpSocketTransportServer : IDisposable { + private const int MinimumReceiveBufferBytes = 4 * 1024 * 1024; + private const int WireBurstPackets = 1; private readonly Socket _socket; private readonly uint _connectionId; private readonly uint _initialSequence; @@ -2092,6 +2100,10 @@ namespace GameCult.Networking if (options == null) throw new ArgumentNullException(nameof(options)); if (string.IsNullOrWhiteSpace(options.RuntimeId)) throw new ArgumentException("Runtime id is required.", nameof(options)); _socket = options.Socket ?? throw new ArgumentNullException(nameof(options.Socket)); + if (_socket.ReceiveBufferSize < MinimumReceiveBufferBytes) + _socket.ReceiveBufferSize = MinimumReceiveBufferBytes; + if (_socket.ReceiveBufferSize < MinimumReceiveBufferBytes) + _socket.ReceiveBufferSize = MinimumReceiveBufferBytes; _connectionId = options.ConnectionId; _initialSequence = options.InitialSequence; _resendDelayMs = options.ResendDelayMs; @@ -2135,10 +2147,7 @@ namespace GameCult.Networking public void Send(CultNetRudpSocketServerPeer peer, string channelId, byte[] payload) { if (peer == null) throw new ArgumentNullException(nameof(peer)); - foreach (var packet in peer.Session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes)) - { - SendPacket(peer.RemoteEndPoint, packet); - } + SendPackets(peer, peer.Session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes)); _stats.FramesSent++; } @@ -2287,10 +2296,17 @@ namespace GameCult.Networking { foreach (var peer in _peers.Values.ToArray()) { - foreach (var packet in peer.Session.DueResends(NowMs())) - { - SendPacket(peer.RemoteEndPoint, packet); - } + SendPackets(peer, peer.Session.DueResends(NowMs())); + } + } + + private void SendPackets(CultNetRudpSocketServerPeer peer, IReadOnlyList packets) + { + for (var index = 0; index < packets.Count; index++) + { + SendPacket(peer.RemoteEndPoint, packets[index]); + if ((index + 1) % WireBurstPackets == 0) + Thread.Sleep(1); } } diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 3b54be31..2cba958d 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1024,6 +1024,7 @@ namespace GameCult.Networking.Tests Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp")); Assert.That(client.Stats.FramesSent, Is.EqualTo(1)); Assert.That(server.Stats.FramesReceived, Is.EqualTo(1)); + } [Test] @@ -1260,6 +1261,74 @@ namespace GameCult.Networking.Tests Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp")); } + [Test] + public async Task RudpCultNetSchemaServer_DeliversLargeSnapshotResponse() + { + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "csharp-rudp-large-snapshot-host", + Socket = BindUdpSocket(), + MaxFragmentBytes = 1024, + MaxPendingReliablePackets = 1024 + }); + var responseCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var serverFailure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.OnCultNet((request, peer) => peer.SendCultNet( + new CultNetSnapshotResponseRawMessage + { + MessageId = request.MessageId, + Documents = + [ + new CultNetRawDocumentRecord + { + SchemaId = "test.large.snapshot.v1", + RecordKey = "test:large:snapshot", + PayloadEncoding = "messagepack", + Payload = Enumerable.Range(0, 128 * 1024) + .Select(index => (byte)(index % 251)).ToArray() + } + ] + })); + + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + try + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + } + catch (Exception error) { serverFailure.TrySetResult(error); } + }) { IsBackground = true }; + serverThread.Start(); + + using var client = CultNetSchemaClients.CreateRudp( + runtimeId: "csharp-rudp-large-snapshot-client", + maxFragmentBytes: 1024); + client.OnCultNet(response => responseCompletion.TrySetResult(response)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + client.SendCultNet(new CultNetSnapshotRequestMessage + { + MessageId = "large-snapshot", + SchemaIds = ["test.large.snapshot.v1"], + RecordKeys = ["test:large:snapshot"] + }); + + var completed = await Task.WhenAny(responseCompletion.Task, serverFailure.Task, Task.Delay(5000)); + if (completed == serverFailure.Task) throw await serverFailure.Task; + if (completed != responseCompletion.Task) + Assert.Fail($"Large snapshot timed out; server sent {server.Stats.BytesSent} bytes, received {server.Stats.BytesReceived} bytes, transports {server.Profile.Transports.Count()}."); + var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromMilliseconds(1)); + cancellation.Cancel(); + Assert.That(response.Documents, Has.Length.EqualTo(1)); + Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024)); + } + [Test] public void RudpSocketTransport_ErgonomicHelpersCarryNamedChannels() { From 6be0573ffcc44956081344c9417f1113e3ee7cde Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 10:33:02 +0100 Subject: [PATCH 124/498] Surface CultNet background transport failures --- src/GameCult.Mesh/CultMeshSnapshots.cs | 17 ++++- .../CultNetSchemaClient.cs | 66 ++++++++++++------- .../CultMeshStreamingTests.cs | 45 +++++++++++++ 3 files changed, 101 insertions(+), 27 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 8926ec50..939f33f1 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -779,7 +779,8 @@ namespace GameCult.Mesh completion.Task, endpoint, resolvedOptions, - messageId) + messageId, + (client as ICultNetSchemaClientHealth)?.BackgroundFailure) .ConfigureAwait(false); } @@ -886,10 +887,20 @@ namespace GameCult.Mesh Task responseTask, string endpoint, CultMeshSnapshotRequestOptions options, - string messageId) + string messageId, + Task? backgroundFailure = null) { var timeoutTask = Task.Delay(options.ResponseTimeout); - var completed = await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false); + var completed = backgroundFailure == null + ? await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false) + : await Task.WhenAny(responseTask, timeoutTask, backgroundFailure).ConfigureAwait(false); + if (completed == backgroundFailure) + { + var error = await backgroundFailure.ConfigureAwait(false); + throw new InvalidOperationException( + $"CultNet snapshot client failed while waiting for response '{messageId}' from {endpoint}.", + error); + } if (completed != responseTask) { throw new TimeoutException( diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs index 6aa1accc..f5e41050 100644 --- a/src/GameCult.Networking/CultNetSchemaClient.cs +++ b/src/GameCult.Networking/CultNetSchemaClient.cs @@ -6,6 +6,7 @@ using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; +using System.Threading.Tasks; namespace GameCult.Networking { @@ -37,6 +38,17 @@ namespace GameCult.Networking where T : ICultNetSchemaMessage; } + /// + /// Exposes terminal failure of a schema client's background transport work. + /// + public interface ICultNetSchemaClientHealth + { + /// + /// Completes if the client's background transport loop terminates unexpectedly. + /// + Task BackgroundFailure { get; } + } + /// /// LiteNetLib-backed implementation of the CultNet schema client port. /// @@ -91,7 +103,7 @@ namespace GameCult.Networking /// /// RUDP-backed implementation of the CultNet schema client port. /// - public sealed class RudpCultNetSchemaClient : ICultNetSchemaClient + public sealed class RudpCultNetSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth { private readonly string _runtimeId; private readonly uint _connectionId; @@ -100,6 +112,8 @@ namespace GameCult.Networking private readonly long _resendDelayMs; private readonly ConcurrentDictionary> _handlers = new(); private readonly object _handlerLock = new(); + private readonly TaskCompletionSource _backgroundFailure = new( + TaskCreationOptions.RunContinuationsAsynchronously); private CultNetRudpSocketTransportConnection? _transport; private Thread? _pumpThread; private volatile bool _disposed; @@ -125,6 +139,9 @@ namespace GameCult.Networking /// public bool Connected => _transport?.Connected == true; + /// + public Task BackgroundFailure => _backgroundFailure.Task; + /// public void Connect(string host, int port) { @@ -188,35 +205,36 @@ namespace GameCult.Networking private void Pump() { - while (!_disposed) + try { - var transport = _transport; - if (transport == null) + while (!_disposed) { - return; - } + var transport = _transport; + if (transport == null) + { + return; + } - ICultNetSchemaMessage? message; - try - { - message = transport.ReceiveSchemaMessageOnce(); + var message = transport.ReceiveSchemaMessageOnce(); transport.PollResends(); - } - catch (ObjectDisposedException) when (_disposed) - { - return; - } - catch (SocketException) when (_disposed) - { - return; - } - if (message != null) - { - Dispatch(message); - } + if (message != null) + { + Dispatch(message); + } - Thread.Sleep(1); + Thread.Sleep(1); + } + } + catch (ObjectDisposedException) when (_disposed) + { + } + catch (SocketException) when (_disposed) + { + } + catch (Exception error) + { + _backgroundFailure.TrySetResult(error); } } diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index e7f20bd9..5062d0e0 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2654,6 +2654,26 @@ public sealed class CultMeshStreamingTests request.MessageId.StartsWith("mesh-test-scoped-snapshot:", StringComparison.Ordinal)); } + [Test] + public async Task SnapshotHelpers_SurfaceBackgroundClientFailureBeforeTimeout() + { + var options = new CultMeshSnapshotRequestOptions + { + ResponseTimeout = TimeSpan.FromSeconds(30), + RecordKeys = new[] { "eve:surface:test" }, + CreateClient = () => new FailedBackgroundSchemaClient( + new FormatException("malformed fragment")) + }; + + Func act = async () => await CultMesh.FetchSnapshotAsync( + "rudp://127.0.0.1:3075", + options); + + var failure = await act.Should().ThrowAsync(); + failure.Which.InnerException.Should().BeOfType(); + failure.Which.Message.Should().Contain("127.0.0.1:3075"); + } + [Test] public async Task SnapshotHelpers_DecodeSameSchemaAliasFromForeignSchemaId() { @@ -3815,3 +3835,28 @@ internal sealed class MeshSnapshotSchemaClient : ICultNetSchemaClient { } } + +internal sealed class FailedBackgroundSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth +{ + public FailedBackgroundSchemaClient(Exception error) + { + BackgroundFailure = Task.FromResult(error); + } + + public bool Connected { get; private set; } + public Task BackgroundFailure { get; } + + public void Connect(string host, int port) => Connected = true; + + public void SendCultNet(T message) where T : ICultNetSchemaMessage + { + } + + public void OnCultNet(Action callback) where T : ICultNetSchemaMessage + { + } + + public void Dispose() + { + } +} From 455dcd8f9e1d05ca454a8445f80bcb4e3d27434f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 10:38:45 +0100 Subject: [PATCH 125/498] Report CultNet failures during connection --- src/GameCult.Mesh/CultMeshSnapshots.cs | 16 +++++++++-- .../CultMeshStreamingTests.cs | 27 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 939f33f1..3364e2a3 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -764,7 +764,11 @@ namespace GameCult.Mesh var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint); client.Connect(host, port); - await WaitForSnapshotClientConnectionAsync(client, endpoint, resolvedOptions.ConnectTimeout) + await WaitForSnapshotClientConnectionAsync( + client, + endpoint, + resolvedOptions.ConnectTimeout, + (client as ICultNetSchemaClientHealth)?.BackgroundFailure) .ConfigureAwait(false); client.SendCultNet(new CultNetSnapshotRequestMessage { @@ -871,11 +875,19 @@ namespace GameCult.Mesh internal static async Task WaitForSnapshotClientConnectionAsync( ICultNetSchemaClient client, string endpoint, - TimeSpan timeout) + TimeSpan timeout, + Task? backgroundFailure = null) { var deadline = DateTimeOffset.UtcNow + timeout; while (!client.Connected) { + if (backgroundFailure?.IsCompleted == true) + { + var error = await backgroundFailure.ConfigureAwait(false); + throw new InvalidOperationException( + $"CultNet snapshot client failed while connecting to {endpoint}.", + error); + } if (DateTimeOffset.UtcNow >= deadline) throw new TimeoutException($"Timed out connecting to CultNet snapshot endpoint {endpoint}."); diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 5062d0e0..46116d72 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2674,6 +2674,26 @@ public sealed class CultMeshStreamingTests failure.Which.Message.Should().Contain("127.0.0.1:3075"); } + [Test] + public async Task SnapshotHelpers_SurfaceBackgroundClientFailureWhileConnecting() + { + var options = new CultMeshSnapshotRequestOptions + { + ConnectTimeout = TimeSpan.FromSeconds(30), + CreateClient = () => new FailedBackgroundSchemaClient( + new FormatException("malformed handshake"), + connected: false) + }; + + Func act = async () => await CultMesh.FetchSnapshotAsync( + "rudp://127.0.0.1:3075", + options); + + var failure = await act.Should().ThrowAsync(); + failure.Which.InnerException.Should().BeOfType(); + failure.Which.Message.Should().Contain("connecting"); + } + [Test] public async Task SnapshotHelpers_DecodeSameSchemaAliasFromForeignSchemaId() { @@ -3838,15 +3858,18 @@ internal sealed class MeshSnapshotSchemaClient : ICultNetSchemaClient internal sealed class FailedBackgroundSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth { - public FailedBackgroundSchemaClient(Exception error) + private readonly bool _connected; + + public FailedBackgroundSchemaClient(Exception error, bool connected = true) { + _connected = connected; BackgroundFailure = Task.FromResult(error); } public bool Connected { get; private set; } public Task BackgroundFailure { get; } - public void Connect(string host, int port) => Connected = true; + public void Connect(string host, int port) => Connected = _connected; public void SendCultNet(T message) where T : ICultNetSchemaMessage { From ffeb4c1f09c4c67c73cde98ee936ed4171c68a0d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 10:43:56 +0100 Subject: [PATCH 126/498] Surface Verse discovery transport failures --- src/GameCult.Mesh/CultMeshVerseDiscovery.cs | 31 +++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs index 9ada1691..c6b9b4b6 100644 --- a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs +++ b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs @@ -203,7 +203,8 @@ namespace GameCult.Mesh completion.TrySetException(new InvalidOperationException(error.Error))); client.Connect(host, port); - await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); + var backgroundFailure = (client as ICultNetSchemaClientHealth)?.BackgroundFailure; + await WaitForConnectionAsync(client, endpoint, backgroundFailure).ConfigureAwait(false); client.SendCultNet(new CultMeshVerseCatalogRequestMessage { MessageId = messageId, @@ -211,7 +212,7 @@ namespace GameCult.Mesh VerseIds = request?.VerseIds }); - return await WaitForResponseAsync(completion.Task, endpoint).ConfigureAwait(false); + return await WaitForResponseAsync(completion.Task, endpoint, backgroundFailure).ConfigureAwait(false); } /// @@ -245,11 +246,21 @@ namespace GameCult.Mesh ?? CultNetSchemaClients.CreateForEndpoint(endpoint, _options.Security, _options.ConfigureClient); } - private async Task WaitForConnectionAsync(ICultNetSchemaClient client, string endpoint) + private async Task WaitForConnectionAsync( + ICultNetSchemaClient client, + string endpoint, + Task? backgroundFailure) { var deadline = DateTimeOffset.UtcNow + _options.ConnectTimeout; while (!client.Connected) { + if (backgroundFailure?.IsCompleted == true) + { + var error = await backgroundFailure.ConfigureAwait(false); + throw new InvalidOperationException( + $"Verse discovery client failed while connecting to {endpoint}.", + error); + } if (DateTimeOffset.UtcNow >= deadline) { throw new TimeoutException($"Timed out connecting to Verse discovery endpoint {endpoint}."); @@ -261,10 +272,20 @@ namespace GameCult.Mesh private async Task WaitForResponseAsync( Task responseTask, - string endpoint) + string endpoint, + Task? backgroundFailure) { var timeoutTask = Task.Delay(_options.ResponseTimeout); - var completed = await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false); + var completed = backgroundFailure == null + ? await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false) + : await Task.WhenAny(responseTask, timeoutTask, backgroundFailure).ConfigureAwait(false); + if (completed == backgroundFailure) + { + var error = await backgroundFailure.ConfigureAwait(false); + throw new InvalidOperationException( + $"Verse discovery client failed while waiting for a response from {endpoint}.", + error); + } if (completed != responseTask) { throw new TimeoutException($"Timed out waiting for Verse discovery response from {endpoint}."); From 5f1583b3c05fd22194edacbd9e1e00beadb64f75 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 10:53:05 +0100 Subject: [PATCH 127/498] Close RUDP schema client sessions cleanly --- .../CultNetRudpSchemaServer.cs | 6 +++ .../CultNetSchemaClient.cs | 27 ++++++++++- .../NetworkingTests.cs | 46 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Networking/CultNetRudpSchemaServer.cs b/src/GameCult.Networking/CultNetRudpSchemaServer.cs index 6021dced..08852ed1 100644 --- a/src/GameCult.Networking/CultNetRudpSchemaServer.cs +++ b/src/GameCult.Networking/CultNetRudpSchemaServer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Threading.Tasks; @@ -137,6 +138,11 @@ namespace GameCult.Networking /// public CultNetTransportStats Stats => _transport.Stats; + /// + /// Gets the currently tracked RUDP peers. + /// + public IReadOnlyCollection Peers => _transport.Peers; + /// /// Registers a schema-v0 handler. /// diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs index f5e41050..91337a62 100644 --- a/src/GameCult.Networking/CultNetSchemaClient.cs +++ b/src/GameCult.Networking/CultNetSchemaClient.cs @@ -199,8 +199,33 @@ namespace GameCult.Networking /// public void Dispose() { + if (_disposed) + { + return; + } + + var transport = _transport; + if (transport?.Connected == true) + { + try + { + transport.Disconnect(Encoding.UTF8.GetBytes("schema-client-disposed")); + } + catch (SocketException) + { + } + catch (ObjectDisposedException) + { + } + } + _disposed = true; - _transport?.Dispose(); + transport?.Dispose(); + var pumpThread = _pumpThread; + if (pumpThread != null && pumpThread != Thread.CurrentThread) + { + pumpThread.Join(TimeSpan.FromMilliseconds(250)); + } } private void Pump() diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 2cba958d..53de18cf 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1261,6 +1261,52 @@ namespace GameCult.Networking.Tests Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp")); } + [Test] + public async Task RudpCultNetSchemaServer_AcceptsSequentialShortLivedClients() + { + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "csharp-rudp-sequential-host", + Socket = BindUdpSocket(), + MaxFragmentBytes = 1024 + }); + server.OnCultNet((request, peer) => + peer.SendCultNet(new CultNetSchemaCatalogResponseMessage + { + MessageId = request.MessageId, + Schemas = Array.Empty() + })); + + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + + async Task FetchOnce(string messageId) + { + using var client = CultNetSchemaClients.CreateRudp(runtimeId: $"client-{messageId}"); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnCultNet(response => completion.TrySetResult(response)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + client.SendCultNet(new CultNetSchemaCatalogRequestMessage { MessageId = messageId }); + var response = await AwaitWithTimeout(completion.Task, TimeSpan.FromSeconds(2)); + Assert.That(response.MessageId, Is.EqualTo(messageId)); + } + + await FetchOnce("first"); + await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); + await FetchOnce("second"); + cancellation.Cancel(); + } + [Test] public async Task RudpCultNetSchemaServer_DeliversLargeSnapshotResponse() { From 71dcfc11e8ae5e33877023a808b964f1aa9a76e2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 11:05:47 +0100 Subject: [PATCH 128/498] Publish RUDP connection state across threads --- src/GameCult.Networking/CultNetTransport.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index 5483983b..d3b290de 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -889,7 +889,7 @@ namespace GameCult.Networking private uint _nextSequence; private ushort _nextFragmentId = 1; private readonly int? _maxPendingReliablePackets; - private bool _connected; + private volatile bool _connected; private long? _lastReceivedAtMs; private uint? _highestReceivedSequence; private readonly HashSet _receivedSequences = new HashSet(); From 0e4c5f9bf37a16f9eb59339d0965fc7972b2fda7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 11:13:19 +0100 Subject: [PATCH 129/498] Observe retained snapshot transport health --- src/GameCult.Mesh/CultMeshSnapshots.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 3364e2a3..3f2eb016 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -105,7 +105,8 @@ namespace GameCult.Mesh await CultMesh.WaitForSnapshotClientConnectionAsync( _client, _endpoint, - _defaults.ConnectTimeout) + _defaults.ConnectTimeout, + (_client as ICultNetSchemaClientHealth)?.BackgroundFailure) .ConfigureAwait(false); var options = CultMesh.CloneSnapshotRequestOptions(_defaults); options.SchemaIds = schemaIds ?? options.SchemaIds; @@ -130,7 +131,8 @@ namespace GameCult.Mesh completion.Task, _endpoint, options, - messageId) + messageId, + (_client as ICultNetSchemaClientHealth)?.BackgroundFailure) .ConfigureAwait(false); } finally From 4d8d43621de224bf9c39801140820f04fb90eda3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 12:05:31 +0100 Subject: [PATCH 130/498] Stream database subscriptions through schema servers --- .../CultNetDatabaseSubscriptionServer.cs | 177 ++++++++++++++++++ .../NetworkingTests.cs | 55 ++++++ 2 files changed, 232 insertions(+) create mode 100644 src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs new file mode 100644 index 00000000..785b35aa --- /dev/null +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using GameCult.Caching; +using R3; + +namespace GameCult.Networking +{ + /// + /// Publishes live database changes through any schema-v0 server transport. + /// + public sealed class CultNetDatabaseSubscriptionServer : IDisposable + { + private readonly ICultNetSchemaServer _server; + private readonly CultNetDatabase _database; + private readonly Func _subscribe; + private readonly Func _unsubscribe; + private readonly ConcurrentDictionary _subscriptions = + new ConcurrentDictionary(SubscriptionKeyComparer.Instance); + private bool _disposed; + + /// Attaches live database subscription handlers to a schema server. + public CultNetDatabaseSubscriptionServer(ICultNetSchemaServer server, CultNetDatabase database) + { + _server = server ?? throw new ArgumentNullException(nameof(server)); + _database = database ?? throw new ArgumentNullException(nameof(database)); + _subscribe = HandleSubscribeAsync; + _unsubscribe = HandleUnsubscribeAsync; + _server.OnCultNet(_subscribe); + _server.OnCultNet(_unsubscribe); + } + + /// Detaches handlers and releases all active watches. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _server.RemoveCultNetMessageListener(_subscribe); + _server.RemoveCultNetMessageListener(_unsubscribe); + foreach (var subscription in _subscriptions.Values) subscription.Dispose(); + _subscriptions.Clear(); + } + + private Task HandleSubscribeAsync(CultNetDatabaseSubscribeMessage request, ICultNetSchemaServerPeer peer) + { + var subscriptionId = string.IsNullOrWhiteSpace(request.SubscriptionId) + ? request.MessageId + : request.SubscriptionId; + if (string.IsNullOrWhiteSpace(subscriptionId)) + { + peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." }); + return Task.CompletedTask; + } + + var key = new SubscriptionKey(peer, subscriptionId); + _subscriptions.AddOrUpdate( + key, + _ => Watch(request, subscriptionId, peer), + (_, current) => + { + current.Dispose(); + return Watch(request, subscriptionId, peer); + }); + if (request.IncludeSnapshot) + { + peer.SendCultNet(_database.Documents.CreateRawSnapshotResponse( + _database.Cache, + request.MessageId, + new CultNetSnapshotRequestMessage + { + MessageId = request.MessageId, + SchemaIds = request.SchemaIds, + RecordKeys = request.RecordKeys + })); + } + return Task.CompletedTask; + } + + private Task HandleUnsubscribeAsync(CultNetDatabaseUnsubscribeMessage request, ICultNetSchemaServerPeer peer) + { + var subscriptionId = string.IsNullOrWhiteSpace(request.SubscriptionId) + ? request.MessageId + : request.SubscriptionId; + if (!string.IsNullOrWhiteSpace(subscriptionId) && + _subscriptions.TryRemove(new SubscriptionKey(peer, subscriptionId), out var subscription)) + subscription.Dispose(); + return Task.CompletedTask; + } + + private IDisposable Watch( + CultNetDatabaseSubscribeMessage request, + string subscriptionId, + ICultNetSchemaServerPeer peer) + { + return _database.WatchAllChanges().Subscribe(change => + { + var message = CreateChange(change, request, subscriptionId); + if (message != null) peer.SendCultNet(message); + }); + } + + private CultNetDatabaseChangeRawMessage? CreateChange( + object change, + CultNetDatabaseSubscribeMessage request, + string subscriptionId) + { + var changeType = change.GetType(); + var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey("")); + var schemaId = (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? ""; + if (request.RecordKeys is { Length: > 0 } && !request.RecordKeys.Contains(key.Value, StringComparer.Ordinal)) + return null; + if (request.SchemaIds is { Length: > 0 } && !request.SchemaIds.Contains(schemaId, StringComparer.Ordinal)) + return null; + + var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated); + var document = changeType.GetProperty("Document")?.GetValue(change); + if (kind == CultNetDatabaseChangeKind.Removed || document == null) + { + return new CultNetDatabaseChangeRawMessage + { + MessageId = Guid.NewGuid().ToString("N"), + SubscriptionId = subscriptionId, + ChangeKind = "removed", + RecordKey = key.Value, + SchemaId = schemaId + }; + } + + return new CultNetDatabaseChangeRawMessage + { + MessageId = Guid.NewGuid().ToString("N"), + SubscriptionId = subscriptionId, + ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated", + Document = CreateRawRecord(key, document) + }; + } + + private CultNetRawDocumentRecord CreateRawRecord(CultRecordKey key, object document) + { + var method = typeof(CultNetDocumentRegistry) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) && + candidate.IsGenericMethodDefinition); + var documentType = document.GetType(); + var handle = Activator.CreateInstance( + typeof(CultRecordHandle<>).MakeGenericType(documentType), + new object[] { key }); + var put = method.MakeGenericMethod(documentType) + .Invoke(_database.Documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null }); + return ((CultNetDocumentPutRawMessage)put!).Document; + } + + private sealed class SubscriptionKey + { + public SubscriptionKey(ICultNetSchemaServerPeer peer, string id) + { + Peer = peer; + Id = id; + } + public ICultNetSchemaServerPeer Peer { get; } + public string Id { get; } + } + + private sealed class SubscriptionKeyComparer : IEqualityComparer + { + public static SubscriptionKeyComparer Instance { get; } = new SubscriptionKeyComparer(); + public bool Equals(SubscriptionKey? x, SubscriptionKey? y) => + x != null && y != null && ReferenceEquals(x.Peer, y.Peer) && string.Equals(x.Id, y.Id, StringComparison.Ordinal); + public int GetHashCode(SubscriptionKey value) => + (RuntimeHelpers.GetHashCode(value.Peer) * 397) ^ StringComparer.Ordinal.GetHashCode(value.Id); + } + } +} diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 53de18cf..82f777f5 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1307,6 +1307,61 @@ namespace GameCult.Networking.Tests cancellation.Cancel(); } + [Test] + public async Task DatabaseSubscriptionServer_StreamsRecordChangesOverRudp() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-subscription-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + + using var client = CultNetSchemaClients.CreateRudp("database-subscription-client"); + var subscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var changed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnCultNet(message => subscribed.TrySetResult(message)); + client.OnCultNet(message => changed.TrySetResult(message)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + const string recordKey = "tests:subscription:note"; + client.SendCultNet(new CultNetDatabaseSubscribeMessage + { + MessageId = "subscribe-note", + SubscriptionId = "note", + RecordKeys = [recordKey], + IncludeSnapshot = true + }); + await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); + + await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "live" + }); + var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); + + cancellation.Cancel(); + Assert.That(update.SubscriptionId, Is.EqualTo("note")); + Assert.That(update.ChangeKind, Is.EqualTo("added")); + Assert.That(update.Document, Is.Not.Null); + Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey)); + } + [Test] public async Task RudpCultNetSchemaServer_DeliversLargeSnapshotResponse() { From b5529435c3bb9de0a37e5512ec6eb94794fe73e2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 12:19:49 +0100 Subject: [PATCH 131/498] Replicate live typed documents over retained sessions --- .../CultNetDatabaseSubscriptionClient.cs | 190 ++++++++++++++++++ .../NetworkingTests.cs | 57 ++++++ 2 files changed, 247 insertions(+) create mode 100644 src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs new file mode 100644 index 00000000..151722d6 --- /dev/null +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using GameCult.Caching; + +namespace GameCult.Networking +{ + /// + /// Replicates selected remote database records into a local typed CultCache over one retained session. + /// + public sealed class CultNetDatabaseSubscriptionClient : IDisposable + { + private readonly ICultNetSchemaClient _client; + private readonly CultCache _cache; + private readonly CultNetDocumentRegistry _documents; + private readonly ConcurrentDictionary>> _initialSnapshots = new(); + private readonly object _queueLock = new(); + private Task _queue = Task.CompletedTask; + private bool _disposed; + + public CultNetDatabaseSubscriptionClient( + ICultNetSchemaClient client, + CultCache cache, + CultNetDocumentRegistry documents) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _documents = documents ?? throw new ArgumentNullException(nameof(documents)); + _client.OnCultNet(HandleSnapshot); + _client.OnCultNet(HandleChange); + } + + /// Raised after a live change has been applied to the local typed cache. + public event Action? Changed; + + /// Gets the local reactive cache receiving replicated documents. + public CultCache Cache => _cache; + + /// + /// Starts a remote subscription and waits until its initial matching snapshot is in the local cache. + /// + public Task> SubscribeAsync( + string subscriptionId, + IEnumerable? recordKeys = null, + IEnumerable? schemaIds = null) + { + if (_disposed) throw new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient)); + if (!_client.Connected) throw new InvalidOperationException("CultNet schema client must be connected before subscribing."); + if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId)); + + var messageId = Guid.NewGuid().ToString("N"); + var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_initialSnapshots.TryAdd(messageId, completion)) + throw new InvalidOperationException($"Duplicate database subscription message id '{messageId}'."); + + _client.SendCultNet(new CultNetDatabaseSubscribeMessage + { + MessageId = messageId, + SubscriptionId = subscriptionId, + RecordKeys = recordKeys?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray(), + SchemaIds = schemaIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray(), + IncludeSnapshot = true + }); + return completion.Task; + } + + /// Stops one live remote subscription. + public void Unsubscribe(string subscriptionId) + { + if (_disposed) return; + if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId)); + _client.SendCultNet(new CultNetDatabaseUnsubscribeMessage + { + MessageId = Guid.NewGuid().ToString("N"), + SubscriptionId = subscriptionId + }); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (var completion in _initialSnapshots.Values) + completion.TrySetException(new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient))); + _initialSnapshots.Clear(); + _client.Dispose(); + } + + private void HandleSnapshot(CultNetSnapshotResponseRawMessage message) + { + Enqueue(async () => + { + if (!_initialSnapshots.TryRemove(message.MessageId, out var completion)) return; + try + { + var applied = await _documents.ApplyRawSnapshotResponseAsync(_cache, message).ConfigureAwait(false); + completion.TrySetResult(applied); + } + catch (Exception error) + { + completion.TrySetException(error); + } + }); + } + + private void HandleChange(CultNetDatabaseChangeRawMessage message) + { + Enqueue(async () => + { + if (string.Equals(message.ChangeKind, "removed", StringComparison.Ordinal)) + { + await DeleteAsync(message).ConfigureAwait(false); + Changed?.Invoke(new CultNetReplicatedDocumentChange( + message.SubscriptionId, + "removed", + message.RecordKey ?? string.Empty, + message.SchemaId ?? string.Empty, + null)); + return; + } + + if (message.Document == null) return; + var document = await _documents.ApplyRawDocumentPutMessageAsync( + _cache, + new CultNetDocumentPutRawMessage + { + MessageId = message.MessageId, + Document = message.Document + }).ConfigureAwait(false); + Changed?.Invoke(new CultNetReplicatedDocumentChange( + message.SubscriptionId, + message.ChangeKind, + message.Document.RecordKey, + message.Document.SchemaId, + document)); + }); + } + + private async Task DeleteAsync(CultNetDatabaseChangeRawMessage message) + { + if (string.IsNullOrWhiteSpace(message.RecordKey) || string.IsNullOrWhiteSpace(message.SchemaId)) return; + var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId!); + var handle = Activator.CreateInstance( + typeof(CultRecordHandle<>).MakeGenericType(descriptor.DocumentType), + new object[] { new CultRecordKey(message.RecordKey!) }); + var task = (Task)typeof(CultCache) + .GetMethod(nameof(CultCache.DeleteAsync), BindingFlags.Public | BindingFlags.Instance)! + .MakeGenericMethod(descriptor.DocumentType) + .Invoke(_cache, new[] { handle })!; + await task.ConfigureAwait(false); + } + + private void Enqueue(Func operation) + { + lock (_queueLock) + { + _queue = _queue.ContinueWith( + _ => operation(), + TaskScheduler.Default).Unwrap(); + } + } + } + + /// Describes one typed document change after local replication. + public sealed class CultNetReplicatedDocumentChange + { + public CultNetReplicatedDocumentChange( + string subscriptionId, + string changeKind, + string recordKey, + string schemaId, + object? document) + { + SubscriptionId = subscriptionId; + ChangeKind = changeKind; + RecordKey = recordKey; + SchemaId = schemaId; + Document = document; + } + + public string SubscriptionId { get; } + public string ChangeKind { get; } + public string RecordKey { get; } + public string SchemaId { get; } + public object? Document { get; } + } +} diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 82f777f5..42707754 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1362,6 +1362,63 @@ namespace GameCult.Networking.Tests Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey)); } + [Test] + public async Task DatabaseSubscriptionClient_ReplicatesInitialAndLiveTypedDocumentsOverRudp() + { + var sourceCache = new CultCache(); + var sourceDatabase = new CultNetDatabase(sourceCache); + const string recordKey = "tests:subscription-client:note"; + await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "initial" + }); + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-subscription-client-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + + var targetCache = new CultCache(); + var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) + .Register(CultNetDocumentBinding.ForDocument(targetCache.Registry)); + var transport = CultNetSchemaClients.CreateRudp("database-subscription-client"); + using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments); + transport.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2)); + + var initial = await AwaitWithTimeout( + client.SubscribeAsync("notes", recordKeys: new[] { recordKey }), + TimeSpan.FromSeconds(2)); + Assert.That(initial.OfType().Single().Text, Is.EqualTo("initial")); + Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.TypeOf()); + + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.Changed += change => changed.TrySetResult(change); + await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "live" + }); + var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); + + cancellation.Cancel(); + Assert.That(update.SubscriptionId, Is.EqualTo("notes")); + Assert.That(update.Document, Is.TypeOf()); + Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live")); + } + [Test] public async Task RudpCultNetSchemaServer_DeliversLargeSnapshotResponse() { From f1179d11dc334843b27aecbb73bc7329f183003a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 12:22:40 +0100 Subject: [PATCH 132/498] Release CultLib Unity package 1.0.4 --- .../Runtime/Plugins/ConcurrentCollections.dll | Bin 0 -> 13824 bytes .../Plugins/GameCult.Caching.MessagePack.dll | Bin 0 -> 25088 bytes .../Plugins/GameCult.Caching.MessagePack.pdb | Bin 0 -> 16112 bytes .../Runtime/Plugins/GameCult.Caching.dll | Bin 0 -> 78848 bytes .../Runtime/Plugins/GameCult.Caching.pdb | Bin 0 -> 28352 bytes .../Runtime/Plugins/GameCult.Logging.dll | Bin 0 -> 6656 bytes .../Runtime/Plugins/GameCult.Logging.pdb | Bin 0 -> 9480 bytes .../Runtime/Plugins/GameCult.Mesh.dll | Bin 0 -> 281600 bytes .../Runtime/Plugins/GameCult.Mesh.pdb | Bin 0 -> 95544 bytes .../Runtime/Plugins/GameCult.Networking.dll | Bin 0 -> 418816 bytes .../Runtime/Plugins/GameCult.Networking.pdb | Bin 0 -> 211608 bytes .../Plugins/Isopoh.Cryptography.Argon2.dll | Bin 0 -> 32256 bytes .../Plugins/Isopoh.Cryptography.Blake2b.dll | Bin 0 -> 27136 bytes .../Isopoh.Cryptography.SecureArray.dll | Bin 0 -> 26112 bytes .../Runtime/Plugins/LiteNetLib.dll | Bin 0 -> 123904 bytes .../Plugins/MessagePack.Annotations.dll | Bin 0 -> 18432 bytes .../Runtime/Plugins/MessagePack.dll | Bin 0 -> 369152 bytes .../Plugins/Microsoft.Bcl.AsyncInterfaces.dll | Bin 0 -> 19216 bytes .../Plugins/Microsoft.Bcl.TimeProvider.dll | Bin 0 -> 32416 bytes .../Plugins/Microsoft.NET.StringTools.dll | Bin 0 -> 31392 bytes .../Runtime/Plugins/R3.dll | Bin 0 -> 601600 bytes .../Plugins/System.Collections.Immutable.dll | Bin 0 -> 252680 bytes .../System.ComponentModel.Annotations.dll | Bin 0 -> 89992 bytes .../Runtime/Plugins/System.IO.Pipelines.dll | Bin 0 -> 84776 bytes .../System.Runtime.CompilerServices.Unsafe.dll | Bin 0 -> 18024 bytes .../Plugins/System.Text.Encodings.Web.dll | Bin 0 -> 79656 bytes .../Runtime/Plugins/System.Text.Json.dll | Bin 0 -> 716568 bytes .../Plugins/System.Threading.Channels.dll | Bin 0 -> 75952 bytes unity/org.gamecult.cultlib/package.json | 2 +- 29 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/ConcurrentCollections.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Argon2.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Blake2b.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.SecureArray.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/LiteNetLib.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.Annotations.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.AsyncInterfaces.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.Bcl.TimeProvider.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/Microsoft.NET.StringTools.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/R3.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Collections.Immutable.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.ComponentModel.Annotations.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.IO.Pipelines.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Runtime.CompilerServices.Unsafe.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Encodings.Web.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Text.Json.dll create mode 100644 unity/org.gamecult.cultlib/Runtime/Plugins/System.Threading.Channels.dll diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/ConcurrentCollections.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/ConcurrentCollections.dll new file mode 100644 index 0000000000000000000000000000000000000000..5ee53e7b9478561b7ee6d1e3deef4bf8bb39fc04 GIT binary patch literal 13824 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 literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll new file mode 100644 index 0000000000000000000000000000000000000000..e43ccc2ce1974e3a211ac97e2a4ece403b0f5175 GIT binary patch literal 78848 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 ZPxwozLNzk|0#hHP_W_Njbn(*E{{zGRZ(9HW literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll new file mode 100644 index 0000000000000000000000000000000000000000..bb04ffb2e0d147582a7c2720fc8db7c807d25bfc GIT binary patch literal 6656 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 gOtFQJ@g_vZ)6}Zr!?d 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

!MmV?xH^xT)DF``ewK zT->O{-?QAj`1=O^_*H|&_y-2NQJwiQ<9}oEZUH{R>LfAULy|iO#k;h} znVx@mJ`+oPiXFqMePW-5k+kug z_GR}an!!?QXK~&9+cRQGRaTZSGH6Z#5w$+QIsD6}KP>BRB z{`cz4FCl|u$2epvgo&97@nkB*lc`W(muL7v1AlFJEwb%bWd`n4+H1#6t4oXJizW9p zbVWa7d{FHfEfnx0rhCb6HOIw7FcZ#vFwjiSqTJ8tG(xLTFpFc(mkTjL z$b}ecD}3NCy}7H|NO%L1VY35$Ls2_yHs7SikZ# zdJ@I(X~Qs|^Be19Sz78a<6G^8S&S3o*FLR=qf$COCa}=DhE;wfeujV`{x(Iy?%xr} zS>U@0V}b7_B^8@WEIvWS3tIdH**(*o-9va_{4kGXj`sJ4kH(M4IgzH=fm-wTtyu*^ zW!3+vBK7k+A(M?D{IG(&WnOj^+HQuB#S&9x0nm^%_3#Otu2a!Gr*A0-wQUGgo= z=LTG7VdcyBK&ZhNXg1c7JY&ClRf%6yMy?nAiJ@+u<2u`CrOJ<$<&oa7IGNmQwi-lF zQ3j7IsE%s0>a6!nVr%l*(yWHn9dGosCDj+9Y@CfPTm4#|^Ha<7r&48rtJ2EcW#SDR zc=zz8x(%7q-j@TmVH6R`59#UO)!_canbeZOWx$9;709c{Rndt`)i2rq?F+^ z6fFLPO6Qgtn>UIce^S&^{3(HKoSK`CQ`c#nqTod4TG?|DRM!$ICd&y}PqW^YL8*Gp8FFAGdmm#m zqbz){7=8mdwno3Hlz5-c681{`aBO<%M*Tiy_4_RX8`iH_8d)@I`P&jcK`nnraUb*} zx;8fr9h%Jh_YiGPI>f{h%UhWKZo&!7g#Pe7Bqjd9@o3Uh$KO|`*h#U17RCjBpnO#_ z`GW;s%?G50{6R5j^+SO32eaOI5$lv<*63fh+ zzEJ5PbZ;;G6{vo<_}Aj0#;ox&Nm9{4S!p6GWx^<9Ji0P6jp-;1#+ z=^sQfr}zvGl}?cV2-Od-r{~go)z`bL_UF6b?ry8ur>D=Nkm|-^#vxxZhkTtt?XPNO z`3^@ce)MOa|Assjx>;WL>@o7hl9$Jleq#N^JUH1IGfCu>7BQz;z62*X=a`-cyaU@} z&c7g0?=RbX!GAMo?=KVX*89sqy{RnA-+&F1Q20+sZvD^Vsc>da)_)nZ7yq|GKmH$s z-SK*ZBQ83;(aBP1jI3o&)*Hmc1>fm4$)F#52D@Y5;E0P3Z*sB}3QIhkUYnrTJLNxq z7(nca$~;w!7N8uc)mZU~QsK>$3WayUn|qHmYZLBURLL7;v$OW$zDMQgJ6%m~^CqY7 zaomQT4q1;bjrI|WM!sfMlVFiuB zDoLVwd~>PIk=pa$uCZfWm!2KNo3W1lb_w_`!ZCft3#X9K3#am|PZMh1GdKmNTgCJU zOlf*sqI9#kQ0t=+R#p1ieeaDZPRrcK@N6M`7FTcxmEHY`C z6U$oh9KG0mdQ^g|1Z=Co-iRHOP20;DZvm3|CJI9xx(9=g7LR7 z`GsAjY8ZXg->eLA4T^U#M8WWE3s1h%8M#SvYv8gD8p3#-ixmg3EU8ukex1jw^Y~* zLqNC{k9dHGa+VCis-SFU2s-FYvmbc2bQzW!oWBJ04}+lm?laGJyD1ff3&3C1hicru zP-~%Mmb<=!+^vZ-xrQK5^PX#f=SZ&0pT@-iRH-lG5iRBsFX7=0OX&!o3I#TPWdnJebY61R__bBsI$KC?A6vDf{|hd${U5=&>V@cqbnGhBHsL-QEP~ z=evBKzGYXaNw;ukV;EcV-dIKV!Z-8GmtXDNm49lXx8Idt@(zPuN1hf_hcqf^0_n_k zR7DX#yHmjdvz#1#tifG=a|;9eWcg{FwEV^y$0sO~AKp!iaz67fGj(}xRv0+I<*B}H zd-6P&GSV;DxVaSd)i3O3ushz};D{rK_mCO=fI_)3PBK{&v@YE5v!18AcXcsoV4Q|zij;mx4HR5Gw7bSN)Zz{A zM_z-RU0Ab)qsj-B!u=uYz5nO{kfBySS*Q|ML$8G&D4|8Yl@Ong;?oWKal;Dl4!xqA zFb9GgXVOgQoB4p{x+;gM)>(}091^*C!`L?^J>K+t=u>GKJ-dH<5r8r!c-FI{w(mreb`1=!LZ79K^iN{;hriJ?p8JC{e6BRV)81ct{bOA{I$3pzO= zIhQjMFBkC&#M_7*+9V+QI4~&FOCjGisSNq8yaQ2+_@qht-pN|4^z3t)NxwF~@b-(H z&3Bo%zJ4P7{4~Z%0M;c%tw{fo27_ezNCgEJAGdCs&+Mr?K`h|UjxL0LwmpKW>Bc5!30@JD-# z$@y*%$ok;@I^SJ}^uAS!(c6OVH&iS2cdGo(f!+n$9!%Cp>uOZ^n$D6Aa#%^~O~SK? z1h8C84xy&^7=R3hv-ZNBeK?4Rq!MGc8c1l2J_hJjuD1&BhYlVb|3cfZGxX$h+3N-g`fWj zRi4?5mrp00E%8%>74++~sn3#M#+kD94V|y_Xt7VcaL6xhhPqRtwgM93;G9;Ip`Lg! z;Hx=^@muvJoT@OVymY5u((fjBc&Pp%VomjdxrMVcwcM}!>J#+2g0L_00PN8@XrN6J z3)B>lzj*qs&1Sj$rbS)RJvyrqw;!GB*eB#);rJ(vKW~h@^A&DLzo02}VN+~_CR}-<6cB0fWA8K)e1tr*hSLY=OY!&A{iX=kqV}fW7@-XJdF0h+sJAUT>!NQ zQS(sc(nw5i8i@ptX(SI*F^xu&wvqQFZR8Omt&Qxl$-mP^9)&%+ z1g+$47PwRaoKyinx=f+Jr7NiTeFRy(T`pkDBXU1_isUu7_euLi1aluS_R z1jR2XenIgI8o%>j>}`K(*6y;6@AGRkELqR4JxB4}m9uy?sgteK+1ZKk`$cst%1);F4)_K_O-kJ(3R=<@&2KGL7D<=Q^>k#0uE=6$4F zjMB7^bgLtcw~wUsjPZ|ANc%B7@W+K0X%t>~yJEvTcs71B(A^QH8vkF~w~-~?JV|S- z_mVF|5O3d9;ya1a`s^-)-SOQ9M;tl)2&G_srjYx-A-n0S(9wH%*d?>IAnSt<@T}h} z+A76_A60eR_`8R5{p_6H&SdDHgLo@?6Zv|2Fgl=c^A9!>Wd~|iqU5|vA}Cftqfc;L z<=Wl``P=y(JyH-4D!*L*Lkf?d|AHhxB~iX&6*T$;`ExYEVnm-UvqO0Lq9EQnNoR0^ zbQ9=3dmeKFI+-9|h>*PAixeJT@5M=eN+Ny5DrofoyZpCS{6wfeE=_{pV5`vTQ!flPmSs0YD^j%nGO{$Rr)na`prn$NN`&t!4o7fUC6#p z+nw+P4|$S%xmh~BOQZEhcCD7*kD)IN;=NR0pAXt7u(t(zU0@#z%y5CXSYW0L>}!Ep zF0h{kX1l=t7MSA#2UuXP3%r$ptfu?~m6f1!5mW|(k}W8)f|4jGVS*+nDIe!J_8X3= zFX@QmgIIQGnpPA1?dvvs`AA+Nyq}2pLp+!{^UQ4kOsl1-ub$XwDFISL@q$Lz_{V)D`}67#6BR$gBiY`4RA|y=*!J$z+1crb6<3TO6L9l@ z-Ll!iueA>_CDVhCh|EazQK0QhbXi%r*a9K?n5ZA;q1)!BqEvCev9T8#y1g># z)U?<8Bn0-#wqmbLXnsq`(~a^$B-;!gv(nZG`G0i^yJ5!VMV3jZt*{g_$!IS}g$u$}#-y4Gxbc!_9TA5Q0x zzn+x#O}cEH`p#CF?|f0z)D#&=$M zx|RUe+F5kk>UK9Qk z9#iu`NVG@-4)||Em`AcO-4tLjF^kQgwJx)zew_kSs#iMQf8K+71++~XuPqeB;GlHq@Yx-Ml+ zqjUW;gMjKWh|i*XXCz)OP>Lgf)E8AyWI>}(Fr9RBu47&Iovn*X^T`7JgZLcDc6~~& zU7aoJ__OSqqVk=1JyAoFq>dN_jXrDiTm_qb8V3n--f6*KB;T(MY-40#tanQxT|2{c zLG(WuO)ELadZ=Fhp{{~MiU)`5iR$4u{*El^O0OgzQp%`0FQDHlT&ce6LpHDV;vvn8 z{dj>*kM(3qIve}AnT;1xn}NM_9}FutQrPxO>BpG526cKZs*@%cBpgD+rx@w^0S!=d z?ZFlosJ10cimB7!b^U#PApPx278^8oIPZi`cxiVX2 z)uDO#25VLGiAj1_dc${Ia=>;DtVv6ffrRQVtKmITIjR5S`1jUV&iyah#>-tdX;(Hb}{9 zfG$v-=9Z;Hevz+->JA`nsy)x80IjEXzWn4!+8o!drb6?=X*}&MD8J#e;dx}ttk?@D z8_N`)Ev~N6LG`kM|JlX~yZAx;9_*5@lJ_c{Gx#M6=exj56GJMY45|czVidIa^Yqqq zcJ<$jw!(dMF}{;LC-NYtD_2SEFC8KponL$zIpkG+pTap+FIPD4Jy#eZ$8jYD=_pBp zk|3y*f=VE0d}h0n{gz(dPdE!4<0oBEQa|a=;T$72`^GE?_(sw0tDI)OF&k3DHx%Fp zWg8{-!cCxf;XIz>)j3`>#~9}F44iGPzLz=^W@Dh$_f~MlS5d^g5wBL*OyqX6S1U}v z^ZvvX;+ILpC#ZNqi@#IVe0es0L}rRe1@|Hu%CEDN67UYh1nyw56Ly_emWlo08dS*_ zH{Dwc;!dj<*|1ly|9{NwE)@mS^k4PymcP#o{+v0#bFMoqc3-=TFoE%py7MCLiVY_0%(2~3>Jy-W96a>%(XS1J(PB^_j z@wxo<368%lhh)!U%fc?Fxjlo7Wa@*t7h!`|_$Ev?*DhSJFUIiT zl4xJQSkTVdHDh?^b5>MH`57BBe!uXZREL~wezkXtP5A(*ezujP#Gtl-Q1bcKIYDi4 z7RRr3?9>R^53SUeHo{7IKvT+tvp7;7*$C+-2c^|qW4A<~WR9Dh#GL?Z`tb*u-#G*0 zRmJCrz_TvT>bRb`l=;-r$dgaG@$N}YL(GSgI(3e;MdC>Rj_dFXt@$6Sb^Ba%3Ja6F zcI@n0_Nmh-1Nwk)ce(lU9;Y}%Y-N?azrGmqD)Tb*luu|+RDbFCRkQtl2JcEdjYs4C zNw)#S_QvTl%~{&^j61|Y{{;2Cj5=M^{1D6_s;#7$-qzN5lB;e?$psZZu)zy65~F1W zL(~Q@%+O=YspWr~E9!6TszjObI>Dvr#TUH@Z1}#6SQLvKq_?xMcwxHLPkF~pjGOOXB)1Z8S0u=&KBO-;=t9%o~}4qeZ0gEIfX}1ZSfsc zAan0WDUu`H38AFV?Y{u2_A^z%g{m0l<987y+bWHqSOty#jF+AF+eMSUp=td&62o{U ze1>^;{UCYKtnd@y6HkN8k5-MEvPndWS^RhHF(~For!8FT@wiFVEJS+Cqd%iopKX(ZO`-r4{|9+J2-+3;5 zJD&Tvyo|Xso~xLUcQ>jbld$A!esYD=enZxcCC3%sd~14UQY^eg87S45GSn}jw0*A7 z(V2!@L85m$TxE&Qa=1zoMGjXvqH`RsqC|CvGykhZD;&;Lt`eQ^aHe~e=t74x>8nJ; z4yRF8qS)cI$4c~W;mJioF3?PG{>0_i8Fr(KU~zXH8tX~-&1pGk@{wJ+W`fG)5>(EX zpmMVWl`pu~$O0jMKF2Xq+BZD|kIQpc?8qG}-LIf;sKnb+7dm^|-k=|EXRtfo#^8vH z4xgv8ID=A16IBK|oiTBr)&3Gio;5}I@If_xuAx)&{6&4O>wTJgrI#D@=HFXAJ^R>| zAAdqZi}5D~c)=NgsH6G-VtPd@Pxd-F4k3w8Sh(f_7lQ= z*-r@Ttg~o)Za2v92aDe7p};=AJ<#++6Ct%y8T)A!`_Oz_{zoMEtw7hVItyED@7fhc zc|J9YArL;D5)6UxLP{_M!e>%~ArM|n35G!UY)UW$!sk+gArM|l35G!Ud`d6`!WUA4 zArQWp5)6UxrIcU@gfFKALm+%5B^UzXt0}<{^Wnz2I5}$XTB_e#ti&pdH-@I-c=HcG z;D|k9<>}Q}Pn;bqH*Y@k+d`~-ym>R1#gd=2NqkJ>^Jjp(w%6hH_SR`*mG~QCaW77N zv%$@_^daOvl-<2MqcL8`UlSWY-l0!;zAT)%MoT}tv=Hr*Zg7KA=trCB$p z_6zk#x*Q);o=Shy(Z1Ot;!-wqa8+VNQ7g&&CNm5DySlce-_g_>z~wzAFF!gMn{n<+ z^5m`5PEKRf{5$;+vo&YSvZ8U9V9`!#9-e+@ef&If^5*greaN%Diwz5u_6x0rEf+eu zyd+@8YRl}fQeEqJQP6r&Lm+%FB^UzX`zgT?2tP;(hCp~VB^UzXhbh63xf#qFz<;eI@$ z&*4b}t2MXHaiB%UQi_<5R0kS2uWT*Az?hD=KbF=w6Dx$C%^TF$R8%xjte}NCNBp|R zk^QBf`ie>Rp{nWXE5cV4a{w{A67*Ja{3bPm3D5M4f0AW+eWnN_mEP#lLn8F9`oFxJ)8V1*L$XauifXf=VMO;ewW~ zU!R!y7Gij9#zgBV@sEVV)9J0%b~Rtp9?Y$-e>;%6jNf5>q8BeS=*LW8=|hV#lUHCV zewRUZk(t3l=#Hsw;D}2Xe!+Ek3WW#DH#DaiBoFTKxAvK4B`4hnxp_8rKsza2WiOc9>~b^+}|v+rPSyDnYg z02TKofbb_Is&hB2(%V&k3J|h8T9r#xlHauTOB_L(E_{^)W|HGzmHGoPgX46SI`5rS zIAwlKuD~v=-H8qguTcIwRel`N%8w(e{J0?n*7JW^I!l@>-80RlTaM(7mX6h1B|Gz@ zbQ0Jo-PuZ>lzczbkO{eKHXN^?hSFuC*KVlo;LTNFy?#||L zI_tbkcKM9$v3N19PWJ%ta(xz;roxVk0!{u0i`hU`$@xGaN=fZG?XRN2-w5 zd0=+Ej~GPP5r}ZIK)bvm+#te@B19kL>1w2do=tVF4=eLq=N5ZsBX)jJdx_1O982v7 zgzaWRbeEV1R(pe0KY}}tqxx!{5FT*F8mIMOKnpM{=0?Wew*7bwL=0Xl+` z!BQL;4B|3SLxZ>l#V#m*LGcTUU(oomrPc`t$f*3e%>AB)ZMDi4%pmHx=E|ai^BX}n zwOSB?LahR&+G3)dq7@NPjcW>)h4_?}7zGtCXz|MTRF`c&-vb&MS-wX%f>YSsZL6K$ z2%|?k^3Y?lK&dv;&|``S=rPq`DV_#YzTy{D0zvT!iciq^q!)vn{WU7%hKB48c-gAQ zphmYeSc*e~ysKld91j2`M5zRoOi<|rl}=FU1eH$E(v54k%-^c;qRF5@DIOBw^k{*> zV!X9M-hMGyju#nhi5DAejh7g##M>Bbi?=n{9&cxGvdxVtdL`Z-f>cs=g33=&844;x zL1ieY3rdQz#+Qz_2WW7m3it)|@rTEPP)YdME43Q;EbU~wMGVd{&J0>#ElgtymVTrWw zS9sy}M1=?PaC;DA&!pmAVI}va0;PC20dn8nU@6|iU=Z(VupIAYuqECbC~cHlP{{?A zUQp=;m0nQk1(jaV(rf&V(l*x}?@wIn!nMqW4ux!;=>=+B)~8!vdQ~Hc-iKM*^C^C@ z^xfzJV7!k=#jBazOp4#)@KuZv@xI_vL2?KBdFuqY`b<$I#H~Am0`s84TC?rJ*siBi zjd?GH_~2+S+y~0>Z16~G`TfcuypSA+_zI_lmDdbTx%<+|(n`e|2h_+U~=wxGYhTYS7t z-??ydf#bcx2O4^NQzwOskrW=nqtvswwdC^P$4?*3%;yasf}9>+4PcADf>;H;aBtF( zehGlcVyUb4JZTzq$-T-7e>{tyuCAVCx@BJ995UN9GDMPjur^i0RdFIy`kM{B;R9h- zP|*CeMZbAhxVKRbl8m5QL;3G#%F~keCCedJ7kVpy>Ldu;LI`6LOkq7NHI%s2+ZFCd z1g|C|Tleebd-pZ+@J8f-R+E*WXX{q%%1gfb=!6v}SkqP>$`e)lfvf#d#PczB?!j4o zQdw4?ZGYv#i=0 z_QArZF8GmJs}$!8E)!=w=WWg}5T^R8?@XeBwcf}9X{of4$9{ulC*^&o{3t|L|}K(Z$FgWvW1z#i1=NoX!8QX}Ax#3G<&fID}4RWk*m+ej~c*6bPag zYN;4?^Gt3Pwbiarp2lB{dWncGqo_TL7PwFlRiK66qU2m6`6IlJa@BdXMx9@|P2QD3 zF+2`NeIxM-)fd0&y=5vl_&?mecbrs3_J@5tO!ovpCKE&$6l|FR1rz}lM1p`s10sqF zqKJa6Ge%|_2UI}Chzce|%yHE<=QZsbFsxxsjB!=e-E~*pHIDD|J#}yQof%Mn@8|vN z9q6h$r>ag>ojP?Y*G(7WRnectFgdF8>;oO`;^{69?HoS3eZoYE@9^OnPnTgr^K57) z(j5Wv3sNZ>j*m+n2&%3tMs_`X*#2HSM8Mk4{E!Uato;1C3~vLS^a^FatK(=|n9wUK z#-?cfero^b7fYW*@UU3=`Ka{9&kE|;wI%w*P072_Y9a6EDg(b?-VdjYw{DuYq&Orl zu4tPP79H{(O=)@LWtyVpr_UoFZJS(nPhiRcEz7r=JQ%SSm7wfv2+DINT%L^T1`Q+ZY93$n(*yOl6~I}Z*TIQi1MO{*R`NezF+6z(=ob;VXLbwhR#&ho2o1Xld*$* zQh$S#eX4e|=WZAuh6%hS0^1?Zy&C27+vjdZdAltR+D**byVzuhsgWf{qG9T9=VVDf z|F2}}^Jx81)Q%Ph3l}lKbo3dAQv3~~&vGHTmycuTo8rM+;^`p1Z3360^+&6E*^U#W z>cltOy@+@$t=fob(UI|lLdVjqJZjG*>J6oQe^7m7qldre#@eLrm}kG|wn)`_B1ZP& z^e5D-+QR_o@Wx6&r^O<9__J-6Ax=480p-5Dsh|y_pgrA5{JQrjwHDkNtfF;PrbNBm zE#)-FHHJm+i&0lXF!eT!S;Rm%%*Li18|BlQjccOy^I?hG9hb|oD9suy+_K8K3a4I= zvv5VV{Uo+-$b6QsGFhZ8%qVQM?rJ&3WM5^%7wt{`3;Ql#?sFAX$BM2#9HK=t;CM`_8g|3%7 z{jS=l>OJm#1O2-t*B|?G%^ZTA5}8PJ?z?tKxp3*nAXw8Y1e{w_8G=lDvtP1fjzhxA zQ2KasLsR&Bi>3hLlNN|Y%*0&VV6KLW`AEKxlVq<(`J_1dSe8G&tDY|3Hn`>HfwP4bFVv>D|u93W_-&fLCqU_aE9YcgaBcVHlLysq1K0-M9Xis+&gxI7+ah?ha6^36hZ0C zJ*m@ss8d!bo~E1`22^ZKM>7nZuwxkpRM_zhx*MBsa+}@Yf~7v7ploCdO8r@ELQbNd z#!mM-npeYw4ATsw$q?LVG8ZtK4Dzw_G7O2a%QH-Iup3Lco&Q-XERoB4v7>&<-8%yvn1 zQr#A2mY7*?W+yYdnz@ykRc3Bu=C)?`F|)7CGX6>D3}POyzwX6#FG0W8%iSrny&YeX ze%TN`0z{>cs0t9Bd_>Ox(b-2VX8dL}y7&k<;l5wj)kky<5L^0)&H?* z28iwjK4zONrqIXq%3_LqOjQ=s&c_^<#kBV^%tc-9x_9s~)Rd3e!pF?YVmcb8Q8Iw~ zv6im`#rq-!{ z5yK#2Ga8zMRg@Ug8Tm?7?$U_Gq(g+mXF!;DI23ybekPKsgF)Hj!u3bsN~Tygg`NU3 zJ;`E{sYA_8#T2IgE#}wH+qCC@@Jx|@HH+xVL71YW;!2$Ffa(q|0H0LUBfP9~r~Xdl z%qz48D9FnE6(juxy4QWIr8L1K!v6p)&12E)RXqESh*tDsm`8kGV$Qs#xD7~NVF|18 zpFnN{-6tH8M`>QPL3VRFs-$bQ+>ZyPe~KhMp_1cu@Hen3(tlHw{E~bfKPxOvmLyYe z;pjUoTA58SnRy$OpCCI8pORoxnq!9+BibOlxqMevIdiEcQ|~BFnU&2Ivt3h<(t>FB zW><_d#{nxvae+>674oLjrhM~twgR2W#|!LF`jBJN9tU&19=LZS;Xy8UF&(es2x8s! zDBZ?$oLIRwH9l7V9)7X<_c7}}P_T8e?z0Bu#kw!-Um5E@LarJ8+sC@^AlH!ovF?ZT ziRru#cPnGHUlwv}BYhBkQ7dSxn4QqzI76?##l35%GeWQZrd`ld`anYRua_?R)qMo9 z#*alYXzA+fq{rEFDUPN8j#o`8mi`2rE@LacTHR@2r$ohN=}*DDyb}dAJ(&7TG%BFD z5l7?af&z-eV(Bk{?Y(b(HHiEP&ic=Y)_)0{{)|dYZItmjMj!GZ{k;SB!`ALB&=+_` z>;FagUVSTi>)EicaM%4?h{BBUiPV>hKAt1c{{b{!r2ZjPkfGXW{nz+J>%WozKk-U2 zBk_|}nkWzN5ld6y(k{(!aRzDr7kAzNgy4faSc%kE5a4x~P?+x(#`0B^5kBeiH7T5U zOby%R^#_7%BCluh;SYSG^*@>UvqI6{!#Q?*%H>(%{X9404D$R7?jX;?2YL4La$zF4 zgFFlG=lO`Xnc&%!Am>p6ZX)MYYfL`hD7U$4y~{^`3wlu=0sPOW{1><`A-GZ4)c-_2 zRAX#pzy%h{3)A0;Vy?!Xi0qR7+y%tq^QZjzFSsvi2D?9}j}OiIrld2mlbuJ2s?RU)Pr#aaiS%^rTJD(n_t|UM;eU)ExzmICEKL>z19c|b z$1o#d9AchKN&@GtIVTGsiPW}YwtYO3zTE;d8wvt5QSzr%L3XK*$E|p&R+Yp~GhZ!z ziLF09CeX*AR|JJ(R<{UKv&k@hTZHM?Hq61Cq5;bp8qAazLBB*WoczVS8Sqkn;ZW%) z0Tyvy042Z%uX?=Z#6Uo&V;kjTy~3g$TZY+5Vc0oG(a9$ja|x;6rNMfCqRxUjCdXW8 z3en79vtt=f!~mCPhML{ryi;)qFbfpq=9B?k$k4fFK;o}f;j4%O*6qZ7?=Y6Jeq zGJBa_o|#~FgY!-mIQoJ@>jbKYXF{|lTAXNxlTb!$vf1UCDP}h~?^L0qC1?}!A)0>< zm3yki<-EVyocA}oJhQLa4bD4Ns#rTXxf)sFJLvlliggtcZ&?kM)yaUL`ifQpdWmI*ao-e$N~K|AiO)pBb(H zH!fxXn6_xy%PLvOH0vyh)N#bFNY`SD;dR*hFwybYeRnm+ixOYD_*z!OSC}PHUJFyH z`Ieio?V=5`n#=bK3auy*VN0m6j@5yLO3pRV=8VxYB7D^7B)nb8bLeF1WU#hh?RiP> z>VGf7)eLboS{&E+ zPO0>7F-uqiM*^!ia95nt$b%|vkR4iIP&_Ev`hulimm$SouH5zFTzZj1SS~Nr2q;Z< z(x-;77iF=HAuP-2{+%@nZ?T2pftU2+P^i`5(n~<3OUSIBuIeX!I!MP&7d@1x%`xg2 zW0WZ+Ext%8nQ}0#G{RP49HSEYAZ)-W&Igu?QI|*>>*Z0Jh&IR$=_O-S!t_#6(-;mU za@$q4Ra4|_yG{FUX}%hbrBUWYy37e>S;j=hYNj|pbp|=(h40@En@lYwDC;#lk~i|V znqM`JC;4^49sEh(yaw(ae*JL!ZBT*`OD!`JrteuB@YY)!Tn-X_Gmba!aPi_DW=N|` zw}^kzEyg~lTNDf3a<;676`0a3WzsG4cq~uhqYbh`oKqBtNTc&;9A@ascdDGIS!H9y zjVj9KJVH8UlS3y{=YvI^*qI^eo8{+a^^J1F;|4B(-W?bSFJ82=UI?-MZc}-M!!HT6`p+gzYz>ITB()<@N@ zxN+=pW!Bpkx8JT*FR|2(7L;}s_vqNYmrT}f0*S1}3H(04eTb+9*6j(v3qN?alDAtN zDlBieI#feQ1aEVw79uEOP6Thqg$Rn26Tv??gb1bYz{uQ*(K#!V3U4y0aC#%BUjDyB zI8``=^DbEpf5ensfTgU6gtxsTxtcGM-;)O#`m?iRBD$ZI;@pgq-cZe z=JGi~Q&2o8Jt|+Cg5#wtqtd29Jcd=&)UUKivTn(gWlTuQGA0^jCJ-*o;-b{!1b5xh zZ-h;z{zTALP?WX>h&n9=s13%`HON8OTFd7O7HE6|0`CnZ-Zn!F_{N5aPdk8W||G^YQKT2WVR2d z@n&crPVqEyZIa#a9;QaFY-Cq&c-tC*JT!8Z9cti;2PN&pwaYirs7dA*ZMe|lC=0$6 zSw191?F(uu3Y?6vE6!oug4EX7MNPB2`uBrtPiC)4da*%~Y#kNlSi9D+opXX`5Bu?k zSgk%HxlW_(!n$)Y7BX&xt5zZ_NpT{@6b(#m@}#PvD8QMb>}Qu zTKE2H{QVdt{GGt_4!C&S0W-v3jV8oijV6q{ZZx4-DC|#VHGGCCg}sfKdz~>#9w=;C zA>Jw$bi`Z#d*|bOw`G!Y@c)8I8t&zZPnI{!6kkow@hOLH#iu{Nm>uh>Bq2T(*!iqi zjZYi#Lcgq}cjy!=ir(2oB5@g|H!)2jIKFX)3JAOQ`~)laa{mL>;FmEbt%M| zDL`(LAyJ-#kC-1CBr6N7eEdsR!&jJE`RE1HoAzM?P~oEuvOmR(qP`Zr$wSolh!Rnvf_oLO+dqt$xP z*A5jrSX}>&L*)(yu%rMgTK}z?@@8Uf=KnBlb3eQtFP2G8#j=o`Dp#=VJ6R3iV~S;r z)2lad%A3r|1Fp#m$*E#N$8-Qv{sW*ZM>soW{wV=wk@$yq>f35vH%GM0Fy{DC^jvPx zy4xx@e}`pE`0$Y8l^s&NmK~>fnQ-G*bF|A&Mfzw2>{oQX2F9CDwTW%t-;<=uSJc$Y ztyvkl2BDhE(coQOK~VqJ(t4-?ZaRqH>RPU4@rIH2F~44v-yZ|3mO^bX=533Nv4Vn( zG1FpwX9)e2mQz-#2J0w5A~OjOuE?3WCuYX2bvwjZA>6XJLyQyRRi>~(l<`6^&CMeA z5<=_ZE=0Y^wUS|xsuxx8VL_e%9+WNGyGfnpt6u4W4-2@0x&)dNRBNK{XNsOew5wCw zVN|4l0k&mOZjzW}N3i{6Q1JrEo+7JZ45lQzk7SAMT4YHcNVcrza+X%yY)7$>>=<;~ zA=#4w?OXHl8uqiSNGj@Y%9W;fmI0)Bs%W?zAkDP<>ZW?`CW4hH%MOXMr5}j0Qnyz} zG{>?9QKMPrsh3l}0>{UluWL?w6Zl+Pp)GShJl-dSGt`{C4TT19nUDj&Zgi;e9PYL_#C_$~BvtbC?Ap$==f3b%>Sbth_Au{Am7Z-{$OEY$%5n_UicXN@fL460^8-_N~H z#H!9`v-JR0BP!XUI=8$BYk~XYm)?SuhCI;x%P(}5 z854B3Amb9w&m2ZheHQSZ()MD(6)>LrNqDfZ*&sV4>c)b66ZO($c62zcj~hbQAF6^g zdVbLmow?j)i147f*l!GESv=cCi<{jGg28yxZ&KQ$T4{ByJrvuQjqCz-_WD<CHb+&?nP)vp>`&L z_Q!$!>QdYiXgnNDb_2Rk_&{B?yyu3!IqrtQCpa3{Pku+W@>?xG!kA6^I`6_1*L#z8 z97|uLvGQ{mvGi4#wU3o@mgPl*ua=p*4wJoKn?cl7P)S)@0nO}b0MU+lJp$S-qH;Nb ze5Dap{|Xy-GGpE+NbXyvPmaTvzG4!6#qlQ2bs*^Acgh}X<3%x?6o`)#w zYI3Jp#7m?r$!6vRKo%C}4PH)$xS$~4PI#b>L_0s<@>kaxR9zR0bbkb(BE2O}8zgkY zp-yKjY`?QnCK)80D663sQ-g#7YJyBTNJAbB5@a=(Hv~#Zv7plfI=%gT^kns8=#jii z(Zo?l?NKOq+M}>sR^j_D=ST&(%=(3UTm^OIlr!}(MqL$j>w03O2U-EP#%YRl8#$y% zd*Sd;w^|8Mq$i2AD*}p?<$>y^L-{ZkSZPt&ypPeM0C}n2kf8v^W+kr~kPS18s1aje zu`Qw83@C?ArVwgdxA6Bz4xmcb@%xcq>-~{CVdee&LhVDWj-|G9G$c1K;qT<;WW0R2 znF*55YJos*9?9LZtgm;du-ph0$c@|~xoH4IZVswuXI}0=Xhhh_On!Q&-xL+)3sK3) zYB&W`qB2OLQeoMY2cjY?Br1xP6BSF{M5Q()D#A)s%(u!H6#=g7Q7djbD<2^^%}@&o z|5X98d?O%>M9^g%0U7KIh_Dh6*&zY3Y-a_;_}M~0jHnT7As{()ARxj1ndIYQejJ@` zBOjN+KU7m#KE&UUeB^m^(XlK`F-|!ZFXlK)AjCJt09wh$5{F6@=X8otDc)L^PLVr( z2k!gMS%uopoCX}A?*+YRJ_uWMl-g}hu!fqb8=wW5M){{oa6F_(~Z>UxaKQpL1_@g*-sA7XV(FX7sL zjU1ZI_r>9lroxl(1@wEyrB|KbRc|^hen>Vo6AoR^nl_)X?rNC$hB+Kp*JX1 z*E&ae$^)D6CdgGfbpZcNu{Bls2iPUyPb7Y#!wf2hUGk!S!nEbz_ z!L6?<#Wl&laj(jke*rEP>&(#cR&%#QzxIw2ze~&ak5LxY{smOspEYQAk%~B{ZzE@8 zO_{4^;|v36>eN4{-^MqY>W^n`-}iiKS4-w}pl#%K8)Q;@!aWG%&&|d9SZaVs^sC!V zo%eDu*?yl6_FW2~4wsFsM4!T#{~yHPgs63PEq9}ijiuiLmFmIKH=Duq8NWrOHjamV zhA1^qiS*=ApD63ieU9)Q787?2q>H8X%$Ud7Xzv|sKP$-)?dLoYY*+%c>XR>VsEaFe z?MJ9oH}c^ku(=7snW-|+bvu%mx}7l6yO^*J!l|BiXKYRH24nk&WRyJ9yH&CpF2dCG zZdYj|PJau_+)h?=`I&)~Dhfn~LWJH*X=it?jhc~R=Xw_cxQ;4^PNsGR>)xr>Xzht- zvukxqJeH!8)Q^crocQ;7v>GpXr0GSp{xX18I_7eR3iC)PpGS%itv7_Yp;N5e1z8@=hY1-e&!nR zXi0f5T7zMFS1C0oCQ0w^PM}_m@<|Pc5$O?%bS)ry7wxS@t}+_t;3*i;kxzOLg}DwF zU&B$v(+J!?S<1kchqjh<}Uc{ld(cDITeMMxWTjg~`$t}!@# zeXDY6_IkbShP9ZQy^bK_29z$u<)Lm>c60gpfg~#)l*U47=oJz!zTg8Ya|1>)g|7@p5Qme=nB@&0Ad_@-sIQ-p>Qm$32vXdrfZTq3NUS=JJ&-@{pgJ zAd+kWD(T{6nmX1+CqY<_ea24H)@wuBT3Bgo^R4o=wE$NZU63l&RLb&K=Bn6OR9u*% zQhmkS#y3AT8P7h!cws(8(6r%we&O+C2k<|D%WtW7RRyus6j5+(`EkN+FqymwFJzMS zMr5*Ph7-wTie4IK-wHN;INeqIWI<|IP!;K^5V88+TMl)P``}=-t;=g7rjGVD*$p>i zs-qnV({-{@SoN~9o6G6a-I|3WL1tgbvDDW|$1XZzPRxpl$PD=hgVq5WG7EPvk(hxg+=I+Eu%kd*X!HM3(r8d~Q(1~8*vv()B4WL*tqe`ac zDpQYw$!03VxRA3opSWx>jePNO6XH5P>9WO>MjLj!%N7pIUACBR9>`=@-rJju>^50B zo+1{cp2gx`sGZ8Je+B?unR_vTPo6l`Ryp-q{})g#PG^Q538~1ufG@g5@fJOXVR}`C zZB87SWl*DhTD`iGK7fp7*5iSO)jIiL4SqOP4<R2I?)Irnq8-ONHwwuSWT#M$zsZfrA}^ z=fS2A!JRsi=oM)s&nfX)a!85K#^Eb*F<(mjMcEBcV@ipSmIA$ll5>dwl$5ya=JG3o z=B#*7nj?~ASDB{Qm?}}^LoyCpN+!>pH{Df)|9d8% z+@q0vzKTVEKJtFCrS52IA@iE$^A*e%Qkc6J%?({V5#-z(9S)V|$V&DR_^ms0f5SqY z3a3OOxIkD>PY`bo%df&4nVL!fw2a;JrrWY7l171Km%YDJAw zOg9b(Ez=Mez_+yD$P96w5PqXE#Dzkr<)k^hQ%%8)vRIjhJIH_)kr#Y|R;6KywJHru z{8pu5i7R4$=5Ivxi^#BKoQmj{?c||hiR@6TvLfbNt7_LY+^wOhq-r6u(^YCAZfId? zlsOqJv)XkjF~Xp_{9Ulk1@cHflGX4Qre+iz!m4hX6|EPR8HKEnq$vtB3MXmr0=T`X z{j^-=UrjKoMvj?9ZZ9f_4)&sgvCFF;WakyiL}>ph?i~d0J}}v}%t4i)D*!1DIF{iY8IX{Wlv`%TjEw%?=?S7^UU)HQJXSa!qvm>Rh4C9!1cg0h!~ z#%{7h`%Q`mCG9sw>y=`^uC*6h1$_k2x`JE~zu%D(uofaEU@gS$5xT4-GJl7FpB0qA z1XT-TNMWS`WQUrEWJ)sC#okn{HEz^lrjN()51NTjEl!v?AruYPO;3>3a3ZGGO(&|f z+=>yFMN?U!<0XoMMC*7-Y7!yU723%qqh&-mnR7Cs+;l&O&QF~Jmc|{(-l7#**;CrF z)I#U4cKbNx{K(1~qga$Pf<-w;Bvaf?3W_C(YI%OnO$8K35>*euXoCkNiJGi}EntZj zti(xnsAP%+o2+DOSv_Jn#kU?n)@2CTE4>IaJ4dh-j52eCQ}J=7&7t#CjbQydKrQA7 z%1$h`SiWvPq|-bfTUoieqq1@bM`b;VcE-II8US}J3_cVqp#sF-3NWDT?Par9Fu%y@K@Vn3*#$n2uTwSYQN7EyeIR z;oJ7Iu7SCHBr?ksTQ@%n)yqy*%`l@8Rt-#cNby-Ff-Y7mwc|jK-6}D14MGJ6dPI^Y zmpjn2=^**;xMB8gn&z%Ln!|B`@Od6lYjK|E1N=Gf#9DVVUepzLhp4O3Y%zL=@#8$1 zN3(#TKjYE5&VC$}x?OghMAc17$ z&U#5U&Y?~(Zz&t1=gY=0K9O*a@*U5di^a6HUZ8A)T{}O5vC1bVJBW*9XA~A=k%ysA zQ~xXEll9wxYhO_sFQC=c;ASaMW_@4GzNN|fUfAg!G4$Z|Bc;Bee^z-h@+xWJN~w*%-o1tws5@OXjDx1IYBjXxf4?A2L*v zC~!|)Q_1J6xD}ZTu-LyF?=qI34yk}mog~ycjS4TZzEBhzMgmE!_mx;P+D6LcA+eSn zs%ynVtnCOl9aDj2p~Tl~6|P*HVwV`xH{WrzI#NmW3S(BAQWhs`4o{}`CfQ#( zrRbj~+f0d?PX0Z`iPnpooW(9LI6<2gz4}HA7N%B$OJ9WHbDNIVbe{3g_=cX#jq!5m zV2n3{TdRni>9cdObOx5jS*ivl=0c(Cb4Es{-aCo%??Wqjm7aF6e z2K{m45y8uXnTwV0)FqgiRTzBmgKI~wESJh+5mG_yO}8CM=TmDJLiy)#m62olY2lym z85bx~nlV>oB5~mPlnNH4&?vZYem*E|qiV4LCn2P&P}~h>jJ`&(ip4y@4)E z99guq^LsTXuO(>f#5?_-#6^$9uP1RiE2LqR^*&wduESb5z=12)qjBs#$DLsxV+i^y z@wR*OK#pIjLab^A7SV#OrV6W*6?KPDk-BDoD0W7Cwnf0}ap;@HF|UYv+Gw=)P)NR{ zg2gyxa-gGn0f)~uw;i6j!HA-3FmeN=qYb+QX?V7uG{DW|mxXUUG#HT`>ev*o>P@($ugtPaEYNXfsWI|4RoAX*J2YDn4W0N-wYfHCIkcPwV+k9+ z=g>hf5!q60>|$^5de3%h^qS&J8?xhCmCf%X`qj}DBpNGt&oR5KSrO9% zZGBw7!P^Ml=f?LN;Rk%b2@AdtPo{3hRgu00(^kW8#i9A;ZP@;vo}!>DZR#(H3`cQUsiKDXOG;PxS~MhPOL?DVfark?N6k-y9GB2o0<-sIshX(1+cU%5vBn5 z5XKFPbLjlkyo$fnHlJDZj?j4Eo# zUw#jAH7u6C(ENJh#~W;FCmPQ)UZTyh%B}ihahv+;O+UF`vi=W}zWO`Nyc0A1IO7Qa z3Z5Fw)xo)G|9+^tOwmz;`4icSHn1FT4Q2+V+fYDaeBK)mH5l2UD|m`WgSi%x?JC%4 zV>5#Bb&J-bJ%PfSybZU**kt_^#P3VMyFjL&1gP%f15W`Y>z~HVPd|gngLCbZ`57S? zL?~WU$JTABfPsiMUWoE%1sFhpzMITNMTR|yO;C6@==6*OR`M2|UJmVddO@G3^_yw@ zc#thFaILc#ms$D+9N;{ZnB_pJOv@mbT^Z2S#@U+efxn^-4u@$So}8&7|YSkaJB z--*VWuxja2do(M%*>v4G!rqi~8@ok$|)x z9+}XZ-C5xI&KUsnsHab*#7}3Fhr;QlqlUlG=(6SUSNo$k&K|bv3P$}&!7o-{H za6W4s(QNl&yc}95*-M1nI)c(yyBjRKVJA#=RIH2Ha*(iUbh1O6xQYas36RMy7Z@QU zKtJ;WK29^{(D|wLV1u&|9lZOV&gw-a8%wn5?)n!SW3Pbk)pxtzEKTF8>EP@%gTTUy9g2gCUIq3V;5r_uU^l)P z9x8C^E_mpl{MA5TH@=LG{>fiyEv2r3{;RSZUc=NtpQ&qg(;;kJ0Be*lJ9P3_k-Dp zvQTmAw5BKT+DvSm1+S7%BS z{$n$mvb{#3O7km93ySmEuCS4tc3)KB;$-G^Y`;S(U~rc224}AP?m3p?gnNa>Pd}9= z0rvt67^Unm_WBAKy|dyr%L{xc`|1ktYUOkZ;$$zbaOUE4lRvS)IIkEXP<6EB`!S@s z+2il-CV?@&m{0w|7GExy7#$+HxYYESJahtHcBmbgV7L?T?V1jz^ z40{!P`VFMF_q(NbjNUS20@~sjeGZ-PKd<5ied(zPWLNTf8FskOybHb zbto+5-8@0=-7z|Vw*lC$G4owBH^_SyLHmrS-xNS@T0dV$K7;fbKV;K?kKjuGeY49m zADG?X(5cxjeL>lG&prf*)_)|_VSbt)3)n=OYg&J;xSrk5Sk3+4>zQLCabh-<*t}Hc&Y;A4=;XhdhIrQ3R zvKlsGO0OMGH2)}*JkV>hLURb^%AZ5%0iA09%Jj6}-GZwuNA|T#F-g75oUkk9hS=1V z@~6zFtDkAEtz1Bf{EH~o)o1%zCF^oYKWi&}?oqNVIy-MOf$iY^SdhJEuCj&JdJ`T; zhkPD?ju$-s0t+5gbhu-`cM!5)$VPal6y7U|4(_B;pr8^2Mv>&0dwC1C{HjhUOp24W2 z!SU@M%oNXf1YMbCp*EfqLu@?fmTi=R7;>!ah9fb>5MIQn-f)2>ArJ9HcBm^;JSb^M zr)Aq?09|&%uNW$O>BCtF=c6xj&Lgz{l%aMEjs~<>ljh^#kdVeSEpVk!@IBN}9f#(b83fxnOMxS<@>y-XFf<2JE9P-Dc)9J9(? z4lM~@rfEh9+->5`1 z$<1K;)zN<^8W)DW`|L_7%#ws1+lq4VLz!=t&O@t5de!=lSDo8Om8jiNp;qSFjm`vGyHRiIw;Oe$u25WU zfq-8q&IBH(LR~_ou2#Z7mTY!|%!sj{gJ$WB+O4Y)IbmPVPAF*dI7 zrEQ4oESpz|_&F<5k;HQslx5D5($Gp<|AUhx#ZJ z$-Gw2YGgB@XEl_;7SC#IDkp05zmk)aDgW=wNpW5&eWOct_hqofIemfGm{|gEV`h_h zw;bk@F38+QUVSDoWyxSf(X4zWxe^f=-V;0`tB%*&fOS z3E3Wu#dz+3r2i?5A;3xdAVysU>aVU6BaO7P`opLAOfhxF){W3E*#7RZh;d4p&-5_` z-Wforl#vm8YEf8DDa&due=-`b8ciTMfzo4^s!;7-+ibYvRCF{G9YMRdpZg#ESoqJV0^loG7rD%PB6 zy|5@Bcg z-!9gh4cec;i*gRfqMYNC^;ODw{qDf_J(XWS<*V3~wx{B_uX#|~g(@vi?+Ht3Wra>! zC<>+35yEWYjEu!^QMepBC|t1jQnxr;hr^)Prflp}QMNI7QI^qIu55eaO1ckk)SG;M zNiJ-0W)mlv6`QhcZDkt;pqgy(%O)&klNBnPqO>lXk+H9Vv?yB+{VQcts-d#Q?VJ_L zjIvF@i?WT!qHI%=^|`V|>-Pd%k;+&RHSXNZMFG*E3G!V`YHu8Xn&2XpaB8$(Sel@$ zkgO_-e^DT4fkwrmw`hSmwBG`Qx;uGkw(gW>sQrb@Hw7=sHwmk4`6h#{{O$6I2IcEz z<(mkg^0DP$mBT>6-nZ z7b;s^-#&y1YLD76qAc1#qHNQWwn3D%4WeYdHi(k-*$tv!*QugJIr%m%<+L3r%6Xb! zPGKphtWY^^yQ-B=H8K{zMLBcm*5y3)oNOH`)lfNI9kTFAIkhuIIki#c>QEa|$=pWN zua`}+DO*1)o9%8 z5Hr1&u+`Q1vD8pO>jZTJVpHGgiCu)gR%o94dJk+t<@ELi$pP2KdZl*7Pn+wN2c&kx znV(&URi5+omcIS9;X(I8V?wVWBR4|caUqMkxh?9}wy4{)DA;!gKx!EI$P!_)+?wv) zA=FY=2lpCkxJ6v%P;8G7ad%LueDt)J*bgQKPDCCS*$a{nw4btb|NJYO-$Q?lX92_O&144EM=`(S(kPivjLY9Vq z#%wweC+29I5GRwT{V0R}+Q?Ivmf~~?DP$YfY5*mqa7WIBx)Q4?RA7@cPWq)EA*J2!R+4uAEelKr7Bf8qm zjYKLKoxlv(TZ4N9KOL9kNoL;mZ^g9@L~SF=sIIo6%pQ#slcZM*dyM)}s8W zkS(fb_*9nIf31f8+on=FwXBrCLDLCuz{s}@<1lD#cti;@j^?AMBA?eh)trN1^sY_0F7j04hXp8z+2z91Y%>Opj z7&J>(?SCB#<*TD_4JC!%D|MEO_&-DKX(&I`_t{VZ?|(E*i1%|*;mP02`=5y|8Sc7S zy?;3DTFCD_e%JDQfZvP!1~*}}s;JMQHu#0M_%DUOyDk2o!e7xA|CR8kJG{@tu~N+c z0pF(Cd?TcK0G~ys*Z(V|dH|o5;}rcnAzSwVKM2`cxPB6{g>VgT`mG*7dX2jr_SaUv zidwdzW)f&Mo&YGO@`@S{_mB!3ZhHqb~c$Sf?=)48j`sTKMe_P;P)`USNL(&&l}9XTWh)d1#S-h zxGkPzx0{s5nDCpB$2{RTA&*JneR&M4+yd}zK3n5#} zW3iB}<&hJmt>y8F-y@Gx;EDd)$fFzM_eyaIQ-get!y;&ttlE9lU$dwn&}&(Y-7&ic zx&^8Q=fulO^UCa^ zNp(YUlBGR2+9<*9$xnm1QT(d+X)!2hS3Rx;+`f893%E31oob25tFCN`$TGM%Svj3c zVaeq121XG1YzylgZ(SG47Vtd3INu)`x0+}DUY=KH8I|oSYucjvwng>Jq5__Clftis z|Nrs44`urmp8w_ujxF3)5_)_VV{KdGx1#@pd~&>Q$XtJbu#dQJ-_wyU1e0&ZVDz6DH$v})>k zt($tHbyH7fD<+?cp)n_i&kZ$o-qA0loS5hl-@kGYn0udO+N(~h2W`c;kmFe=` z(#-YyzBQ-NYd_=o!xG-i>7v|s#anX{-p2j^cT&Q;msAqogac$=bD;43m&-hPx?&w$ zH*R*Jmo7PY8F{Fz?N;cut37yGq4!f-_~hv_uU;^2cEUTMqhkGIi)WU0@Qyfb@az(A zucZ|Smv~)HlleI&^o!q`Q{pW+UYNo7CcG~WSFGle+HNJ@EDOK&`4tCOcoWwAIJ3g* zxZjU66JD1Jg_&ObOk;^xFW%jw9_VHd7@Zh22By2>sE=*Unl=KvxC=jKjpl3nsWG$I;E9=*fWg@ud!oo zH*#{$h09C4^2_AA4ohSD}=P2$=%Vw{r@Wyvksk@B(ab}5E*-@!{G*)4*fj^NUCFdwsvx4K06^g4=%r%qCNpChe=_v(U? z^;>y&%osO&D{sF8$IY(rewiW4pG}w9??8olxXZH36W-?wMy)RKCY`QS-m?0d)J-`& zYk!%y9a(YkX^}Ti(EZJF=y&m6BE=G~FYXfWE;DaCMsd?MD(}tI`&QmAq?_=zJK=yO z32(N^{KT~KUv6!o&qRgnGey43tt@@a{Ipoy_^(O(t>*q>pKYkk^1~IkqExZ!khFx? z!Q4li)J?TKtTgj=%!K!u#hr7oLO!`b=7-c>iFe+9%JX+8$o#`TGLHe1@J^fcYS)DK z`W0p#rSLnOEInZEPcIdwdP=(mv>n5IVdm!+=1U~4gV%lDV9GmvoI)ODc^h-SQe1n6 zQW-l@sT{O&_KJjeoSEZ@TjKRPO!ylwzi>HnxUbAfX7*gHkk=kn+pUW?Yq9G0$+Rf% zb(C_raK6mdQ(x^`=;a-)n(cX|YV*Ry&nzwRex|+(z17Ey|FbE3o!5v`OL*sYRQ}&F z&h(olYR$*U96tZEBk?uL@6VAu{AcE<)d}y^sq%e&skJz3O)*+`!aLN;{>J{w|I^DQ z4j<~J4%B&g7! zYki0*De=mY?U``5y+Je44KwKvbTX==w+|Y2qL%DByR7>wjA9}PwA&&u4>KThy=R>DlAiop6znS0rmY0*gcMY0rTv|k*u6Pd{wA^d9 z6h1gf@m6}3QTf$diB|F&sGxas<#(C4Lv%6Ft(N*V-f;6P9WTEdf#i2by`Ve23Fi0k zAr6{k(5gB?cYD(fdd%wKe(xZI`kW`fNBEKh`-k53oOVIlwOU2M=km&or8?<#}VR0w+8yUw6f z4SLtR)u8JvUXypHLEkM<$oIYb4C-`@ppU)B3_8-{eZp7Z#j^!7|oK%-sX3#`ISUw0D0ceW5w(8$YJKUy`|U9hc+0r zb>t}XySbZE*ep|&0pxjiT6%*b7ntAE z7H_x@jkJ&>Bdg5sF9wZ{tTE_LE8nEZjWKDPFO7p!Bexo~#}uV^P~=VvIo(1Y9QmU` zKN^<~i~Px;q2_mZ1ojG=68Oik3q>kqIq#-fI&}N`Bq0p z8#Leu`CS>AY|tx4Wo=}dLDyT(u8$mQ&<&3MXfB(f>;wD}D(el|s(Gidd>qVj&^WrKdQmil4j zb%VNIB)`8$-U0HwL#<7J8TrPbmoJgj{o99bq6K^(`N4;-Tp_60ppM=praz-m#p~#u zW97?_{%BC0L9EN>$*cL`NGm!`jil(Y^B;q4JDr=;_ge@$RvWa~p!(=#2DN7&dk3!}dWAv%F~3Z7jX`f2v?#hZF9wybIrzT8Ym8n) z>K(n8EH5jf*X6~kI(o;jD?ZUX$A^Akuz-|DZ#Tae86HgZRz>eLDp7+ji{4`pLrMIu zj6Pscs-2*9(MJthXV8t&Ck=Yjpqrx47&M##$VBg!=(7fWRxarF=nDo#nD0*X?ufo< zP*+RgkI|P6I?m$V6MfyFTBCVi^i6|!>5_Q&N8dJR2RJ;@dm#F*K_|1eNE?eb8N_uJ zpogO$7<4P6pNZb1(T@%K(xAtqpBVI@rT3@kX9j(4(38G@BG-1#LOYehd zfkD%$zYX5sqeThT=Gm6|m(lhHRav}$_|QY<_fH_vtjkxtfB8^%qw-C33-f!8df(tR zbNXB%*Oh(L`HhZM8T5>$w|8u7gTAr!4vh6m#BJV>=r3pq1~q*dT)*M(Q?rC&vaGw93*uH8wOvWqEAZP{@_Bu?8J$`CJ_v z9}0P8Y=Zf1v=Utvn{3cI2Hg~!8VY%PY@ZNxM{Jsf+|lIokFgmB-E7c3v6-Qe55x{J zKOR#Y)3KYZvWi}#<{Li1~9em}$-5=C%+?gDW)8ee37cmc)Z ziw#-^IFaL1rx{dd@k-)nhe}itKPLoril1*GU$LBZiLW%jIM76ItN6v1&*>)fJ>sh^ zq^tKTA9CF78NbZ@7Fw>ij$dJrQL%6@$1Zw1FodkH-2NtZ~OSo z=J%v&jsEf5LVg3{e=xt7jPpCj?+W=1iQjF07h7A|C4QelR3BU#5r4p-jJ5Z%@rMi= zVf8RE{+L00s(`#qi9c!3MB~do@uv-1Y0&iepAG6@&>`_>4T_tT&y7DH%1d2*z4>B>sWqI#W+r3mg`$PQOg!;|Sv&0ucYWIRkUPQFvkdIk=xNnvV{@Pd1#{=BG``P`o`ieC=-V(0c2?@|gNb;v!9=`HBaesve+DSsw57F+rT+bHr+~R) zn9Pm4%e-~WV$8bTmtwB#KcGb!tGrvuf0g&Cng85g?k#uwe(Nf4GNrEa&NlpaJ6CL3 z<(;vI%z~X1rv0L`Monw%xoT2$TH~{`WgdNw%;jTcel<~M)v3zo4C6-s0m}K9!7}gN zQRc_PR+66wYF1&6Fy8(QW$I$2_%_^PeQ5l5c!xRD@_*M33K^z3$?A9bFvWHB9nGG@ zuL6@x3t1X{ExheH{6_Nc^11C*%5CRaG9O$Zv-b*_uWTu^ipUBRp~ZbZj*-j*-GSc>+?mc_TMts*)3$xe2E070Ks4@ywMxo04 zyyo9vN{0RyKF_^Ey!zsDnO}F6`Kg%=$4i!+)Hw+}V}ztGEP?$fMV0ro$>AA$$n7NX zv86vk|Byv_ql26rfBY7stGv$+c?EpubeWG2TB~co?+xa@0 zj}N{9OgP2Ky+xtR;=yoW>Pl(0gWxcIk+BxE# zZ+dmju9Bs)(JI%N!P0Cy?JPb2Q-7JS*0e{%%riZczTg?Foef>8yqEV+p|xflJs)$s z^a{+~%>3YDX|u^!iDq?;%ytLMlw{H)93lNvIjS=g?6rf(LV3`cdr%jD+IIrx+AXJJ zHY_;|^Y(ogV4l6@DOpMQOP_PQPiwrfTIS#ynQxnWM>Fp*^D#`-zD%dd4>A6{Gg4*w z*34N0B`1z2BUh~?9S-y#AoCL|ML6B=Mt|R3&&1ccpD_0&y9v|9 z+-D4zyJm#U7tMX)?s9k7L*@ed(<*Pu&Z_tKhCJ7!%KK=l;l9nrVm8U+7 zS~GJYy>^v1-Smm0|KKQL<`2IgI!=#mq(%I`FkNg6aGCXI;t#-N-OJ4peXQ>!U&B{34b8q2-;6tGqtvKMhZ2t=S6qq_dvG-7s5P?#FpAk=yrs zDL+n+NqQq*$FW;+d*sg1oiW3sR*fgCJdKosk>n4f-h$emBbqSx8g=M4K_7keAnUJ3 zDWq!=qRD6znt_}@U6ZFGYW|p5I_XnqblGT%!UhxH(k<(=EVCK&U$z8RV z%x{m8dErEvuP*u`8|(A!z5-J*U1kYpkk+#+z6EpGY?=A{d`tfKHO=E%%{DtpmhNP% zTji-vDWkO!haWmzzH6XUN%~oopvoHE&apB13)Zss-$}7v zvobzVqqe;780FScdvMPl_%^0fm=1sUz`nR2+;ae?OZO^^yU|h^xSOc$Yv$W#mJW3E zWqOkaNcPSgiu=5VVPhiRmM5t7?RkmJHPtfrpU@3_JR@^kGZ!wB``wE~!KLWZN-Q1) z=HSdE%;AmGG1cz}ve9(x>@m|C*PFTS7`dOkbT(lY+4yiE8lcKM*XAsDnfbbzu6`$5 zOMiH0g�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 M3w9T!x{5*af4GD#RsaA1 literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll new file mode 100644 index 0000000000000000000000000000000000000000..6a459bc4610e444065c01699d07d504c520167c0 GIT binary patch literal 418816 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

L;indpq6SmZuZ(Q2l(HxlwkK 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­a0!n7t!eQ0vKK* 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 literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb new file mode 100644 index 0000000000000000000000000000000000000000..bc74a83c6f8b5d8794e52c48f91adde18b8babfd GIT binary patch literal 211608 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 S@HXc$@MNq+oC**C`TqbmDt=c0 literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Argon2.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Argon2.dll new file mode 100644 index 0000000000000000000000000000000000000000..ac61f4a3c96a0bba3e98206905b43c4ab1368c3b GIT binary patch literal 32256 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 e8Sz{=yB!=e|I44-)!(}L6c6lQKL6i&;Qs literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Blake2b.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.Blake2b.dll new file mode 100644 index 0000000000000000000000000000000000000000..d58f40ddcbf237cc8bd8d1e70ea9be47902471a2 GIT binary patch literal 27136 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 literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.SecureArray.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/Isopoh.Cryptography.SecureArray.dll new file mode 100644 index 0000000000000000000000000000000000000000..681dddafaac9fe54177800d860e907be3ae06ab2 GIT binary patch literal 26112 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أ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 literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.Annotations.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/MessagePack.Annotations.dll new file mode 100644 index 0000000000000000000000000000000000000000..3c580eb92e3b4af9335dc5532e756c993fe5ca49 GIT binary patch literal 18432 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