Own the portable Eve surface contract

This commit is contained in:
Meta Vens 2026-08-17 21:14:55 +02:00
commit dd196508ac
33 changed files with 2878 additions and 0 deletions

2
.gitignore vendored
View file

@ -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/

View file

@ -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

View file

@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<BaseOutputPath>$(MSBuildThisFileDirectory)bin~\</BaseOutputPath>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)obj~\</BaseIntermediateOutputPath>
<MSBuildProjectExtensionsPath>$(MSBuildThisFileDirectory)obj~\</MSBuildProjectExtensionsPath>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Description>Portable Eve surface and provider contracts.</Description>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: ce2d4913d64690c44ae68935014fd79d
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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.

View file

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: fe5aa27636bead047b2aecb14fdc59f2
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 2164f6fea1216214d895e7dc99c46ecf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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<EveAssetCatalogEntry>();
}
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<string, string> metadata)
{
AssetRef = assetRef ?? "";
AssetKind = assetKind ?? "";
Variants = variants ?? Array.Empty<EveAssetVariant>();
Metadata = metadata == null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(metadata, StringComparer.Ordinal);
}
public EveAssetCatalogEntry(
string assetRef,
string assetKind,
EveAssetVariant[] variants,
IReadOnlyDictionary<string, string>? metadata = null)
: this(
assetRef,
assetKind,
variants,
metadata == null ? new Dictionary<string, string>() : new Dictionary<string, string>(metadata))
{
}
[Key(0)] public string AssetRef { get; }
[Key(1)] public string AssetKind { get; }
[Key(2)] public EveAssetVariant[] Variants { get; }
[Key(3)] public Dictionary<string, string> 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<string, string> metadata)
{
RuntimeId = runtimeId ?? "";
Platform = platform ?? "";
Format = format ?? "";
Uri = uri ?? "";
ContentHash = contentHash ?? "";
SizeBytes = sizeBytes;
AssetKey = assetKey ?? "";
Metadata = metadata == null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(metadata, StringComparer.Ordinal);
}
public EveAssetVariant(
string runtimeId,
string platform,
string format,
string uri,
string contentHash,
long sizeBytes,
string assetKey,
IReadOnlyDictionary<string, string>? metadata = null)
: this(
runtimeId,
platform,
format,
uri,
contentHash,
sizeBytes,
assetKey,
metadata == null ? new Dictionary<string, string>() : new Dictionary<string, string>(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<string, string> Metadata { get; }
}
}

View file

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8f53654f7c98d894588127ba001f11b4

View file

@ -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<EveEntitySoaBuffer>();
[Key(10)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty<EveEntitySoaColumn>();
[Key(11)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty<EveEntitySoaDirtyRange>();
[Key(12)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty<EveEntityRenderGroup>();
[Key(13)] public EveEntityIdentity[] Identities { get; set; } = Array.Empty<EveEntityIdentity>();
[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;
}
}

View file

@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: 8e87e1be783846508efea45787f2d45c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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<EveInputActionDocument>();
[Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty<EveInputProfileDocument>();
}
[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<string, string> Payload { get; set; } = new Dictionary<string, string>(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<string>();
[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<EveInputBindingDocument>();
}
[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<string>();
[Key(2)] public int MaxStepIntervalMs { get; set; } = 650;
[Key(3)] public string CompletionControl { get; set; } = "";
}
}

View file

@ -0,0 +1,4 @@
fileFormatVersion: 2
guid: 4c297c7dbb64a0f49a7f805e770c5e55

View file

@ -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<EveInputLayoutControl> Controls { get; set; } = Array.Empty<EveInputLayoutControl>();
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;
}
}

View file

@ -0,0 +1,4 @@
fileFormatVersion: 2
guid: 8a2a1258551e42a9831a3e4f9483d928

View file

@ -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<string> Controls { get; set; } = Array.Empty<string>();
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<EveInputBinding> _bindings;
private readonly HashSet<string> _pressed = new HashSet<string>(StringComparer.Ordinal);
private readonly Dictionary<string, SequenceState> _sequences = new Dictionary<string, SequenceState>(StringComparer.Ordinal);
public EveInputGestureResolver(IEnumerable<EveInputBinding> bindings)
{
_bindings = (bindings ?? Array.Empty<EveInputBinding>()).Where(binding => binding != null).ToArray();
}
public IReadOnlyList<string> Press(string control, long timestampMs)
{
_pressed.Add(control ?? "");
var actions = new HashSet<string>(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; }
}
}
}

