From dd196508ac476ee3b633f5b71058719e5666a9cb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 21:14:55 +0200 Subject: [PATCH] Own the portable Eve surface contract --- .gitignore | 2 + README.md | 5 + .../Directory.Build.props | 9 + .../GameCult.Eve.Surface.csproj | 17 + .../GameCult.Eve.Surface.csproj.meta | 9 + packages/org.gamecult.eve.surface/README.md | 36 + .../org.gamecult.eve.surface/README.md.meta | 9 + .../org.gamecult.eve.surface/Runtime.meta | 10 + .../Runtime/EveAssetCatalogDocument.cs | 146 +++ .../Runtime/EveAssetCatalogDocument.cs.meta | 3 + .../Runtime/EveEntitySoaViewDocument.cs | 98 ++ .../Runtime/EveEntitySoaViewDocument.cs.meta | 13 + .../Runtime/EveInputCapabilityDocument.cs | 77 ++ .../EveInputCapabilityDocument.cs.meta | 4 + .../Runtime/EveInputDeviceLayouts.cs | 57 ++ .../Runtime/EveInputDeviceLayouts.cs.meta | 4 + .../Runtime/EveInputGestures.cs | 84 ++ .../Runtime/EveInputGestures.cs.meta | 3 + .../Runtime/EveInventoryInteraction.cs | 84 ++ .../Runtime/EveInventoryInteraction.cs.meta | 4 + .../Runtime/EveProviderDocuments.cs | 416 +++++++++ .../Runtime/EveProviderDocuments.cs.meta | 3 + .../Runtime/EveSurfaceBuilder.cs | 876 ++++++++++++++++++ .../Runtime/EveSurfaceBuilder.cs.meta | 3 + .../Runtime/EveSurfaceDocument.cs | 540 +++++++++++ .../Runtime/EveSurfaceDocument.cs.meta | 3 + .../Runtime/GameCult.Eve.Surface.asmdef | 23 + .../Runtime/GameCult.Eve.Surface.asmdef.meta | 9 + .../org.gamecult.eve.surface/package.json | 29 + .../package.json.meta | 9 + .../.gitignore | 3 + ...eSurfaceSerializationCompatibilityTests.cs | 268 ++++++ ...ult.Eve.Surface.Serialization.Tests.csproj | 22 + 33 files changed, 2878 insertions(+) create mode 100644 packages/org.gamecult.eve.surface/Directory.Build.props create mode 100644 packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj create mode 100644 packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta create mode 100644 packages/org.gamecult.eve.surface/README.md create mode 100644 packages/org.gamecult.eve.surface/README.md.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta create mode 100644 packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef create mode 100644 packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta create mode 100644 packages/org.gamecult.eve.surface/package.json create mode 100644 packages/org.gamecult.eve.surface/package.json.meta create mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore create mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs create mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj diff --git a/.gitignore b/.gitignore index 7f96da8..7a4e50c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ packages/eve-contracts/node_modules/ packages/eve-contracts/*.tgz !packages/org.gamecult.eve.surface/ !packages/org.gamecult.eve.surface/** +packages/org.gamecult.eve.surface/bin~/ +packages/org.gamecult.eve.surface/obj~/ !packages/org.gamecult.eve.unity-uitoolkit/ !packages/org.gamecult.eve.unity-uitoolkit/** obj/ diff --git a/README.md b/README.md index 1f45762..476ec0a 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ effects; Eve owns projection, input capture, timestamped sensor publication, and renderer parity. If a pretty panel cannot say which fields are stale, denied, predicted, or authoritative, it is not done. It is flirting with the operator. +The renderer-neutral C# contract lives at +`packages/org.gamecult.eve.surface`. Provider daemons and lowerers consume that +Eve-owned package; renderer repositories do not own copies of the surface +schema or composition builders. + ## Current Mechanism The current checked-in client is the iOS Theos app, still named `EveCanvas` at diff --git a/packages/org.gamecult.eve.surface/Directory.Build.props b/packages/org.gamecult.eve.surface/Directory.Build.props new file mode 100644 index 0000000..6984c05 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Directory.Build.props @@ -0,0 +1,9 @@ + + + $(MSBuildThisFileDirectory)bin~\ + $(MSBuildThisFileDirectory)obj~\ + $(MSBuildThisFileDirectory)obj~\ + + + + diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj new file mode 100644 index 0000000..442ae1a --- /dev/null +++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj @@ -0,0 +1,17 @@ + + + netstandard2.1 + enable + latest + enable + Portable Eve surface and provider contracts. + $(MSBuildThisFileDirectory)..\..\..\CultLib + + + + + + + + + diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta new file mode 100644 index 0000000..9b36186 --- /dev/null +++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: ce2d4913d64690c44ae68935014fd79d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md new file mode 100644 index 0000000..5138bbd --- /dev/null +++ b/packages/org.gamecult.eve.surface/README.md @@ -0,0 +1,36 @@ +# Eve Surface Contract + +Eve owns this renderer-neutral package. It defines the retained CultUI surface +document, provider advertisement, asset catalog, input vocabulary, and builder +primitives shared by providers and lowerers. Headless daemons depend on this +package without depending on EveUnity; Unity is one consumer among many. + +Components own normal child layout through `Children`, live CultMesh value +bindings through `StateBindings`, and nested synced document regions through +`EmbeddedDocuments`. An embedded document slot names the child document id, +schema, presentation kind, and route hint; renderers resolve that child through +CultMesh instead of rebuilding it with runtime-local projection code. + +See `../../docs/surface-contract-v1.md` for the full contract and +`../../web/fixtures/cultui-embedded-surface.json` for the parity fixture. +Runtime discovery and required feature coverage live in +`../../tools/parity/parity-manifest.json`; active GUI runtimes must list +`embeddedDocuments` as a supported feature and require the `embedded-surface` +fixture. + +Verification entrypoints: + +- C# serialization: `dotnet test tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj` +- Browser/TypeScript: `node --test web\eve-dsl.test.mjs` +- Semantic runtime matrix: `powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1` +- Flutter GUI contract, when Flutter is installed: `flutter test --plain-name embedded_surface_fixture_contract` +- Rust/CultMesh document sync: run `cargo test rust_preserves_cultui_embedded_surface_slots_through_typed_document_sync` from the sibling `CultLib/packages/cultnet-rs` directory. + +The parity manifest is the discovery source for web, Flutter desktop/Linux/Android, +iOS/UIKit, Android/Kotlin, Unity UI Toolkit, and Rust contract coverage. Do not +add a new renderer claim without adding its nested-surface fixture requirement +there. + +EveUnity must not carry another live copy of this package. Its Unity Scene and +UI Toolkit packages depend on the released Eve-owned package. + diff --git a/packages/org.gamecult.eve.surface/README.md.meta b/packages/org.gamecult.eve.surface/README.md.meta new file mode 100644 index 0000000..0df23ca --- /dev/null +++ b/packages/org.gamecult.eve.surface/README.md.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: fe5aa27636bead047b2aecb14fdc59f2 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/packages/org.gamecult.eve.surface/Runtime.meta b/packages/org.gamecult.eve.surface/Runtime.meta new file mode 100644 index 0000000..3e9c8ce --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 2164f6fea1216214d895e7dc99c46ecf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs new file mode 100644 index 0000000..aad726d --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using GameCult.Caching; +using MessagePack; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.asset_catalog", SchemaId)] + [MessagePackObject] + public sealed class EveAssetCatalogDocument + { + public const string SchemaId = "gamecult.eve.asset_catalog.v1"; + + [SerializationConstructor] + public EveAssetCatalogDocument( + string schema, + string providerId, + string catalogId, + long version, + string updatedAtUtc, + EveAssetCatalogEntry[] assets) + { + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + ProviderId = providerId ?? ""; + CatalogId = catalogId ?? ""; + Version = version; + UpdatedAtUtc = updatedAtUtc ?? ""; + Assets = assets ?? Array.Empty(); + } + + public EveAssetCatalogDocument( + string providerId, + string catalogId, + long version, + string updatedAtUtc, + EveAssetCatalogEntry[] assets) + : this(SchemaId, providerId, catalogId, version, updatedAtUtc, assets) + { + } + + [Key(0)] public string Schema { get; } + [Key(1)] public string ProviderId { get; } + [Key(2)] public string CatalogId { get; } + [Key(3)] public long Version { get; } + [Key(4)] public string UpdatedAtUtc { get; } + [Key(5)] public EveAssetCatalogEntry[] Assets { get; } + } + + [MessagePackObject] + public sealed class EveAssetCatalogEntry + { + [SerializationConstructor] + public EveAssetCatalogEntry( + string assetRef, + string assetKind, + EveAssetVariant[] variants, + Dictionary metadata) + { + AssetRef = assetRef ?? ""; + AssetKind = assetKind ?? ""; + Variants = variants ?? Array.Empty(); + Metadata = metadata == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(metadata, StringComparer.Ordinal); + } + + public EveAssetCatalogEntry( + string assetRef, + string assetKind, + EveAssetVariant[] variants, + IReadOnlyDictionary? metadata = null) + : this( + assetRef, + assetKind, + variants, + metadata == null ? new Dictionary() : new Dictionary(metadata)) + { + } + + [Key(0)] public string AssetRef { get; } + [Key(1)] public string AssetKind { get; } + [Key(2)] public EveAssetVariant[] Variants { get; } + [Key(3)] public Dictionary Metadata { get; } + } + + [MessagePackObject] + public sealed class EveAssetVariant + { + [SerializationConstructor] + public EveAssetVariant( + string runtimeId, + string platform, + string format, + string uri, + string contentHash, + long sizeBytes, + string assetKey, + Dictionary metadata) + { + RuntimeId = runtimeId ?? ""; + Platform = platform ?? ""; + Format = format ?? ""; + Uri = uri ?? ""; + ContentHash = contentHash ?? ""; + SizeBytes = sizeBytes; + AssetKey = assetKey ?? ""; + Metadata = metadata == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(metadata, StringComparer.Ordinal); + } + + public EveAssetVariant( + string runtimeId, + string platform, + string format, + string uri, + string contentHash, + long sizeBytes, + string assetKey, + IReadOnlyDictionary? metadata = null) + : this( + runtimeId, + platform, + format, + uri, + contentHash, + sizeBytes, + assetKey, + metadata == null ? new Dictionary() : new Dictionary(metadata)) + { + } + + [Key(0)] public string RuntimeId { get; } + [Key(1)] public string Platform { get; } + [Key(2)] public string Format { get; } + [Key(3)] public string Uri { get; } + [Key(4)] public string ContentHash { get; } + [Key(5)] public long SizeBytes { get; } + [Key(6)] public string AssetKey { get; } + [Key(7)] public Dictionary Metadata { get; } + } +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta new file mode 100644 index 0000000..32ba45b --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8f53654f7c98d894588127ba001f11b4 + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs new file mode 100644 index 0000000..5bd7ec7 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs @@ -0,0 +1,98 @@ +using System; +using GameCult.Caching; +using MessagePack; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.entity_soa_view", SchemaId)] + [MessagePackObject] + public sealed class EveEntitySoaViewDocument + { + public const string SchemaId = "gamecult.eve.entity_soa_view.v2"; + + [Key(0)] public string Schema { get; set; } = SchemaId; + [Key(1)] public string ProviderId { get; set; } = ""; + [Key(2)] public string ViewId { get; set; } = ""; + [Key(3)] public string PublishedAtUtc { get; set; } = ""; + [Key(4)] public string BodySchemaId { get; set; } = ""; + [Key(5)] public int LayoutVersion { get; set; } + [Key(6)] public long ProducerEpoch { get; set; } + [Key(7)] public long Sequence { get; set; } + [Key(8)] public int Capacity { get; set; } + [Key(9)] public EveEntitySoaBuffer[] Buffers { get; set; } = Array.Empty(); + [Key(10)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty(); + [Key(11)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty(); + [Key(12)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty(); + [Key(13)] public EveEntityIdentity[] Identities { get; set; } = Array.Empty(); + [Key(14)] public long FrameId { get; set; } + } + + [MessagePackObject] + public sealed class EveEntitySoaBuffer + { + [Key(0)] public string BufferId { get; set; } = ""; + [Key(1)] public long ByteOffset { get; set; } + [Key(2)] public long ByteLength { get; set; } + } + + [MessagePackObject] + public sealed class EveEntitySoaColumn + { + [Key(0)] public string ColumnId { get; set; } = ""; + [Key(1)] public string Semantic { get; set; } = ""; + [Key(2)] public string BufferId { get; set; } = ""; + [Key(3)] public string ScalarType { get; set; } = ""; + [Key(4)] public long ByteOffset { get; set; } + [Key(5)] public int ElementStride { get; set; } + [Key(6)] public int ElementCount { get; set; } + [Key(7)] public string Unit { get; set; } = ""; + [Key(8)] public string CoordinateSpace { get; set; } = ""; + } + + [MessagePackObject] + public sealed class EveEntitySoaDirtyRange + { + [Key(0)] public string ColumnId { get; set; } = ""; + [Key(1)] public int StartIndex { get; set; } + [Key(2)] public int Count { get; set; } + [Key(3)] public long Sequence { get; set; } + } + + [MessagePackObject] + public sealed class EveEntityIdentity + { + [Key(0)] public int Index { get; set; } + [Key(1)] public string EntityId { get; set; } = ""; + [Key(2)] public string EntityKind { get; set; } = ""; + [Key(3)] public string Label { get; set; } = ""; + [Key(4)] public string Faction { get; set; } = ""; + [Key(5)] public bool Selectable { get; set; } + [Key(6)] public bool Controllable { get; set; } + [Key(7)] public string AssetRef { get; set; } = ""; + } + + [MessagePackObject] + public sealed class EveEntityRenderGroup + { + [Key(0)] public int GroupId { get; set; } + [Key(1)] public string MeshAssetRef { get; set; } = ""; + [Key(2)] public string MaterialAssetRef { get; set; } = ""; + [Key(3)] public int SubMeshIndex { get; set; } + [Key(4)] public int Layer { get; set; } + [Key(5)] public int InstanceCount { get; set; } + [Key(6)] public float DefaultScale { get; set; } = 1f; + [Key(7)] public float BoundsCenterX { get; set; } + [Key(8)] public float BoundsCenterY { get; set; } + [Key(9)] public float BoundsCenterZ { get; set; } + [Key(10)] public float BoundsSizeX { get; set; } + [Key(11)] public float BoundsSizeY { get; set; } + [Key(12)] public float BoundsSizeZ { get; set; } + [Key(13)] public string ShadowMode { get; set; } = "on"; + [Key(14)] public bool ReceiveShadows { get; set; } = true; + [Key(15)] public int Lod { get; set; } = -1; + } +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta new file mode 100644 index 0000000..4c20d19 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: 8e87e1be783846508efea45787f2d45c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs new file mode 100644 index 0000000..88727fc --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using GameCult.Caching; +using MessagePack; + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.input_capability", SchemaId)] + [MessagePackObject] + public sealed class EveInputCapabilityDocument + { + public const string SchemaId = "gamecult.eve.input_capability.v1"; + [Key(0)] public string Schema { get; set; } = SchemaId; + [Key(1)] public string ProviderId { get; set; } = ""; + [Key(2)] public string CapabilityId { get; set; } = ""; + [Key(3)] public long Version { get; set; } + [Key(4)] public EveInputActionDocument[] Actions { get; set; } = Array.Empty(); + [Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty(); + } + + [MessagePackObject] + public sealed class EveInputActionDocument + { + [Key(0)] public string ActionId { get; set; } = ""; + [Key(1)] public string Label { get; set; } = ""; + [Key(2)] public string Operation { get; set; } = ""; + [Key(3)] public string Context { get; set; } = "pilot"; + [Key(4)] public string Category { get; set; } = "ship"; + [Key(5)] public string Availability { get; set; } = "available"; + [Key(6)] public string SourceRef { get; set; } = ""; + [Key(7)] public Dictionary Payload { get; set; } = new Dictionary(StringComparer.Ordinal); + [Key(8)] public EveInputValueDocument? InputValue { get; set; } + [Key(9)] public string IconRef { get; set; } = ""; + [Key(10)] public bool ActionBar { get; set; } + } + + [MessagePackObject] + public sealed class EveInputValueDocument + { + [Key(0)] public string Model { get; set; } = ""; + [Key(1)] public string PayloadKey { get; set; } = ""; + [Key(2)] public string[] PayloadKeys { get; set; } = Array.Empty(); + [Key(3)] public double? CurrentValue { get; set; } + [Key(4)] public double? MinimumValue { get; set; } + [Key(5)] public double? MaximumValue { get; set; } + [Key(6)] public double? StepValue { get; set; } + [Key(7)] public string Unit { get; set; } = ""; + } + + [MessagePackObject] + public sealed class EveInputProfileDocument + { + [Key(0)] public string ProfileId { get; set; } = ""; + [Key(1)] public string DeviceClass { get; set; } = ""; + [Key(2)] public EveInputBindingDocument[] Bindings { get; set; } = Array.Empty(); + } + + [MessagePackObject] + public sealed class EveInputBindingDocument + { + [Key(0)] public string BindingId { get; set; } = ""; + [Key(1)] public string ActionId { get; set; } = ""; + [Key(2)] public EveInputGestureDocument Gesture { get; set; } = new EveInputGestureDocument(); + [Key(3)] public bool ActionBar { get; set; } + } + + [MessagePackObject] + public sealed class EveInputGestureDocument + { + [Key(0)] public string Kind { get; set; } = "direct"; + [Key(1)] public string[] Controls { get; set; } = Array.Empty(); + [Key(2)] public int MaxStepIntervalMs { get; set; } = 650; + [Key(3)] public string CompletionControl { get; set; } = ""; + } +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta new file mode 100644 index 0000000..61e268a --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: 4c297c7dbb64a0f49a7f805e770c5e55 + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs new file mode 100644 index 0000000..a961c08 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; + +namespace GameCult.Eve.Surface +{ + public sealed class EveInputDeviceLayout + { + public string LayoutId { get; set; } = ""; + public string DeviceClass { get; set; } = ""; + public IReadOnlyList Controls { get; set; } = Array.Empty(); + public bool SupportsDirectionalStrings { get; set; } + + public static EveInputDeviceLayout AnsiKeyboardMouse() => new EveInputDeviceLayout + { + LayoutId = "keyboard.ansi-104+mouse.standard", + DeviceClass = "keyboard-mouse", + Controls = new[] + { + Key("keyboard.escape", "Esc", 0, 0, 1.25f), Key("keyboard.f1", "F1", 2, 0), Key("keyboard.f2", "F2", 3, 0), + Key("keyboard.1", "1", 1, 1), Key("keyboard.2", "2", 2, 1), Key("keyboard.3", "3", 3, 1), Key("keyboard.4", "4", 4, 1), Key("keyboard.5", "5", 5, 1), + Key("keyboard.tab", "Tab", 0, 2, 1.5f), Key("keyboard.q", "Q", 1.5f, 2), Key("keyboard.w", "W", 2.5f, 2), Key("keyboard.e", "E", 3.5f, 2), Key("keyboard.r", "R", 4.5f, 2), + Key("keyboard.a", "A", 1.75f, 3), Key("keyboard.s", "S", 2.75f, 3), Key("keyboard.d", "D", 3.75f, 3), Key("keyboard.f", "F", 4.75f, 3), + Key("keyboard.leftShift", "Shift", 0, 4, 2.25f), Key("keyboard.space", "Space", 3.75f, 5, 6.25f), + Key("mouse.primary", "Mouse 1", 18, 1), Key("mouse.secondary", "Mouse 2", 19, 1), Key("mouse.middle", "Mouse 3", 18.5f, 2) + } + }; + + public static EveInputDeviceLayout StandardGamepad() => new EveInputDeviceLayout + { + LayoutId = "gamepad.standard", + DeviceClass = "gamepad", + SupportsDirectionalStrings = true, + Controls = new[] + { + Key("gamepad.leftStick", "Left Stick", 2, 3, 2), Key("gamepad.rightStick", "Right Stick", 8, 4, 2), + Key("gamepad.dpad.up", "Up", 1, 1), Key("gamepad.dpad.left", "Left", 0, 2), Key("gamepad.dpad.right", "Right", 2, 2), Key("gamepad.dpad.down", "Down", 1, 3), + Key("gamepad.buttonSouth", "A / Cross", 10, 3), Key("gamepad.buttonEast", "B / Circle", 11, 2), Key("gamepad.buttonWest", "X / Square", 9, 2), Key("gamepad.buttonNorth", "Y / Triangle", 10, 1), + Key("gamepad.leftTrigger", "LT / L2", 2, 0, 2), Key("gamepad.rightTrigger", "RT / R2", 9, 0, 2) + } + }; + + private static EveInputLayoutControl Key(string id, string label, float x, float y, float width = 1) => + new EveInputLayoutControl { Control = id, Label = label, X = x, Y = y, Width = width, Height = 1 }; + } + + public sealed class EveInputLayoutControl + { + public string Control { get; set; } = ""; + public string Label { get; set; } = ""; + public float X { get; set; } + public float Y { get; set; } + public float Width { get; set; } = 1; + public float Height { get; set; } = 1; + } +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta new file mode 100644 index 0000000..2c04385 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: 8a2a1258551e42a9831a3e4f9483d928 + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs new file mode 100644 index 0000000..ddaf856 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GameCult.Eve.Surface +{ + public sealed class EveInputGesture + { + public string Kind { get; set; } = "direct"; + public IReadOnlyList Controls { get; set; } = Array.Empty(); + public int MaxStepIntervalMs { get; set; } = 650; + public string CompletionControl { get; set; } = ""; + } + + public sealed class EveInputBinding + { + public string BindingId { get; set; } = ""; + public string ActionId { get; set; } = ""; + public EveInputGesture Gesture { get; set; } = new EveInputGesture(); + public bool ActionBar { get; set; } + } + + public sealed class EveInputGestureResolver + { + private readonly IReadOnlyList _bindings; + private readonly HashSet _pressed = new HashSet(StringComparer.Ordinal); + private readonly Dictionary _sequences = new Dictionary(StringComparer.Ordinal); + + public EveInputGestureResolver(IEnumerable bindings) + { + _bindings = (bindings ?? Array.Empty()).Where(binding => binding != null).ToArray(); + } + + public IReadOnlyList Press(string control, long timestampMs) + { + _pressed.Add(control ?? ""); + var actions = new HashSet(StringComparer.Ordinal); + foreach (var binding in _bindings) + { + var gesture = binding.Gesture ?? new EveInputGesture(); + if (gesture.Kind == "direct" && gesture.Controls.FirstOrDefault() == control) + actions.Add(binding.ActionId); + else if (gesture.Kind == "chord" && gesture.Controls.Count > 0 && gesture.Controls.All(_pressed.Contains)) + actions.Add(binding.ActionId); + else if (gesture.Kind == "sequence" && Advance(binding, control ?? "", timestampMs)) + actions.Add(binding.ActionId); + } + return actions.ToArray(); + } + + public void Release(string control) => _pressed.Remove(control ?? ""); + + public void Reset() + { + _pressed.Clear(); + _sequences.Clear(); + } + + private bool Advance(EveInputBinding binding, string control, long timestampMs) + { + var controls = binding.Gesture.Controls; + if (controls.Count == 0) return false; + _sequences.TryGetValue(binding.BindingId, out var previous); + var index = previous.Index > 0 && timestampMs - previous.TimestampMs <= Math.Max(50, binding.Gesture.MaxStepIntervalMs) ? previous.Index : 0; + var next = controls[index] == control ? index + 1 : controls[0] == control ? 1 : 0; + if (next == controls.Count) + { + _sequences.Remove(binding.BindingId); + return string.IsNullOrWhiteSpace(binding.Gesture.CompletionControl); + } + _sequences[binding.BindingId] = new SequenceState(next, timestampMs); + return false; + } + + private readonly struct SequenceState + { + public SequenceState(int index, long timestampMs) { Index = index; TimestampMs = timestampMs; } + public int Index { get; } + public long TimestampMs { get; } + } + } +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta new file mode 100644 index 0000000..5c24939 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e3c2941be243a754092ac2543d1e6bf7 + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs new file mode 100644 index 0000000..21490a9 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Mesh; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + public static class EveInventoryInteraction + { + public const string GridKind = "inventory.grid"; + public const string ItemKind = "inventory.item"; + public const string DragSessionKind = "inventory.drag_session"; + + public static bool TryCreateDropRequest( + EveSurfaceDocument document, + EveSurfaceComponent source, + EveSurfaceComponent target, + int destinationX, + int destinationY, + string clientId, + out EveSurfaceCommandRequest? request) + { + request = null; + if (document == null || source == null || target == null || + !string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) || + !string.Equals(target.Kind, GridKind, StringComparison.Ordinal)) + { + return false; + } + + var sourceKind = source.GetProp("sourceKind", source.GetProp("source")); + var command = target.GetProp($"dropCommand.{sourceKind}", target.GetProp("dropCommand")); + if (string.IsNullOrWhiteSpace(sourceKind) || string.IsNullOrWhiteSpace(command)) + return false; + + var sourceIndex = Int(source.GetProp("sourceIndex"), -1); + var targetIndex = Int(target.GetProp("targetIndex"), -1); + var targetKind = target.GetProp("targetKind"); + var payload = new Dictionary(StringComparer.Ordinal) + { + ["sourceKind"] = sourceKind, + ["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")), + ["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture), + ["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1) + .ToString(CultureInfo.InvariantCulture), + ["itemKey"] = source.GetProp("itemKey"), + ["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture), + ["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture), + ["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture), + ["destinationKind"] = targetKind, + ["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")), + ["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture), + ["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1) + .ToString(CultureInfo.InvariantCulture), + ["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture), + ["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture), + ["hasDestinationPosition"] = "true" + }; + var template = document.Commands.FirstOrDefault(candidate => + string.Equals(candidate.Command, command, StringComparison.Ordinal)); + var operation = template == null + ? CultMesh.OperationInvocation(command) + : CultMesh.OperationInvocation(template.Operation); + request = new EveSurfaceCommandRequest( + document.ProviderId, + document.Surface.Id, + operation, + CultMesh.OperationPayload(payload), + DateTimeOffset.UtcNow, + string.IsNullOrWhiteSpace(clientId) ? "unity-uitoolkit" : clientId); + return true; + } + + private static int Int(string value, int fallback) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : fallback; + } +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta new file mode 100644 index 0000000..110cb4b --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: 17fd4173e6e44b4b9c503d343021ffcb + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs new file mode 100644 index 0000000..eb7cd62 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -0,0 +1,416 @@ +using System; +using System.Collections.Generic; +using GameCult.Caching; +using MessagePack; +using MessagePack.Formatters; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.provider_advertisement", SchemaId)] + [MessagePackObject] + [MessagePackFormatter(typeof(EveProviderAdvertisementCompatibilityFormatter))] + public sealed class EveProviderAdvertisementDocument + { + public const string SchemaId = "gamecult.eve.provider_advertisement.v1"; + + public EveProviderAdvertisementDocument( + string providerId, + string serviceId, + string verseId, + string title, + string kind, + string cultMeshAddress, + string updatedAtUtc, + EveProviderFreshness freshness, + IReadOnlyList schemas, + IReadOnlyList witnesses, + IReadOnlyList surfaces, + IReadOnlyList commands, + IReadOnlyList? authorizedBodyProducerIds = null) + : this(SchemaId, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, freshness, schemas, witnesses, surfaces, commands, authorizedBodyProducerIds) + { + } + + [SerializationConstructor] + public EveProviderAdvertisementDocument( + string schema, + string providerId, + string serviceId, + string verseId, + string title, + string kind, + string cultMeshAddress, + string updatedAtUtc, + EveProviderFreshness freshness, + IReadOnlyList schemas, + IReadOnlyList witnesses, + IReadOnlyList surfaces, + IReadOnlyList commands, + IReadOnlyList? authorizedBodyProducerIds = null) + { + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + ProviderId = providerId ?? ""; + ServiceId = serviceId ?? ""; + VerseId = verseId ?? ""; + Title = title ?? ""; + Kind = kind ?? ""; + CultMeshAddress = cultMeshAddress ?? ""; + UpdatedAtUtc = updatedAtUtc ?? ""; + Freshness = freshness ?? new EveProviderFreshness("unknown", "", 0); + Schemas = schemas ?? Array.Empty(); + Witnesses = witnesses ?? Array.Empty(); + Surfaces = surfaces ?? Array.Empty(); + Commands = commands ?? Array.Empty(); + AuthorizedBodyProducerIds = authorizedBodyProducerIds ?? Array.Empty(); + } + + [Key(0)] public string Schema { get; } + [Key(1)] public string ProviderId { get; } + [Key(2)] public string ServiceId { get; } + [Key(3)] public string VerseId { get; } + [Key(4)] public string Title { get; } + [Key(5)] public string Kind { get; } + [Key(6)] public string CultMeshAddress { get; } + [Key(7)] public string UpdatedAtUtc { get; } + [Key(8)] public EveProviderFreshness Freshness { get; } + [Key(9)] public IReadOnlyList Schemas { get; } + [Key(10)] public IReadOnlyList Witnesses { get; } + [Key(11)] public IReadOnlyList Surfaces { get; } + [Key(12)] public IReadOnlyList Commands { get; } + [Key(13)] public IReadOnlyList AuthorizedBodyProducerIds { get; } + } + + /// + /// Reads canonical provider advertisements and the original Aetheria-expanded v1 layout. + /// + public sealed class EveProviderAdvertisementCompatibilityFormatter : + IMessagePackFormatter + { + private const int CurrentFieldCount = 14; + private const int LegacyFieldCount = 16; + + public void Serialize( + ref MessagePackWriter writer, + EveProviderAdvertisementDocument? value, + MessagePackSerializerOptions options) + { + if (value == null) { writer.WriteNil(); return; } + writer.WriteArrayHeader(CurrentFieldCount); + writer.Write(value.Schema); + writer.Write(value.ProviderId); + writer.Write(value.ServiceId); + writer.Write(value.VerseId); + writer.Write(value.Title); + writer.Write(value.Kind); + writer.Write(value.CultMeshAddress); + writer.Write(value.UpdatedAtUtc); + Formatter(options).Serialize(ref writer, value.Freshness, options); + Formatter>(options).Serialize(ref writer, value.Schemas, options); + Formatter>(options).Serialize(ref writer, value.Witnesses, options); + Formatter>(options).Serialize(ref writer, value.Surfaces, options); + Formatter>(options).Serialize(ref writer, value.Commands, options); + Formatter>(options).Serialize(ref writer, value.AuthorizedBodyProducerIds, options); + } + + public EveProviderAdvertisementDocument? Deserialize( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) return null; + if (reader.NextMessagePackType != MessagePackType.Array) + throw new MessagePackSerializationException("Eve provider advertisement must be an array."); + + options.Security.DepthStep(ref reader); + try + { + var fields = reader.ReadArrayHeader(); + if (fields == LegacyFieldCount) + return ReadLegacy(ref reader, options); + + var schema = ReadString(ref reader, fields, 0); + var providerId = ReadString(ref reader, fields, 1); + var serviceId = ReadString(ref reader, fields, 2); + var verseId = ReadString(ref reader, fields, 3); + var title = ReadString(ref reader, fields, 4); + var kind = ReadString(ref reader, fields, 5); + var address = ReadString(ref reader, fields, 6); + var updatedAtUtc = ReadString(ref reader, fields, 7); + var freshness = fields > 8 ? Formatter(options).Deserialize(ref reader, options) : null; + var schemas = fields > 9 ? Formatter>(options).Deserialize(ref reader, options) : null; + var witnesses = fields > 10 ? Formatter>(options).Deserialize(ref reader, options) : null; + var surfaces = fields > 11 ? Formatter>(options).Deserialize(ref reader, options) : null; + var commands = fields > 12 ? Formatter>(options).Deserialize(ref reader, options) : null; + var authorizedBodyProducerIds = fields > 13 ? Formatter>(options).Deserialize(ref reader, options) : null; + for (var index = CurrentFieldCount; index < fields; index++) reader.Skip(); + return new EveProviderAdvertisementDocument( + schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc, + freshness ?? new EveProviderFreshness("unknown", "", 0), + schemas ?? Array.Empty(), + witnesses ?? Array.Empty(), + surfaces ?? Array.Empty(), + commands ?? Array.Empty(), + authorizedBodyProducerIds ?? Array.Empty()); + } + finally { reader.Depth--; } + } + + private static EveProviderAdvertisementDocument ReadLegacy( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + var schema = ReadString(ref reader); + var providerId = ReadString(ref reader); + var serviceId = ReadString(ref reader); + var verseId = ReadString(ref reader); + reader.Skip(); // RootVerse remains provider-owned settings, not portable advertisement state. + reader.Skip(); // CanonicalService + reader.Skip(); // LocatedService + var address = ReadString(ref reader); + var title = ReadString(ref reader); + var kind = ReadString(ref reader); + var updatedAtUtc = ReadString(ref reader); + var freshness = Formatter(options).Deserialize(ref reader, options) + ?? new EveProviderFreshness("unknown", "", 0); + var schemas = Formatter>(options).Deserialize(ref reader, options) + ?? Array.Empty(); + var witnesses = Formatter>(options).Deserialize(ref reader, options) + ?? Array.Empty(); + var surfaces = ReadLegacySurfaces(ref reader); + var commands = ReadLegacyCommands(ref reader); + return new EveProviderAdvertisementDocument( + schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc, + freshness, schemas, witnesses, surfaces, commands); + } + + private static EveAdvertisedSurface[] ReadLegacySurfaces(ref MessagePackReader reader) + { + var count = reader.ReadArrayHeader(); + var values = new EveAdvertisedSurface[count]; + for (var index = 0; index < count; index++) + { + var fields = reader.ReadArrayHeader(); + var schema = ReadString(ref reader, fields, 0); + var surfaceId = ReadString(ref reader, fields, 1); + var recordRef = ReadString(ref reader, fields, 2); + var transport = ReadString(ref reader, fields, 3); + var status = ReadString(ref reader, fields, 4); + for (var field = 5; field < fields; field++) reader.Skip(); + values[index] = new EveAdvertisedSurface( + surfaceId, schema, recordRef, transport, status, "", null); + } + return values; + } + + private static EveAdvertisedCommand[] ReadLegacyCommands(ref MessagePackReader reader) + { + var count = reader.ReadArrayHeader(); + var values = new EveAdvertisedCommand[count]; + for (var index = 0; index < count; index++) + { + var fields = reader.ReadArrayHeader(); + var command = ReadString(ref reader, fields, 0); + var transport = ReadString(ref reader, fields, 1); + var summary = ReadString(ref reader, fields, 2); + for (var field = 3; field < fields; field++) reader.Skip(); + values[index] = new EveAdvertisedCommand(command, "", transport, summary); + } + return values; + } + + private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) => + options.Resolver.GetFormatter() + ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}."); + + private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? ""; + private static string ReadString(ref MessagePackReader reader, int fields, int index) => + index < fields ? ReadString(ref reader) : ""; + } + + + [MessagePackObject] + public sealed class EveProviderFreshness + { + [SerializationConstructor] + public EveProviderFreshness(string state, string lastSeenAtUtc, long maxAgeMs) + { + State = state ?? ""; + LastSeenAtUtc = lastSeenAtUtc ?? ""; + MaxAgeMs = maxAgeMs; + } + + [Key(0)] public string State { get; } + [Key(1)] public string LastSeenAtUtc { get; } + [Key(2)] public long MaxAgeMs { get; } + } + + [MessagePackObject] + public sealed class EveProviderWitness + { + [SerializationConstructor] + public EveProviderWitness(string kind, string reference, string summary) + { + Kind = kind ?? ""; + Reference = reference ?? ""; + Summary = summary ?? ""; + } + + [Key(0)] public string Kind { get; } + [Key(1)] public string Reference { get; } + [Key(2)] public string Summary { get; } + } + + [MessagePackObject] + public sealed class EveAdvertisedSurface + { + [SerializationConstructor] + public EveAdvertisedSurface( + string surfaceId, + string schema, + string recordRef, + string transport, + string status, + string surfaceKind, + EveWorldInteractionAdvertisement? worldInteraction = null) + { + SurfaceId = surfaceId ?? ""; + Schema = schema ?? ""; + RecordRef = recordRef ?? ""; + Transport = transport ?? ""; + Status = status ?? ""; + SurfaceKind = surfaceKind ?? ""; + WorldInteraction = worldInteraction; + } + + [Key(0)] public string SurfaceId { get; } + [Key(1)] public string Schema { get; } + [Key(2)] public string RecordRef { get; } + [Key(3)] public string Transport { get; } + [Key(4)] public string Status { get; } + [Key(5)] public string SurfaceKind { get; } + [Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; } + } + + [MessagePackObject] + public sealed class EveWorldInteractionAdvertisement + { + [SerializationConstructor] + public EveWorldInteractionAdvertisement( + string projectionKind, + IReadOnlyList stateSchemas, + string commandBoundary, + string commandRecordRef, + string receiptSchema, + string receiptRecordRef, + string assetManifestRecordRef, + IReadOnlyList loweringTargets, + string ownership) + { + ProjectionKind = projectionKind ?? ""; + StateSchemas = stateSchemas ?? Array.Empty(); + CommandBoundary = commandBoundary ?? ""; + CommandRecordRef = commandRecordRef ?? ""; + ReceiptSchema = receiptSchema ?? ""; + ReceiptRecordRef = receiptRecordRef ?? ""; + AssetManifestRecordRef = assetManifestRecordRef ?? ""; + LoweringTargets = loweringTargets ?? Array.Empty(); + Ownership = ownership ?? ""; + } + + [Key(0)] public string ProjectionKind { get; } + [Key(1)] public IReadOnlyList StateSchemas { get; } + [Key(2)] public string CommandBoundary { get; } + [Key(3)] public string CommandRecordRef { get; } + [Key(4)] public string ReceiptSchema { get; } + [Key(5)] public string ReceiptRecordRef { get; } + [Key(6)] public string AssetManifestRecordRef { get; } + [Key(7)] public IReadOnlyList LoweringTargets { get; } + [Key(8)] public string Ownership { get; } + } + + [MessagePackObject] + public sealed class EveAdvertisedCommand + { + [SerializationConstructor] + public EveAdvertisedCommand(string command, string surfaceId, string transport, string summary) + { + Command = command ?? ""; + SurfaceId = surfaceId ?? ""; + Transport = transport ?? ""; + Summary = summary ?? ""; + } + + [Key(0)] public string Command { get; } + [Key(1)] public string SurfaceId { get; } + [Key(2)] public string Transport { get; } + [Key(3)] public string Summary { get; } + } + + [CultDocument("gamecult.eve.command_receipt", SchemaId)] + [MessagePackObject] + public sealed class EveCommandReceiptDocument + { + public const string SchemaId = "gamecult.eve.command_receipt.v1"; + + public EveCommandReceiptDocument( + string receiptId, + string commandId, + string command, + string state, + string ownerRepo, + string authority, + string providerId, + string surfaceId, + string message, + string issuedAtUtc, + long sourceVersion) + : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion) + { + } + + [SerializationConstructor] + public EveCommandReceiptDocument( + string schema, + string receiptId, + string commandId, + string command, + string state, + string ownerRepo, + string authority, + string providerId, + string surfaceId, + string message, + string issuedAtUtc, + long sourceVersion) + { + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + ReceiptId = receiptId ?? ""; + CommandId = commandId ?? ""; + Command = command ?? ""; + State = state ?? ""; + OwnerRepo = ownerRepo ?? ""; + Authority = authority ?? ""; + ProviderId = providerId ?? ""; + SurfaceId = surfaceId ?? ""; + Message = message ?? ""; + IssuedAtUtc = issuedAtUtc ?? ""; + SourceVersion = sourceVersion; + } + + [Key(0)] public string Schema { get; } + [Key(1)] public string ReceiptId { get; } + [Key(2)] public string CommandId { get; } + [Key(3)] public string Command { get; } + [Key(4)] public string State { get; } + [Key(5)] public string OwnerRepo { get; } + [Key(6)] public string Authority { get; } + [Key(7)] public string ProviderId { get; } + [Key(8)] public string SurfaceId { get; } + [Key(9)] public string Message { get; } + [Key(10)] public string IssuedAtUtc { get; } + [Key(11)] public long SourceVersion { get; } + } +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta new file mode 100644 index 0000000..274e291 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f7ea0cd07c4d5da4099b5acf8d44ca2e + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs new file mode 100644 index 0000000..68ee612 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs @@ -0,0 +1,876 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Mesh; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + /// + /// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors. + /// + public sealed class EveSurfaceBuilder + { + private readonly string _surfaceId; + private readonly List _children = new(); + private readonly List _styles = new(); + private readonly List _commands = new(); + private EveSurfaceComponent? _root; + 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 EveCommandTemplate(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 EveCommandTemplate(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 RootColumn(string id, Action build) + { + _root = BuildContainer(id, "partition", Props(("split", "y")), build); + return this; + } + + public EveSurfaceBuilder RootRow(string id, Action build) + { + _root = BuildContainer(id, "partition", Props(("split", "x")), build); + return this; + } + + public EveSurfaceBuilder RootGrid( + string id, + int columns, + int rows, + Action build) + { + _root = BuildContainer( + id, + "grid", + Props( + ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), + build); + return this; + } + + public EveSurfaceBuilder Row(string id, Action build) + { + _children.Add(BuildContainer(id, "partition", Props(("split", "x")), build)); + return this; + } + + public EveSurfaceBuilder Column(string id, Action build) + { + _children.Add(BuildContainer(id, "partition", Props(("split", "y")), build)); + return this; + } + + public EveSurfaceBuilder Grid(string id, int columns, int rows, Action build) + { + _children.Add(BuildContainer( + id, + "grid", + Props( + ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), + build)); + return this; + } + + public EveSurfaceBuilder Modal(string id, string title, Action build) + { + _children.Add(BuildContainer(id, "modal", Props(("title", title ?? "")), 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) + })); + return this; + } + + public EveSurfaceBuilder Style(string name, string value) + { + _styles.Add(new EveStyleToken(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, + _root ?? 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 EveSurfaceComponent BuildContainer( + string id, + string kind, + IReadOnlyDictionary props, + Action build) + { + if (build == null) throw new ArgumentNullException(nameof(build)); + var container = new EveSurfaceContainerBuilder(id, kind, props, AddCommand); + build(container); + return container.Build(); + } + + private void AddCommand(EveCommandTemplate 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 Dictionary Props(params (string Key, string Value)[] values) + { + return (values ?? Array.Empty<(string Key, string Value)>()) + .ToDictionary(value => value.Key, value => value.Value ?? "", 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 EveSurfaceContainerBuilder + { + private readonly string _id; + private readonly string _kind; + private readonly Dictionary _props; + private readonly Action _addCommand; + private readonly List _children = new(); + private readonly List _stateBindings = new(); + private readonly List _embeddedDocuments = new(); + + internal EveSurfaceContainerBuilder( + string id, + string kind, + IReadOnlyDictionary props, + Action addCommand) + { + _id = id ?? ""; + _kind = kind ?? "partition"; + _props = new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal); + _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); + } + + public EveSurfaceContainerBuilder Gap(int value) => Prop("gap", value); + + public EveSurfaceContainerBuilder Padding(int value) => Prop("padding", value); + + public EveSurfaceContainerBuilder Size(string value) => Prop("size", value); + + public EveSurfaceContainerBuilder Min(string value) => Prop("min", value); + + public EveSurfaceContainerBuilder Max(string value) => Prop("max", value); + + public EveSurfaceContainerBuilder Align(string value) => Prop("align", value); + + public EveSurfaceContainerBuilder Clip(string value = "hidden") => Prop("clip", value); + + public EveSurfaceContainerBuilder Scroll(string value = "y") => Prop("scroll", value); + + public EveSurfaceContainerBuilder Region(string value) => Prop("region", value); + + public EveSurfaceContainerBuilder Bind(CultMeshStateBindingDescriptor binding) + { + if (binding != null) + _stateBindings.Add(binding); + return this; + } + + public EveSurfaceContainerBuilder Row(string id, Action build) + { + _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "x")), build)); + return this; + } + + public EveSurfaceContainerBuilder Column(string id, Action build) + { + _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "y")), build)); + return this; + } + + public EveSurfaceContainerBuilder Grid( + string id, + int columns, + int rows, + Action build) + { + _children.Add(BuildContainer( + id, + "grid", + EveSurfaceBuilder.Props( + ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), + build)); + return this; + } + + public EveSurfaceContainerBuilder Pane(string id, string title, Action build) + { + _children.Add(BuildContainer(id, "pane", EveSurfaceBuilder.Props(("title", title ?? "")), build)); + return this; + } + + public EveSurfaceContainerBuilder Card(string id, string title, Action build) + { + _children.Add(BuildContainer(id, "card", EveSurfaceBuilder.Props(("title", title ?? "")), build)); + return this; + } + + public EveSurfaceContainerBuilder Modal(string id, string title, Action build) + { + _children.Add(BuildContainer(id, "modal", EveSurfaceBuilder.Props(("title", title ?? "")), build)); + return this; + } + + public EveSurfaceContainerBuilder FieldRow( + string id, + string label, + Action build) + { + return Row(id, row => row + .Gap(8) + .Label($"{id}.label", label, labelPart => labelPart.Size("12rem")) + .Column($"{id}.field", field => + { + field.Size("1fr"); + build(field); + })); + } + + public EveSurfaceContainerBuilder Label( + string id, + string text, + Action? configure = null) + { + return Leaf(id, "label", EveSurfaceBuilder.Props(("value", text ?? "")), null, null, configure); + } + + public EveSurfaceContainerBuilder Text(string id, string text) + { + return Leaf(id, "text", EveSurfaceBuilder.Props(("value", text ?? ""))); + } + + public EveSurfaceContainerBuilder Title(string id, string text) + { + return Leaf(id, "text.title", EveSurfaceBuilder.Props(("value", text ?? ""))); + } + + public EveSurfaceContainerBuilder Metric( + string id, + string label, + string value, + CultMeshStateBindingDescriptor? binding = null) + { + return Leaf( + id, + "metric", + EveSurfaceBuilder.Props(("label", label ?? ""), ("value", value ?? "")), + binding); + } + + public EveSurfaceContainerBuilder Progress( + string id, + string label, + string value, + string min, + string max, + CultMeshStateBindingDescriptor? binding = null) + { + return Leaf( + id, + "progress", + EveSurfaceBuilder.Props( + ("label", label ?? ""), + ("value", value ?? ""), + ("min", min ?? ""), + ("max", max ?? "")), + binding); + } + + public EveSurfaceContainerBuilder Button(string id, string label, string command) + { + return Button(id, label, CultMesh.OperationBinding(command, label)); + } + + public EveSurfaceContainerBuilder Button( + string id, + string label, + CultMeshOperationBindingDescriptor operation) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveCommandTemplate(operation)); + _children.Add(EveSurfaceBuilder.ButtonComponent(id, label, operation)); + return this; + } + + public EveSurfaceContainerBuilder PopupButton( + string id, + string label, + string title, + Action build, + string anchor = "viewport", + string placement = "center", + int offsetX = 0, + int offsetY = 0) + { + if (build == null) throw new ArgumentNullException(nameof(build)); + var popup = BuildContainer( + $"{id}.popup", + "modal", + EveSurfaceBuilder.Props( + ("title", title ?? label ?? ""), + ("anchor", anchor ?? "viewport"), + ("placement", placement ?? "center"), + ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("presentation", "popover")), + build); + _children.Add(new EveSurfaceComponent( + id ?? "", + "control.popup", + EveSurfaceBuilder.Props( + ("label", label ?? ""), + ("title", title ?? label ?? ""), + ("anchor", anchor ?? "viewport"), + ("placement", placement ?? "center"), + ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture))), + new[] { popup })); + return this; + } + + public EveSurfaceContainerBuilder EnumField( + string id, + string label, + string value, + CultMeshStateBindingDescriptor optionsBinding, + CultMeshOperationBindingDescriptor setValue, + string anchor = "trigger", + string placement = "right-down") + { + return FieldRow(id, label, field => field.PopupButton( + $"{id}.dropdown", + value, + label, + popup => popup.OptionList($"{id}.options", label, optionsBinding, setValue), + anchor, + placement)); + } + + public EveSurfaceContainerBuilder OptionList( + string id, + string label, + CultMeshStateBindingDescriptor itemsBinding, + CultMeshOperationBindingDescriptor operation) + { + if (itemsBinding == null) throw new ArgumentNullException(nameof(itemsBinding)); + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveCommandTemplate(operation)); + _children.Add(new EveSurfaceComponent( + id ?? "", + "options", + EveSurfaceBuilder.Props( + ("label", label ?? ""), + ("command", operation.OperationId), + ("operationId", operation.OperationId), + ("schemaId", operation.SchemaId)), + Array.Empty(), + new[] { itemsBinding })); + return this; + } + + public EveSurfaceContainerBuilder TextInput( + string id, + string label, + string value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding = null) + { + return Control("control.input.text", id, label, value, operation, binding); + } + + public EveSurfaceContainerBuilder Toggle( + string id, + string label, + bool value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding = null) + { + return Control("control.toggle", id, label, value ? "true" : "false", operation, binding); + } + + public EveSurfaceContainerBuilder Slider( + string id, + string label, + string value, + string min, + string max, + string step, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding = null) + { + return Control( + "control.slider", + id, + label, + value, + operation, + binding, + ("min", min ?? ""), + ("max", max ?? ""), + ("step", step ?? "")); + } + + public EveSurfaceContainerBuilder Select( + string id, + string label, + string value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding = null) + { + return Control("control.select", id, label, value, operation, binding); + } + + public EveSurfaceContainerBuilder EmbeddedDocument( + string id, + string slotId, + string documentId, + string schemaId, + string presentationKind, + CultMeshRouteHint? routeHint = null) + { + _children.Add(new EveSurfaceComponent( + id ?? "", + "surface.slot", + EveSurfaceBuilder.Props( + ("slotId", slotId ?? ""), + ("documentId", documentId ?? ""), + ("schemaId", schemaId ?? ""), + ("presentationKind", presentationKind ?? "")), + Array.Empty(), + Array.Empty(), + new[] + { + new EveEmbeddedDocumentSlot( + slotId ?? "", + documentId ?? "", + schemaId ?? "", + presentationKind ?? "", + routeHint) + })); + return this; + } + + internal EveSurfaceComponent Build() + { + return new EveSurfaceComponent( + _id, + _kind, + _props, + _children.ToArray(), + _stateBindings.ToArray(), + _embeddedDocuments.ToArray()); + } + + private EveSurfaceContainerBuilder Prop(string key, int value) + { + _props[key] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); + return this; + } + + private EveSurfaceContainerBuilder Prop(string key, string value) + { + _props[key] = value ?? ""; + return this; + } + + private EveSurfaceContainerBuilder Leaf( + string id, + string kind, + IReadOnlyDictionary props, + CultMeshStateBindingDescriptor? binding = null, + IReadOnlyList? children = null, + Action? configure = null) + { + var builder = new EveSurfaceContainerBuilder(id, kind, props, _addCommand); + configure?.Invoke(builder); + var component = new EveSurfaceComponent( + id ?? "", + kind ?? "", + new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal), + children ?? Array.Empty(), + binding == null ? Array.Empty() : new[] { binding }); + if (builder._props.Count != (props?.Count ?? 0)) + { + component = new EveSurfaceComponent( + id ?? "", + kind ?? "", + new Dictionary(builder._props, StringComparer.Ordinal), + children ?? Array.Empty(), + binding == null ? Array.Empty() : new[] { binding }); + } + _children.Add(component); + return this; + } + + private EveSurfaceContainerBuilder Control( + string kind, + string id, + string label, + string value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding, + params (string Key, string Value)[] extraProps) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveCommandTemplate(operation)); + var props = EveSurfaceBuilder.Props( + new[] + { + ("label", label ?? ""), + ("value", value ?? ""), + ("command", operation.OperationId), + ("operationId", operation.OperationId), + ("schemaId", operation.SchemaId) + }.Concat(extraProps ?? Array.Empty<(string Key, string Value)>()).ToArray()); + return Leaf(kind: kind, id: id, props: props, binding: binding); + } + + private EveSurfaceComponent BuildContainer( + string id, + string kind, + IReadOnlyDictionary props, + Action build) + { + if (build == null) throw new ArgumentNullException(nameof(build)); + var container = new EveSurfaceContainerBuilder(id, kind, props, _addCommand); + build(container); + return container.Build(); + } + } + + 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 EveCommandTemplate(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 EveCommandTemplate(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 EveCommandTemplate(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 EveCommandTemplate(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[] { 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[] { 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/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta new file mode 100644 index 0000000..44bff25 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 97ac7f757692aef4db64d75d0df6fe51 + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs new file mode 100644 index 0000000..e7d6536 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs @@ -0,0 +1,540 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using GameCult.Mesh; +using MessagePack; +using MessagePack.Formatters; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] + [MessagePackObject] + [MessagePackFormatter(typeof(EveSurfaceDocumentCompatibilityFormatter))] + public sealed class EveSurfaceDocument + { + public const string DefaultType = "surface-state"; + public const string SchemaId = "gamecult.eve.surface.v1"; + + public EveSurfaceDocument( + string providerId, + string providerKind, + string title, + long version, + string updatedAtUtc, + EveSurfaceTree surface, + IReadOnlyList commands) + : this(DefaultType, SchemaId, providerId, providerKind, title, version, updatedAtUtc, surface, commands) + { + } + + [SerializationConstructor] + public EveSurfaceDocument( + string type, + string schema, + string providerId, + string providerKind, + string title, + long version, + string updatedAtUtc, + EveSurfaceTree surface, + IReadOnlyList commands) + { + Type = string.IsNullOrWhiteSpace(type) ? DefaultType : type; + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + 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 Type { get; } + + [Key(1)] + public string Schema { get; } + + [Key(2)] + public string ProviderId { get; } + + [Key(3)] + public string ProviderKind { get; } + + [Key(4)] + public string Title { get; } + + [Key(5)] + public long Version { get; } + + [Key(6)] + public string UpdatedAtUtc { get; } + + [Key(7)] + public EveSurfaceTree Surface { get; } + + [Key(8)] + 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, + IReadOnlyList children) + : this(id, kind, props, children, StateBindingsFromProps(props)) + { + } + + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary props, + IReadOnlyList children, + IReadOnlyList stateBindings) + : this(id, kind, props, children, stateBindings, Array.Empty()) + { + } + + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary props, + IReadOnlyList children, + IReadOnlyList stateBindings, + IReadOnlyList embeddedDocuments, + IReadOnlyDictionary? layout = null, + IReadOnlyDictionary? style = null) + : this( + id, + kind, + props, + children, + (stateBindings ?? Array.Empty()) + .Select(CultMeshStateBindingRecord.FromBinding) + .ToArray(), + embeddedDocuments, + layout, + style) + { + } + + [SerializationConstructor] + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary props, + IReadOnlyList children, + CultMeshStateBindingRecord[] stateBindingRecords, + IReadOnlyList embeddedDocuments, + IReadOnlyDictionary? layout = null, + IReadOnlyDictionary? style = null) + { + Id = id ?? ""; + Kind = kind ?? ""; + Props = props ?? new Dictionary(StringComparer.Ordinal); + Children = children ?? Array.Empty(); + StateBindingRecords = stateBindingRecords ?? Array.Empty(); + EmbeddedDocuments = embeddedDocuments ?? Array.Empty(); + Layout = layout ?? new Dictionary(StringComparer.Ordinal); + Style = style ?? new Dictionary(StringComparer.Ordinal); + } + + [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 CultMeshStateBindingRecord[] StateBindingRecords { get; } + + [IgnoreMember] + public IReadOnlyList StateBindings => + StateBindingRecords.Select(record => record.ToBinding()).ToArray(); + + [Key(5)] + public IReadOnlyList EmbeddedDocuments { get; } + + [Key(6)] + public IReadOnlyDictionary Layout { get; } + + [Key(7)] + public IReadOnlyDictionary Style { get; } + + public string GetProp(string key, string fallback = "") + { + return Props.TryGetValue(key, out var value) ? value : fallback; + } + + 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(new CultMeshStateBindingDescriptor(targetProp, prop.Value)); + } + + return bindings; + } + } + + [MessagePackObject] + public sealed class EveEmbeddedDocumentSlot + { + public EveEmbeddedDocumentSlot( + string slotId, + string documentId, + string schemaId, + string presentationKind, + CultMeshRouteHint? routeHint = null) + : this(slotId, documentId, schemaId, presentationKind, CultMeshRouteRecord.FromRoute(routeHint)) + { + } + + [SerializationConstructor] + public EveEmbeddedDocumentSlot( + string slotId, + string documentId, + string schemaId, + string presentationKind, + CultMeshRouteRecord route) + { + SlotId = slotId ?? ""; + DocumentId = documentId ?? ""; + SchemaId = schemaId ?? ""; + PresentationKind = presentationKind ?? ""; + Route = route ?? CultMeshRouteRecord.FromRoute(CultMeshRouteHint.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 CultMeshRouteRecord Route { get; } + + [IgnoreMember] + public CultMeshRouteHint RouteHint => Route.ToRoute(); + } + + [MessagePackObject] + public sealed class EveStyleToken + { + [SerializationConstructor] + public EveStyleToken(string name, string value) + { + Name = name ?? ""; + Value = value ?? ""; + } + + [Key(0)] + public string Name { get; } + + [Key(1)] + public string Value { get; } + } + + [MessagePackObject] + [MessagePackFormatter(typeof(EveCommandTemplateCompatibilityFormatter))] + public sealed class EveCommandTemplate + { + public EveCommandTemplate(CultMeshOperationBindingDescriptor operation) + : this(CultMeshOperationBindingRecord.FromBinding(operation)) + { + } + + [SerializationConstructor] + public EveCommandTemplate(CultMeshOperationBindingRecord operationRecord) + { + OperationRecord = operationRecord ?? throw new ArgumentNullException(nameof(operationRecord)); + } + + [Key(0)] + public CultMeshOperationBindingRecord OperationRecord { get; } + + [IgnoreMember] + public CultMeshOperationBindingDescriptor Operation => OperationRecord.ToBinding(); + + [IgnoreMember] + public string Command => Operation.OperationId; + + [IgnoreMember] + public string Label => Operation.Label; + + [IgnoreMember] + public string Transport => Operation.RouteHint.Description ?? ""; + } + + [CultDocument("gamecult.eve.command_invocation", SchemaId)] + [MessagePackObject] + public sealed class EveSurfaceCommandRequest + { + public const string SchemaId = "gamecult.eve.command_invocation.v1"; + + public EveSurfaceCommandRequest( + string providerId, + string surfaceId, + CultMeshOperationInvocationDescriptor operation, + CultMeshOperationPayload payload, + DateTimeOffset issuedAt, + string clientId, + string commandBoundary = "", + string receiptSchema = "") + : this( + SchemaId, + providerId, + surfaceId, + CultMeshOperationInvocationRecord.FromInvocation(operation), + payload?.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal), + issuedAt, + clientId, + commandBoundary, + receiptSchema) + { + } + + [SerializationConstructor] + public EveSurfaceCommandRequest( + string schema, + string providerId, + string surfaceId, + CultMeshOperationInvocationRecord operation, + Dictionary payload, + DateTimeOffset issuedAt, + string clientId, + string commandBoundary, + string receiptSchema) + { + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + ProviderId = providerId; + SurfaceId = surfaceId; + OperationRecord = operation ?? throw new ArgumentNullException(nameof(operation)); + PayloadFields = payload == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(payload, StringComparer.Ordinal); + IssuedAt = issuedAt; + ClientId = clientId; + CommandBoundary = commandBoundary ?? ""; + ReceiptSchema = receiptSchema ?? ""; + } + + [Key(0)] + public string Schema { get; } + + [Key(1)] + public string ProviderId { get; } + + [Key(2)] + public string SurfaceId { get; } + + [Key(3)] + public CultMeshOperationInvocationRecord OperationRecord { get; } + + [IgnoreMember] + public CultMeshOperationInvocationDescriptor Operation => OperationRecord.ToInvocation(); + + [IgnoreMember] + public string Command => Operation.OperationId; + + [Key(4)] + public Dictionary PayloadFields { get; } + + [IgnoreMember] + public CultMeshOperationPayload Payload => new CultMeshOperationPayload(PayloadFields); + + [Key(5)] + public DateTimeOffset IssuedAt { get; } + + [Key(6)] + public string ClientId { get; } + + [Key(7)] + public string CommandBoundary { get; } + + [Key(8)] + public string ReceiptSchema { get; } + + [IgnoreMember] + public string CommandId => Operation.IdempotencyKey ?? ""; + } + + /// + /// Reads the current Eve surface envelope and the original seven-field v1 envelope. + /// + public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter + { + private const int LegacyFieldCount = 7; + private const int CurrentFieldCount = 9; + + public void Serialize(ref MessagePackWriter writer, EveSurfaceDocument? value, MessagePackSerializerOptions options) + { + if (value == null) { writer.WriteNil(); return; } + writer.WriteArrayHeader(CurrentFieldCount); + writer.Write(value.Type); + writer.Write(value.Schema); + writer.Write(value.ProviderId); + writer.Write(value.ProviderKind); + writer.Write(value.Title); + writer.Write(value.Version); + writer.Write(value.UpdatedAtUtc); + Formatter(options).Serialize(ref writer, value.Surface, options); + Formatter>(options).Serialize(ref writer, value.Commands, options); + } + + public EveSurfaceDocument? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) return null; + if (reader.NextMessagePackType != MessagePackType.Array) + throw new MessagePackSerializationException("Eve surface document must be an array."); + + options.Security.DepthStep(ref reader); + try + { + var fields = reader.ReadArrayHeader(); + if (fields == LegacyFieldCount) + { + return new EveSurfaceDocument( + ReadString(ref reader), ReadString(ref reader), ReadString(ref reader), + reader.ReadInt64(), ReadString(ref reader), + Formatter(options).Deserialize(ref reader, options)!, + Formatter>(options).Deserialize(ref reader, options) + ?? Array.Empty()); + } + + var type = ReadString(ref reader, fields, 0); + var schema = ReadString(ref reader, fields, 1); + var providerId = ReadString(ref reader, fields, 2); + var providerKind = ReadString(ref reader, fields, 3); + var title = ReadString(ref reader, fields, 4); + var version = fields > 5 ? reader.ReadInt64() : 0; + var updatedAtUtc = ReadString(ref reader, fields, 6); + var surface = fields > 7 ? Formatter(options).Deserialize(ref reader, options) : null; + var commands = fields > 8 ? Formatter>(options).Deserialize(ref reader, options) : null; + for (var index = CurrentFieldCount; index < fields; index++) reader.Skip(); + return new EveSurfaceDocument( + type, schema, providerId, providerKind, title, version, updatedAtUtc, + surface ?? throw new MessagePackSerializationException("Eve surface tree is required."), + commands ?? Array.Empty()); + } + finally { reader.Depth--; } + } + + private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) => + options.Resolver.GetFormatter() + ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}."); + + private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? ""; + private static string ReadString(ref MessagePackReader reader, int fields, int index) => + index < fields ? ReadString(ref reader) : ""; + } + + /// + /// Reads both direct legacy command fields and the current wrapped binding record. + /// + public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, EveCommandTemplate? value, MessagePackSerializerOptions options) + { + if (value == null) { writer.WriteNil(); return; } + writer.WriteArrayHeader(1); + BindingFormatter(options).Serialize(ref writer, value.OperationRecord, options); + } + + public EveCommandTemplate? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) return null; + if (reader.NextMessagePackType != MessagePackType.Array) + throw new MessagePackSerializationException("Eve command template must be an array."); + + options.Security.DepthStep(ref reader); + try + { + var fields = reader.ReadArrayHeader(); + if (fields == 1 && reader.NextMessagePackType == MessagePackType.Array) + { + return new EveCommandTemplate( + BindingFormatter(options).Deserialize(ref reader, options) + ?? new CultMeshOperationBindingRecord()); + } + + var operationId = ReadString(ref reader, fields, 0); + var label = ReadString(ref reader, fields, 1); + var schemaId = ReadString(ref reader, fields, 2); + var routeKind = ReadString(ref reader, fields, 3); + var routeDescription = ReadString(ref reader, fields, 4); + for (var index = 5; index < fields; index++) reader.Skip(); + return new EveCommandTemplate(new CultMeshOperationBindingRecord( + operationId, label, schemaId, routeKind, routeDescription)); + } + finally { reader.Depth--; } + } + + private static IMessagePackFormatter BindingFormatter( + MessagePackSerializerOptions options) => + options.Resolver.GetFormatter() + ?? throw new MessagePackSerializationException("No CultMesh operation binding formatter is registered."); + + private static string ReadString(ref MessagePackReader reader, int fields, int index) => + index < fields ? reader.ReadString() ?? "" : ""; + } + +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta new file mode 100644 index 0000000..834c814 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6516c42104b235f42b15efeb4639bb73 + diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef new file mode 100644 index 0000000..2240a47 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef @@ -0,0 +1,23 @@ +{ + "name": "GameCult.Eve.Surface", + "rootNamespace": "GameCult.Eve.Surface", + "references": [ + "GameCult.Mesh" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "GameCult.Caching.dll", + "GameCult.Mesh.dll", + "MessagePack.Annotations.dll", + "MessagePack.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} + + diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta new file mode 100644 index 0000000..64f708c --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: a9004278c14816345941af2f2ce673ca +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json new file mode 100644 index 0000000..0e245a1 --- /dev/null +++ b/packages/org.gamecult.eve.surface/package.json @@ -0,0 +1,29 @@ +{ + "name": "org.gamecult.eve.surface", + "displayName": "Eve Surface Contract", + "version": "0.3.0", + "unity": "2021.3", + "description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.", + "dependencies": { + "org.gamecult.cultlib": "1.0.43" + }, + "files": [ + "Runtime", + "Runtime.meta", + "README.md", + "README.md.meta", + "Directory.Build.props", + "GameCult.Eve.Surface.csproj", + "GameCult.Eve.Surface.csproj.meta", + "package.json.meta" + ], + "keywords": [ + "Eve", + "CultUI", + "CultMesh", + "Surface" + ], + "license": "MPL-2.0", + "category": "Scripting" +} + diff --git a/packages/org.gamecult.eve.surface/package.json.meta b/packages/org.gamecult.eve.surface/package.json.meta new file mode 100644 index 0000000..5d44ca4 --- /dev/null +++ b/packages/org.gamecult.eve.surface/package.json.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 49f1738bd94075e489c82190cf660b1d +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore new file mode 100644 index 0000000..ccae00c --- /dev/null +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore @@ -0,0 +1,3 @@ +!GameCult.Eve.Surface.Serialization.Tests.csproj + + diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs new file mode 100644 index 0000000..40fd3d0 --- /dev/null +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -0,0 +1,268 @@ +using System.Buffers; +using GameCult.Eve.Surface; +using GameCult.Mesh; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Eve.Surface.Tests; + +[TestFixture] +public sealed class EveSurfaceSerializationCompatibilityTests +{ + [Test] + public void EntitySoaV2SerializesLogicalLayoutWithoutTransportFields() + { + var document = new EveEntitySoaViewDocument + { + ProviderId = "provider", + ViewId = "entities", + BodySchemaId = "entity.slab.v1", + LayoutVersion = 2, + ProducerEpoch = 3, + Sequence = 4, + Capacity = 8, + FrameId = 91, + Buffers = new[] { new EveEntitySoaBuffer { BufferId = "entity-hot", ByteLength = 64 } }, + Identities = new[] + { + new EveEntityIdentity + { + Index = 0, + EntityId = "player", + EntityKind = "ship", + Label = "Vanguard", + Faction = "alliance", + Selectable = true, + Controllable = true, + AssetRef = "cultmesh://assets/player" + } + } + }; + + var bytes = MessagePackSerializer.Serialize(document, Options); + var reader = new MessagePackReader(bytes); + var restored = MessagePackSerializer.Deserialize(bytes, Options); + + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(15)); + Assert.That(restored.Schema, Is.EqualTo(EveEntitySoaViewDocument.SchemaId)); + Assert.That(restored.Buffers[0].BufferId, Is.EqualTo("entity-hot")); + Assert.That(restored.FrameId, Is.EqualTo(91)); + Assert.That(restored.Identities[0].Label, Is.EqualTo("Vanguard")); + Assert.That(restored.Identities[0].Faction, Is.EqualTo("alliance")); + Assert.That(restored.Identities[0].Selectable, Is.True); + Assert.That(restored.Identities[0].Controllable, Is.True); + Assert.That(restored.Identities[0].AssetRef, Is.EqualTo("cultmesh://assets/player")); + } + + [Test] + public void EntitySoaContractsExposeNoTransportAuthorityFields() + { + var forbidden = new[] { "Backend", "Location", "CapabilityToken", "Synchronization", "SynchronizationMode", "Descriptor" }; + var exposed = new[] { typeof(EveEntitySoaViewDocument), typeof(EveEntitySoaBuffer), typeof(EveEntitySoaColumn) } + .SelectMany(type => type.GetProperties()) + .Select(property => property.Name); + + Assert.That(exposed, Has.None.Matches(name => forbidden.Contains(name, StringComparer.Ordinal))); + } + + private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard; + + [Test] + public void LegacySevenFieldSurfaceDocumentDeserializes() + { + var bytes = WriteLegacySurfaceDocument(); + + var document = MessagePackSerializer.Deserialize(bytes, Options); + + Assert.Multiple(() => + { + Assert.That(document.Type, Is.EqualTo(EveSurfaceDocument.DefaultType)); + Assert.That(document.Schema, Is.EqualTo(EveSurfaceDocument.SchemaId)); + Assert.That(document.ProviderId, Is.EqualTo("legacy-provider")); + Assert.That(document.ProviderKind, Is.EqualTo("legacy-kind")); + Assert.That(document.Title, Is.EqualTo("Legacy surface")); + Assert.That(document.Version, Is.EqualTo(17)); + Assert.That(document.UpdatedAtUtc, Is.EqualTo("2026-07-13T12:00:00Z")); + Assert.That(document.Surface.Id, Is.EqualTo("legacy-surface")); + Assert.That(document.Commands, Has.Count.EqualTo(1)); + Assert.That(document.Commands[0].Command, Is.EqualTo("legacy.execute")); + }); + } + + [Test] + public void LegacyDirectFiveStringCommandTemplateDeserializes() + { + var bytes = WriteLegacyCommandTemplate(); + + var command = MessagePackSerializer.Deserialize(bytes, Options); + + Assert.Multiple(() => + { + Assert.That(command.Command, Is.EqualTo("legacy.execute")); + Assert.That(command.Label, Is.EqualTo("Execute legacy command")); + Assert.That(command.Operation.SchemaId, Is.EqualTo("gamecult.legacy.command.v1")); + Assert.That(command.OperationRecord.RouteKind, Is.EqualTo("cultmesh")); + Assert.That(command.OperationRecord.RouteDescription, Is.EqualTo("legacy.commands")); + Assert.That(command.Transport, Is.EqualTo("legacy.commands")); + }); + } + + [Test] + public void CurrentSerializationWritesNineFieldSurfaceAndWrappedStructuredCommandBinding() + { + var document = CreateCurrentDocument(); + + var bytes = MessagePackSerializer.Serialize(document, Options); + var reader = new MessagePackReader(bytes); + + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9)); + for (var field = 0; field < 8; field++) + reader.Skip(); + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "commands collection"); + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "command template wrapper"); + Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Array), "structured operation binding"); + + var roundTrip = MessagePackSerializer.Deserialize(bytes, Options); + Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute")); + } + + [Test] + public void LegacyExpandedProviderAdvertisementDeserializesAsCanonicalContract() + { + var document = MessagePackSerializer.Deserialize( + WriteLegacyProviderAdvertisement(), + Options); + + Assert.Multiple(() => + { + Assert.That(document.ProviderId, Is.EqualTo("aetheria")); + Assert.That(document.ServiceId, Is.EqualTo("aetheria-daemon")); + Assert.That(document.VerseId, Is.EqualTo("aetheria.local")); + Assert.That(document.Title, Is.EqualTo("Aetheria")); + Assert.That(document.CultMeshAddress, Is.EqualTo("cultmesh://aetheria.local/aetheria")); + Assert.That(document.Surfaces[0].SurfaceId, Is.EqualTo("aetheria.pilot")); + Assert.That(document.Surfaces[0].Schema, Is.EqualTo(EveSurfaceDocument.SchemaId)); + Assert.That(document.Commands[0].Command, Is.EqualTo("aetheria.pilot.move")); + Assert.That(document.Commands[0].SurfaceId, Is.Empty); + }); + } + + [Test] + public void CurrentProviderAdvertisementSerializationWritesCanonicalFourteenFields() + { + var document = new EveProviderAdvertisementDocument( + "provider", "service", "verse", "Title", "game.runtime", "cultmesh://verse/provider", + "2026-07-13T14:00:00Z", new EveProviderFreshness("fresh", "2026-07-13T14:00:00Z", 15000), + new[] { EveSurfaceDocument.SchemaId }, Array.Empty(), + Array.Empty(), Array.Empty(), + new[] { "body-producer" }); + + var bytes = MessagePackSerializer.Serialize(document, Options); + var reader = new MessagePackReader(bytes); + + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(14)); + var roundTrip = MessagePackSerializer.Deserialize(bytes, Options); + Assert.Multiple(() => + { + Assert.That(roundTrip.Title, Is.EqualTo("Title")); + Assert.That(roundTrip.AuthorizedBodyProducerIds, Is.EqualTo(new[] { "body-producer" })); + }); + } + + private static EveSurfaceDocument CreateCurrentDocument() => new( + "current-provider", + "current-kind", + "Current surface", + 23, + "2026-07-13T13:00:00Z", + CreateSurfaceTree("current-surface"), + new[] + { + new EveCommandTemplate(new CultMeshOperationBindingRecord( + "current.execute", + "Execute current command", + "gamecult.current.command.v1", + "cultmesh", + "current.commands")) + }); + + private static EveSurfaceTree CreateSurfaceTree(string id) => new( + id, + new EveSurfaceComponent( + "root", + "column", + new Dictionary(), + Array.Empty()), + Array.Empty()); + + private static byte[] WriteLegacySurfaceDocument() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(7); + writer.Write("legacy-provider"); + writer.Write("legacy-kind"); + writer.Write("Legacy surface"); + writer.Write(17L); + writer.Write("2026-07-13T12:00:00Z"); + writer.WriteRaw(MessagePackSerializer.Serialize(CreateSurfaceTree("legacy-surface"), Options)); + writer.WriteArrayHeader(1); + writer.WriteRaw(WriteLegacyCommandTemplate()); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private static byte[] WriteLegacyCommandTemplate() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(5); + writer.Write("legacy.execute"); + writer.Write("Execute legacy command"); + writer.Write("gamecult.legacy.command.v1"); + writer.Write("cultmesh"); + writer.Write("legacy.commands"); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private static byte[] WriteLegacyProviderAdvertisement() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(16); + writer.Write(EveProviderAdvertisementDocument.SchemaId); + writer.Write("aetheria"); + writer.Write("aetheria-daemon"); + writer.Write("aetheria.local"); + writer.Write("asgard"); + writer.Write("aetheria"); + writer.Write("aetheria.local/aetheria"); + writer.Write("cultmesh://aetheria.local/aetheria"); + writer.Write("Aetheria"); + writer.Write("game.runtime"); + writer.Write("2026-07-13T14:00:00Z"); + writer.WriteArrayHeader(3); + writer.Write("fresh"); + writer.Write("2026-07-13T14:00:00Z"); + writer.Write(15000); + writer.WriteArrayHeader(1); + writer.Write(EveSurfaceDocument.SchemaId); + writer.WriteArrayHeader(0); + writer.WriteArrayHeader(1); + writer.WriteArrayHeader(5); + writer.Write(EveSurfaceDocument.SchemaId); + writer.Write("aetheria.pilot"); + writer.Write("eve:surface:aetheria.pilot"); + writer.Write("cultmesh"); + writer.Write("available"); + writer.WriteArrayHeader(1); + writer.WriteArrayHeader(3); + writer.Write("aetheria.pilot.move"); + writer.Write("cultmesh"); + writer.Write("Move the pilot ship"); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } +} + diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj b/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj new file mode 100644 index 0000000..85d1b96 --- /dev/null +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + true + obj\bin\ + + + + + + + + + + + + + +