View file

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e3c2941be243a754092ac2543d1e6bf7

View file

@ -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<string, string>(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;
}
}

View file

@ -0,0 +1,4 @@
fileFormatVersion: 2
guid: 17fd4173e6e44b4b9c503d343021ffcb

View file

@ -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<string> schemas,
IReadOnlyList<EveProviderWitness> witnesses,
IReadOnlyList<EveAdvertisedSurface> surfaces,
IReadOnlyList<EveAdvertisedCommand> commands,
IReadOnlyList<string>? 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<string> schemas,
IReadOnlyList<EveProviderWitness> witnesses,
IReadOnlyList<EveAdvertisedSurface> surfaces,
IReadOnlyList<EveAdvertisedCommand> commands,
IReadOnlyList<string>? 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<string>();
Witnesses = witnesses ?? Array.Empty<EveProviderWitness>();
Surfaces = surfaces ?? Array.Empty<EveAdvertisedSurface>();
Commands = commands ?? Array.Empty<EveAdvertisedCommand>();
AuthorizedBodyProducerIds = authorizedBodyProducerIds ?? Array.Empty<string>();
}
[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<string> Schemas { get; }
[Key(10)] public IReadOnlyList<EveProviderWitness> Witnesses { get; }
[Key(11)] public IReadOnlyList<EveAdvertisedSurface> Surfaces { get; }
[Key(12)] public IReadOnlyList<EveAdvertisedCommand> Commands { get; }
[Key(13)] public IReadOnlyList<string> AuthorizedBodyProducerIds { get; }
}
/// <summary>
/// Reads canonical provider advertisements and the original Aetheria-expanded v1 layout.
/// </summary>
public sealed class EveProviderAdvertisementCompatibilityFormatter :
IMessagePackFormatter<EveProviderAdvertisementDocument?>
{
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<EveProviderFreshness>(options).Serialize(ref writer, value.Freshness, options);
Formatter<IReadOnlyList<string>>(options).Serialize(ref writer, value.Schemas, options);
Formatter<IReadOnlyList<EveProviderWitness>>(options).Serialize(ref writer, value.Witnesses, options);
Formatter<IReadOnlyList<EveAdvertisedSurface>>(options).Serialize(ref writer, value.Surfaces, options);
Formatter<IReadOnlyList<EveAdvertisedCommand>>(options).Serialize(ref writer, value.Commands, options);
Formatter<IReadOnlyList<string>>(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<EveProviderFreshness>(options).Deserialize(ref reader, options) : null;
var schemas = fields > 9 ? Formatter<IReadOnlyList<string>>(options).Deserialize(ref reader, options) : null;
var witnesses = fields > 10 ? Formatter<IReadOnlyList<EveProviderWitness>>(options).Deserialize(ref reader, options) : null;
var surfaces = fields > 11 ? Formatter<IReadOnlyList<EveAdvertisedSurface>>(options).Deserialize(ref reader, options) : null;
var commands = fields > 12 ? Formatter<IReadOnlyList<EveAdvertisedCommand>>(options).Deserialize(ref reader, options) : null;
var authorizedBodyProducerIds = fields > 13 ? Formatter<IReadOnlyList<string>>(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<string>(),
witnesses ?? Array.Empty<EveProviderWitness>(),
surfaces ?? Array.Empty<EveAdvertisedSurface>(),
commands ?? Array.Empty<EveAdvertisedCommand>(),
authorizedBodyProducerIds ?? Array.Empty<string>());
}
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<EveProviderFreshness>(options).Deserialize(ref reader, options)
?? new EveProviderFreshness("unknown", "", 0);
var schemas = Formatter<IReadOnlyList<string>>(options).Deserialize(ref reader, options)
?? Array.Empty<string>();
var witnesses = Formatter<IReadOnlyList<EveProviderWitness>>(options).Deserialize(ref reader, options)
?? Array.Empty<EveProviderWitness>();
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<T> Formatter<T>(MessagePackSerializerOptions options) =>
options.Resolver.GetFormatter<T>()
?? 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<string> stateSchemas,
string commandBoundary,
string commandRecordRef,
string receiptSchema,
string receiptRecordRef,
string assetManifestRecordRef,
IReadOnlyList<string> loweringTargets,
string ownership)
{
ProjectionKind = projectionKind ?? "";
StateSchemas = stateSchemas ?? Array.Empty<string>();
CommandBoundary = commandBoundary ?? "";
CommandRecordRef = commandRecordRef ?? "";
ReceiptSchema = receiptSchema ?? "";
ReceiptRecordRef = receiptRecordRef ?? "";
AssetManifestRecordRef = assetManifestRecordRef ?? "";
LoweringTargets = loweringTargets ?? Array.Empty<string>();
Ownership = ownership ?? "";
}
[Key(0)] public string ProjectionKind { get; }
[Key(1)] public IReadOnlyList<string> 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<string> 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; }
}
}

View file

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f7ea0cd07c4d5da4099b5acf8d44ca2e

View file

@ -0,0 +1,876 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Mesh;
#nullable enable
namespace GameCult.Eve.Surface
{
/// <summary>
/// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors.
/// </summary>
public sealed class EveSurfaceBuilder
{
private readonly string _surfaceId;
private readonly List<EveSurfaceComponent> _children = new();
private readonly List<EveStyleToken> _styles = new();
private readonly List<EveCommandTemplate> _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<EveSurfaceGroupBuilder> build)
{
return Group(id, "column", build);
}
public EveSurfaceBuilder ButtonRow(string id, Action<EveSurfaceGroupBuilder> build)
{
return Group(id, "row", build);
}
public EveSurfaceBuilder Form(string id, Action<EveSurfaceFormBuilder> 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<EveSurfaceContainerBuilder> build)
{
_root = BuildContainer(id, "partition", Props(("split", "y")), build);
return this;
}
public EveSurfaceBuilder RootRow(string id, Action<EveSurfaceContainerBuilder> build)
{
_root = BuildContainer(id, "partition", Props(("split", "x")), build);
return this;
}
public EveSurfaceBuilder RootGrid(
string id,
int columns,
int rows,
Action<EveSurfaceContainerBuilder> 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<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "partition", Props(("split", "x")), build));
return this;
}
public EveSurfaceBuilder Column(string id, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "partition", Props(("split", "y")), build));
return this;
}
public EveSurfaceBuilder Grid(string id, int columns, int rows, Action<EveSurfaceContainerBuilder> 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<EveSurfaceContainerBuilder> 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<string, string>(StringComparer.Ordinal)
{
["slotId"] = slotId ?? "",
["documentId"] = documentId ?? "",
["schemaId"] = schemaId ?? "",
["presentationKind"] = presentationKind ?? ""
},
Array.Empty<EveSurfaceComponent>(),
Array.Empty<CultMeshStateBindingDescriptor>(),
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<EveSurfaceGroupBuilder> 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<string, string> props,
Action<EveSurfaceContainerBuilder> 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<string, string>(StringComparer.Ordinal)
{
["value"] = value ?? ""
},
Array.Empty<EveSurfaceComponent>());
}
internal static EveSurfaceComponent ButtonComponent(
string id,
string label,
CultMeshOperationBindingDescriptor operation)
{
return new EveSurfaceComponent(
id,
"control.button",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? operation.Label,
["command"] = operation.OperationId,
["operationId"] = operation.OperationId,
["schemaId"] = operation.SchemaId
},
Array.Empty<EveSurfaceComponent>());
}
internal static Dictionary<string, string> EmptyProps()
{
return new Dictionary<string, string>(StringComparer.Ordinal);
}
internal static Dictionary<string, string> 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<string, string> _props;
private readonly Action<EveCommandTemplate> _addCommand;
private readonly List<EveSurfaceComponent> _children = new();
private readonly List<CultMeshStateBindingDescriptor> _stateBindings = new();
private readonly List<EveEmbeddedDocumentSlot> _embeddedDocuments = new();
internal EveSurfaceContainerBuilder(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
Action<EveCommandTemplate> addCommand)
{
_id = id ?? "";
_kind = kind ?? "partition";
_props = new Dictionary<string, string>(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<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "x")), build));
return this;
}
public EveSurfaceContainerBuilder Column(string id, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "y")), build));
return this;
}
public EveSurfaceContainerBuilder Grid(
string id,
int columns,
int rows,
Action<EveSurfaceContainerBuilder> 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<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "pane", EveSurfaceBuilder.Props(("title", title ?? "")), build));
return this;
}
public EveSurfaceContainerBuilder Card(string id, string title, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "card", EveSurfaceBuilder.Props(("title", title ?? "")), build));
return this;
}
public EveSurfaceContainerBuilder Modal(string id, string title, Action<EveSurfaceContainerBuilder> build)
{
_children.Add(BuildContainer(id, "modal", EveSurfaceBuilder.Props(("title", title ?? "")), build));
return this;
}
public EveSurfaceContainerBuilder FieldRow(
string id,
string label,
Action<EveSurfaceContainerBuilder> 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<EveSurfaceContainerBuilder>? 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<EveSurfaceContainerBuilder> 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<EveSurfaceComponent>(),
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<EveSurfaceComponent>(),
Array.Empty<CultMeshStateBindingDescriptor>(),
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<string, string> props,
CultMeshStateBindingDescriptor? binding = null,
IReadOnlyList<EveSurfaceComponent>? children = null,
Action<EveSurfaceContainerBuilder>? configure = null)
{
var builder = new EveSurfaceContainerBuilder(id, kind, props, _addCommand);
configure?.Invoke(builder);
var component = new EveSurfaceComponent(
id ?? "",
kind ?? "",
new Dictionary<string, string>(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal),
children ?? Array.Empty<EveSurfaceComponent>(),
binding == null ? Array.Empty<CultMeshStateBindingDescriptor>() : new[] { binding });
if (builder._props.Count != (props?.Count ?? 0))
{
component = new EveSurfaceComponent(
id ?? "",
kind ?? "",
new Dictionary<string, string>(builder._props, StringComparer.Ordinal),
children ?? Array.Empty<EveSurfaceComponent>(),
binding == null ? Array.Empty<CultMeshStateBindingDescriptor>() : 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<string, string> props,
Action<EveSurfaceContainerBuilder> 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<EveCommandTemplate> _addCommand;
private readonly List<EveSurfaceComponent> _children = new();
internal EveSurfaceGroupBuilder(
string id,
string kind,
Action<EveCommandTemplate> 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<EveCommandTemplate> _addCommand;
private readonly List<EveSurfaceComponent> _children = new();
internal EveSurfaceFormBuilder(string id, Action<EveCommandTemplate> 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<CultMeshStateBindingDescriptor>()
: new[] { binding };
_children.Add(new EveSurfaceComponent(
$"{_id}.metric.{EveSurfaceBuilder.Slug(label)}",
"metric",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? "",
["value"] = value ?? ""
},
Array.Empty<EveSurfaceComponent>(),
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<CultMeshStateBindingDescriptor>()
: new[] { binding };
return new EveSurfaceComponent(
$"{_id}.{EveSurfaceBuilder.Slug(label)}",
kind,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? "",
["value"] = value ?? "",
["command"] = operation.OperationId,
["operationId"] = operation.OperationId,
["schemaId"] = operation.SchemaId
},
Array.Empty<EveSurfaceComponent>(),
bindings);
}
}
public static class EveSurface
{
public static EveSurfaceBuilder Create(string surfaceId)
{
return new EveSurfaceBuilder(surfaceId);
}
}
}

View file

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 97ac7f757692aef4db64d75d0df6fe51

View file

@ -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<EveCommandTemplate> 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<EveCommandTemplate> 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<EveCommandTemplate>();
}
[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<EveCommandTemplate> Commands { get; }
}
[MessagePackObject]
public sealed class EveSurfaceTree
{
[SerializationConstructor]
public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList<EveStyleToken> styles)
{
Id = id ?? "";
Root = root ?? throw new ArgumentNullException(nameof(root));
Styles = styles ?? Array.Empty<EveStyleToken>();
}
[Key(0)]
public string Id { get; }
[Key(1)]
public EveSurfaceComponent Root { get; }
[Key(2)]
public IReadOnlyList<EveStyleToken> Styles { get; }
}
[MessagePackObject]
public sealed class EveSurfaceComponent
{
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children)
: this(id, kind, props, children, StateBindingsFromProps(props))
{
}
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children,
IReadOnlyList<CultMeshStateBindingDescriptor> stateBindings)
: this(id, kind, props, children, stateBindings, Array.Empty<EveEmbeddedDocumentSlot>())
{
}
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children,
IReadOnlyList<CultMeshStateBindingDescriptor> stateBindings,
IReadOnlyList<EveEmbeddedDocumentSlot> embeddedDocuments,
IReadOnlyDictionary<string, string>? layout = null,
IReadOnlyDictionary<string, string>? style = null)
: this(
id,
kind,
props,
children,
(stateBindings ?? Array.Empty<CultMeshStateBindingDescriptor>())
.Select(CultMeshStateBindingRecord.FromBinding)
.ToArray(),
embeddedDocuments,
layout,
style)
{
}
[SerializationConstructor]
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children,
CultMeshStateBindingRecord[] stateBindingRecords,
IReadOnlyList<EveEmbeddedDocumentSlot> embeddedDocuments,
IReadOnlyDictionary<string, string>? layout = null,
IReadOnlyDictionary<string, string>? style = null)
{
Id = id ?? "";
Kind = kind ?? "";
Props = props ?? new Dictionary<string, string>(StringComparer.Ordinal);
Children = children ?? Array.Empty<EveSurfaceComponent>();
StateBindingRecords = stateBindingRecords ?? Array.Empty<CultMeshStateBindingRecord>();
EmbeddedDocuments = embeddedDocuments ?? Array.Empty<EveEmbeddedDocumentSlot>();
Layout = layout ?? new Dictionary<string, string>(StringComparer.Ordinal);
Style = style ?? new Dictionary<string, string>(StringComparer.Ordinal);
}
[Key(0)]
public string Id { get; }
[Key(1)]
public string Kind { get; }
[Key(2)]
public IReadOnlyDictionary<string, string> Props { get; }
[Key(3)]
public IReadOnlyList<EveSurfaceComponent> Children { get; }
[Key(4)]
public CultMeshStateBindingRecord[] StateBindingRecords { get; }
[IgnoreMember]
public IReadOnlyList<CultMeshStateBindingDescriptor> StateBindings =>
StateBindingRecords.Select(record => record.ToBinding()).ToArray();
[Key(5)]
public IReadOnlyList<EveEmbeddedDocumentSlot> EmbeddedDocuments { get; }
[Key(6)]
public IReadOnlyDictionary<string, string> Layout { get; }
[Key(7)]
public IReadOnlyDictionary<string, string> Style { get; }
public string GetProp(string key, string fallback = "")
{
return Props.TryGetValue(key, out var value) ? value : fallback;
}
private static IReadOnlyList<CultMeshStateBindingDescriptor> StateBindingsFromProps(
IReadOnlyDictionary<string, string>? props)
{
if (props == null || props.Count == 0)
return Array.Empty<CultMeshStateBindingDescriptor>();
var bindings = new List<CultMeshStateBindingDescriptor>();
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<string, string>(StringComparer.Ordinal),
issuedAt,
clientId,
commandBoundary,
receiptSchema)
{
}
[SerializationConstructor]
public EveSurfaceCommandRequest(
string schema,
string providerId,
string surfaceId,
CultMeshOperationInvocationRecord operation,
Dictionary<string, string> 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<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(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<string, string> 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 ?? "";
}
/// <summary>
/// Reads the current Eve surface envelope and the original seven-field v1 envelope.
/// </summary>
public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter<EveSurfaceDocument?>
{
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<EveSurfaceTree>(options).Serialize(ref writer, value.Surface, options);
Formatter<IReadOnlyList<EveCommandTemplate>>(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<EveSurfaceTree>(options).Deserialize(ref reader, options)!,
Formatter<IReadOnlyList<EveCommandTemplate>>(options).Deserialize(ref reader, options)
?? Array.Empty<EveCommandTemplate>());
}
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<EveSurfaceTree>(options).Deserialize(ref reader, options) : null;
var commands = fields > 8 ? Formatter<IReadOnlyList<EveCommandTemplate>>(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<EveCommandTemplate>());
}
finally { reader.Depth--; }
}
private static IMessagePackFormatter<T> Formatter<T>(MessagePackSerializerOptions options) =>
options.Resolver.GetFormatter<T>()
?? 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) : "";
}
/// <summary>
/// Reads both direct legacy command fields and the current wrapped binding record.
/// </summary>
public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter<EveCommandTemplate?>
{
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<CultMeshOperationBindingRecord> BindingFormatter(
MessagePackSerializerOptions options) =>
options.Resolver.GetFormatter<CultMeshOperationBindingRecord>()
?? 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() ?? "" : "";
}
}

View file

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6516c42104b235f42b15efeb4639bb73

View file

@ -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
}

View file

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: a9004278c14816345941af2f2ce673ca
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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"
}

View file

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 49f1738bd94075e489c82190cf660b1d
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,3 @@
!GameCult.Eve.Surface.Serialization.Tests.csproj

View file

@ -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<EveEntitySoaViewDocument>(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<string>(name => forbidden.Contains(name, StringComparer.Ordinal)));
}
private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard;
[Test]
public void LegacySevenFieldSurfaceDocumentDeserializes()
{
var bytes = WriteLegacySurfaceDocument();
var document = MessagePackSerializer.Deserialize<EveSurfaceDocument>(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<EveCommandTemplate>(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<EveSurfaceDocument>(bytes, Options);
Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute"));
}
[Test]
public void LegacyExpandedProviderAdvertisementDeserializesAsCanonicalContract()
{
var document = MessagePackSerializer.Deserialize<EveProviderAdvertisementDocument>(
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<EveProviderWitness>(),
Array.Empty<EveAdvertisedSurface>(), Array.Empty<EveAdvertisedCommand>(),
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<EveProviderAdvertisementDocument>(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<string, string>(),
Array.Empty<EveSurfaceComponent>()),
Array.Empty<EveStyleToken>());
private static byte[] WriteLegacySurfaceDocument()
{
var buffer = new ArrayBufferWriter<byte>();
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<byte>();
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<byte>();
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();
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<BaseOutputPath>obj\bin\</BaseOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
</ItemGroup>
</Project>