diff --git a/TestProject/Packages/manifest.json b/TestProject/Packages/manifest.json index 9ac9aad..dd23f0a 100644 --- a/TestProject/Packages/manifest.json +++ b/TestProject/Packages/manifest.json @@ -5,7 +5,7 @@ "com.unity.modules.imageconversion": "1.0.0", "com.unity.modules.physics": "1.0.0", "org.gamecult.cultlib": "file:../../../CultLib/artifacts/unity/org.gamecult.cultlib", - "org.gamecult.eve.surface": "file:../../../Eve/packages/org.gamecult.eve.surface", + "org.gamecult.eve.surface": "file:../../packages/org.gamecult.eve.surface", "org.gamecult.eve.unity-scene": "file:../../packages/org.gamecult.eve.unity-scene" }, "testables": [ diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index c23969d..a34a129 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -23,7 +23,7 @@ "dependencies": {} }, "org.gamecult.eve.surface": { - "version": "file:../../../Eve/packages/org.gamecult.eve.surface", + "version": "file:../../packages/org.gamecult.eve.surface", "depth": 0, "source": "local", "dependencies": { diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md new file mode 100644 index 0000000..eb65c17 --- /dev/null +++ b/packages/org.gamecult.eve.surface/README.md @@ -0,0 +1,29 @@ +# Eve Surface Contract + +This package defines the retained CultUI surface document that every Eve +runtime lowers. + +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: + +- 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 `E:/Projects/CultLib/packages/cultnet-rs` + +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. 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..4bc751a --- /dev/null +++ b/packages/org.gamecult.eve.surface/README.md.meta @@ -0,0 +1,7 @@ +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..f150917 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime.meta @@ -0,0 +1,8 @@ +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..eba3b44 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs @@ -0,0 +1,144 @@ +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..0af6487 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f53654f7c98d894588127ba001f11b4 \ No newline at end of file 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..e9bb1da --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using GameCult.Caching; +using MessagePack; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.provider_advertisement", SchemaId)] + [MessagePackObject] + 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) + : this(SchemaId, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, freshness, schemas, witnesses, surfaces, commands) + { + } + + [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) + { + 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(); + } + + [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; } + } + + [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..f29c875 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f7ea0cd07c4d5da4099b5acf8d44ca2e \ No newline at end of file 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..2d88eb5 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs @@ -0,0 +1,874 @@ +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..adc41da --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97ac7f757692aef4db64d75d0df6fe51 \ No newline at end of file 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..c4eb051 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using GameCult.Mesh; +using MessagePack; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] + [MessagePackObject] + 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] + 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", 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 ?? ""; + } +} 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..ebd5f02 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6516c42104b235f42b15efeb4639bb73 \ No newline at end of file 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..8041c88 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef @@ -0,0 +1,20 @@ +{ + "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" + ], + "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..d8f543e --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta @@ -0,0 +1,7 @@ +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..8d57c63 --- /dev/null +++ b/packages/org.gamecult.eve.surface/package.json @@ -0,0 +1,18 @@ +{ + "name": "org.gamecult.eve.surface", + "displayName": "Eve Surface Contract", + "version": "0.1.0", + "unity": "2021.3", + "description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.", + "dependencies": { + "org.gamecult.cultlib": "1.0.3" + }, + "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..8e89223 --- /dev/null +++ b/packages/org.gamecult.eve.surface/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 49f1738bd94075e489c82190cf660b1d +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json index 417228f..3b61b23 100644 --- a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json @@ -129,7 +129,7 @@ "packageName": "org.gamecult.eve.surface", "version": "0.1.0", "packageManager": "upm", - "ownerRepo": "Eve", + "ownerRepo": "EveUnity", "purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents." } ] diff --git a/packages/org.gamecult.eve.unity-uitoolkit/README.md b/packages/org.gamecult.eve.unity-uitoolkit/README.md new file mode 100644 index 0000000..f61b026 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/README.md @@ -0,0 +1,150 @@ +# Eve UI Toolkit Lowering + +This package lowers `gamecult.eve.surface.v1` retained surface documents into +Unity UI Toolkit `VisualElement` trees. + +It owns native projection only. Providers still own truth, accepted state, +style token values, and command effects through CultMesh/CultNet. Unknown +component kinds degrade to inert containers instead of gaining local semantics. + +Plugin semantics enter through plugin ABI sidecars and advertisements, not +through Unity. This package includes first-party projection adapters for the +`sai.vn` projection surface (`vn.stage`, dialogue panels, action rails, and +story command requests), the `norn.graph` projection surface (`embed.norn`), +and the `tex.math` source-fallback projection surface (`embed.tex`, +`tex.inline`, and `tex.block`). Sai still owns story state and command +semantics. Norn still owns graph layout and graph semantics. TeX still owns +parsing, typesetting, baseline metrics, and cached render semantics. Unity only +owns native projection of already-declared plugin capabilities and emits +`gamecult.eve.command.v1` requests. + +This package lives in the Eve repository as the shared Unity lowering target. +Aetheria and other Unity consumers should import it from Eve instead of +carrying local copies. + +Nested CultUI regions use component `EmbeddedDocuments`. Pass an +`EmbeddedDocumentResolver` through `EveUiToolkitSurfaceOptions`; the lowerer +mounts the resolved child surface under the slot while preserving the child +document's command surface id. + +Discovery and test coverage for this feature live in the shared Eve parity +matrix: `../../tools/parity/parity-manifest.json` names `embeddedDocuments` and +`../../web/fixtures/cultui-embedded-surface.json` is the canonical fixture. +Unity consumers should keep verifier coverage that builds a parent surface with +a resolver-backed child, then checks for the embedded visual element rather than +copying child state into a local adapter. + +The runtime capability manifest is +`eve-runtime-capability.json`. Its lifecycle section records the current split +evidence: + +- release: typed UPM release request contract, UPM `.tgz` artifact smoke, and + incubating package identity; +- test: package-owned EditMode tests run through Aetheria in Unity batchmode, + plus Aetheria consumer-build smoke through Unity's generated project; +- capture: typed capture request contract and pending Unity editor or + batchmode PNG artifact. + +The release stage also declares the UPM release contract: +`org.gamecult.eve.unity-uitoolkit` is released from this package root, reads +its version from `package.json`, and uses tag pattern +`eveunity-uitoolkit-v{version}` once the tag is cut from EveUnity. +`tools/eveunity/eveunity-release-contract.mjs` builds a +`gamecult.eve.runtime_release_request.v1` request from the runtime capability +manifest and UPM package manifest. The request names the package version, tag, +artifact path, dependency set, required package dependency owners, and target +`GameCult/EveUnity` repository without pretending Eve has published the tagged +release. +`tools/eveunity/eveunity-release-artifact.mjs` consumes that request and runs +`npm pack` against the declared package root, producing the declared +`org.gamecult.eve.unity-uitoolkit-{version}.tgz` artifact as local smoke +evidence. The tagged release still belongs in the EveUnity repo. + +The test stage declares the Unity EditMode runner contract: the current runner +is `scripts/run-aetheria-unity-editmode-tests.ps1`, runs +`GameCult.Eve.UnityUIToolkit.Tests` on `EditMode`, writes XML and log artifacts +under `artifacts/aetheria-unity-editmode/{stamp}`, and temporarily adds +`org.gamecult.eve.unity-uitoolkit` to Aetheria's Unity `testables`. This is a +Unity package consumption proof. The test contract names the CultLib-owned +NuGet/precompiled assembly inputs Unity needs during incubation; CultLib still +owns the .NET/NuGet dependency story for its assemblies. +Each managed dependency record carries a nested `dependencyContract` naming +CultLib as `sourceAuthority`, `nuget` as `packageSource`, the package id, +assembly name, version policy, Unity incubation resolution mode, and the +handoff requirement for EveUnity. This is not Eve inventing a second dependency +system. It is EveUnity declaring the assemblies it consumes while CultLib keeps +the package authority. + +The capture stage declares `gamecult.eve.runtime_capture_request.v1` request +construction through `tools/eveunity/eveunity-capture-contract.mjs`. That +request is derived from the runtime capability manifest and Aetheria's provider +advertisement, then points at the PNG artifact EveUnity must produce later. It +does not fake a screenshot; it makes the capture input contract executable. +`tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs` also emits a typed +`gamecult.eve.unity_uitoolkit_projection.v1` JSON projection artifact for the +Aetheria world surface. That artifact is runtime projection evidence, not +provider state and not a Unity frame capture. + +The manifest also declares `worldSurfaceLowering` target `unity-uitoolkit`. +That means this package can lower provider-advertised `interactive-world` and +`interactive-world-editor` surfaces as semantic UI Toolkit command surfaces. +It does not claim Unity scene/world simulation ownership; Aetheria still owns +world state, assets, command acceptance, and receipts. + +Those lifecycle claims are validated by the parity harness. A missing evidence +path is a runtime capability error, not a README footnote. + +The split handoff manifest is `eveunity-split-handoff.json`. It is the +machine-readable map of what EveUnity must carry out of Eve incubation: the UPM +package body, the Unity test lifecycle, the Unity capture lifecycle, the Eve +contracts it consumes, and the forbidden imports it must not use as shortcuts. +It does not make the package ready to split by itself; it makes the remaining +release, test, and capture blockers inspectable. + +From the repository root, run the split lifecycle smoke with: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\run-eveunity-lifecycle-smoke.ps1 +``` + +That smoke validates `eve-runtime-capability.json`, checks lifecycle evidence +paths, builds the UPM artifact smoke, runs the Aetheria Unity package +consumer-build smoke, and verifies the split handoff manifest. Pass +`-RunUnityEditMode` when the local Unity editor should also execute the +incubating batchmode EditMode test runner. The final tagged UPM release, +batchmode runner ownership, and capture artifact still graduate to `EveUnity`. +The capture contract smoke is: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\run-eveunity-capture-contract-smoke.ps1 +``` + +The semantic projection capture smoke is: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\run-eveunity-uitoolkit-capture-smoke.ps1 +``` + +The release contract smoke is: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\run-eveunity-release-contract-smoke.ps1 +``` + +The release artifact smoke is: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\run-eveunity-release-artifact-smoke.ps1 +``` + +For Aetheria, the Unity evidence path is: + +- `powershell -ExecutionPolicy Bypass -File ..\..\scripts\run-aetheria-unity-package-smoke.ps1` +- `powershell -ExecutionPolicy Bypass -File ..\..\scripts\run-aetheria-unity-editmode-tests.ps1` + +The EditMode test asmdef declares Unity precompiled references for the +Brokkr/CultMesh DLLs Unity needs to resolve `GameCult.Mesh`. That is Unity +assembly plumbing, not a claim that CultLib lacks NuGet/.NET packaging. + +Those checks sit alongside Eve's shared browser, Flutter, iOS, Android/Kotlin, +and Rust contract tests, all discoverable from the parity manifest. diff --git a/packages/org.gamecult.eve.unity-uitoolkit/README.md.meta b/packages/org.gamecult.eve.unity-uitoolkit/README.md.meta new file mode 100644 index 0000000..8844c0d --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9dabe4b47bd743f41a4c0903fd309eae +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime.meta b/packages/org.gamecult.eve.unity-uitoolkit/Runtime.meta new file mode 100644 index 0000000..b653c25 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 40e1d9868e9fdbf408ac5bc46239c817 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs new file mode 100644 index 0000000..f989ccf --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Eve.Surface; +using UnityEngine; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public sealed class EveUiToolkitSurfaceLowerer + { + private readonly EveUiToolkitSurfaceOptions _options; + + public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null) + { + _options = options ?? EveUiToolkitSurfaceOptions.Default; + } + + public VisualElement Lower(EveSurfaceDocument document, Action? commandSink = null) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + + var root = LowerComponent(document.Surface.Root, document, commandSink); + root.name = string.IsNullOrWhiteSpace(root.name) ? document.Surface.Id : root.name; + root.AddToClassList("eve-surface-root"); + ApplyStyleTokens(root, document.Surface.Styles); + return root; + } + + private VisualElement LowerComponent( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var element = CreateElement(component, document, commandSink); + element.name = SafeName(component.Id); + element.AddToClassList("eve-component"); + element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}"); + element.userData = component; + ApplyGeneratedLayout(element, component); + + foreach (var child in component.Children) + element.Add(LowerComponent(child, document, commandSink)); + + foreach (var slot in component.EmbeddedDocuments) + { + var nested = LowerEmbeddedDocument(slot, commandSink); + if (nested != null) + element.Add(nested); + } + + return element; + } + + private VisualElement? LowerEmbeddedDocument( + EveEmbeddedDocumentSlot slot, + Action? commandSink) + { + var document = _options.EmbeddedDocumentResolver?.Invoke(slot); + if (document == null) + return null; + + var element = LowerComponent(document.Surface.Root, document, commandSink); + element.name = SafeName(string.IsNullOrWhiteSpace(slot.SlotId) ? document.Surface.Id : slot.SlotId); + element.AddToClassList("eve-embedded-document"); + element.AddToClassList($"eve-embedded-kind-{SafeClass(slot.PresentationKind)}"); + ApplyStyleTokens(element, document.Surface.Styles); + return element; + } + + private VisualElement CreateElement( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var projectionAdapter = _options.FindPluginProjectionAdapter(component); + if (projectionAdapter != null) + return projectionAdapter.Lower(component, document, commandSink); + + switch (NormalizeKind(component.Kind)) + { + case "surface": + { + var element = new VisualElement(); + element.style.flexGrow = 1; + element.style.flexDirection = FlexDirection.Column; + return element; + } + case "grid": + { + var element = new VisualElement(); + element.style.flexDirection = FlexDirection.Row; + element.style.flexWrap = Wrap.Wrap; + element.style.alignItems = Align.Stretch; + return element; + } + case "partition": + { + var element = new VisualElement(); + element.style.flexDirection = component.GetProp("split") == "x" + ? FlexDirection.Row + : FlexDirection.Column; + element.style.flexWrap = Wrap.Wrap; + return element; + } + case "pane": + case "modal": + case "card": + { + var card = new VisualElement(); + card.AddToClassList("eve-card"); + card.style.flexDirection = FlexDirection.Column; + var title = component.GetProp("title"); + if (!string.IsNullOrWhiteSpace(title)) + card.Add(TitleLabel(title)); + return card; + } + case "metric": + { + var metric = new VisualElement(); + metric.AddToClassList("eve-metric"); + metric.style.flexDirection = FlexDirection.Column; + metric.Add(MutedLabel(component.GetProp("label"))); + metric.Add(ValueLabel(component.GetProp("value"))); + return metric; + } + case "progress": + { + var progress = new VisualElement(); + progress.AddToClassList("eve-progress"); + progress.style.flexDirection = FlexDirection.Column; + progress.Add(MutedLabel(component.GetProp("label"))); + progress.Add(ValueLabel(component.GetProp("value"))); + return progress; + } + case "options": + { + var options = new VisualElement(); + options.AddToClassList("eve-options"); + options.style.flexDirection = FlexDirection.Column; + var label = component.GetProp("label"); + if (!string.IsNullOrWhiteSpace(label)) + options.Add(TitleLabel(label)); + return options; + } + case "inspector.kv": + { + var inspector = new VisualElement(); + inspector.AddToClassList("eve-inspector"); + inspector.style.flexDirection = FlexDirection.Column; + return inspector; + } + case "row": + { + var row = new VisualElement(); + row.AddToClassList("eve-row"); + row.style.flexDirection = FlexDirection.Row; + row.style.flexWrap = Wrap.Wrap; + foreach (var prop in component.Props) + row.Add(FieldLabel(prop.Key, prop.Value)); + return row; + } + case "text": + { + return BodyLabel(component.GetProp("text", component.GetProp("value", component.GetProp("title")))); + } + case "control.button": + case "control.popup": + { + var label = component.GetProp("label", component.GetProp("title", "Invoke")); + var command = component.GetProp("command", component.GetProp("action", "invoke")); + return new Button(() => EmitCommand(document, component, command, commandSink)) { text = label }; + } + case "control.text": + case "control.input.text": + { + var label = component.GetProp("label", component.GetProp("title", "Value")); + var command = component.GetProp("command", component.GetProp("action", "set")); + var value = component.GetProp("value"); + var field = new TextField(label); + field.SetValueWithoutNotify(value); + field.RegisterValueChangedCallback(evt => + { + if (string.Equals(evt.newValue, value, StringComparison.Ordinal)) + return; + + var payload = new Dictionary(component.Props, StringComparer.Ordinal) + { + ["value"] = evt.newValue ?? "" + }; + EmitCommand(document, component, command, GameCult.Mesh.CultMesh.OperationPayload(payload), commandSink); + }); + return field; + } + default: + { + var element = new VisualElement(); + element.AddToClassList("eve-unknown"); + var title = component.GetProp("title", component.GetProp("label")); + if (!string.IsNullOrWhiteSpace(title)) + element.Add(BodyLabel(title)); + return element; + } + } + } + + private static void EmitCommand( + EveSurfaceDocument document, + EveSurfaceComponent component, + string command, + Action? commandSink) + { + EmitCommand( + document, + component, + command, + GameCult.Mesh.CultMesh.OperationPayload(component.Props), + commandSink); + } + + private static void EmitCommand( + EveSurfaceDocument document, + EveSurfaceComponent component, + string command, + GameCult.Mesh.CultMeshOperationPayload payload, + Action? commandSink) + { + if (commandSink == null || string.IsNullOrWhiteSpace(command)) + return; + + commandSink(new EveSurfaceCommandRequest( + document.ProviderId, + document.Surface.Id, + ResolveOperation(document, command), + payload, + DateTimeOffset.UtcNow, + "unity-uitoolkit")); + } + + private static GameCult.Mesh.CultMeshOperationInvocationDescriptor ResolveOperation( + EveSurfaceDocument document, + string command) + { + foreach (var template in document.Commands) + { + if (string.Equals(template.Command, command, StringComparison.Ordinal)) + return GameCult.Mesh.CultMesh.OperationInvocation(template.Operation); + } + + return GameCult.Mesh.CultMesh.OperationInvocation(command); + } + + private static Label TitleLabel(string text) + { + var label = new Label(text); + label.AddToClassList("eve-title"); + label.style.unityFontStyleAndWeight = FontStyle.Bold; + return label; + } + + private static Label BodyLabel(string text) + { + var label = new Label(text); + label.AddToClassList("eve-text"); + return label; + } + + private static Label MutedLabel(string text) + { + var label = new Label(text); + label.AddToClassList("eve-muted"); + return label; + } + + private static Label ValueLabel(string text) + { + var label = new Label(text); + label.AddToClassList("eve-value"); + label.style.unityFontStyleAndWeight = FontStyle.Bold; + return label; + } + + private static VisualElement FieldLabel(string key, string value) + { + var field = new VisualElement(); + field.AddToClassList("eve-field"); + field.style.flexDirection = FlexDirection.Row; + field.Add(MutedLabel(key)); + field.Add(BodyLabel(value)); + return field; + } + + private static void ApplyStyleTokens(VisualElement root, IReadOnlyList tokens) + { + foreach (var token in tokens) + { + if (string.IsNullOrWhiteSpace(token.Name) || string.IsNullOrWhiteSpace(token.Value)) + continue; + + root.AddToClassList($"eve-style-token-{SafeClass(token.Name)}"); + } + } + + private static void ApplyGeneratedLayout(VisualElement element, EveSurfaceComponent component) + { + var layout = component.Layout ?? new Dictionary(StringComparer.Ordinal); + var style = component.Style ?? new Dictionary(StringComparer.Ordinal); + + if (TryGet(layout, "display", out var display)) + element.style.display = string.Equals(display, "none", StringComparison.OrdinalIgnoreCase) + ? DisplayStyle.None + : DisplayStyle.Flex; + if (TryGet(layout, "direction", out var direction)) + { + if (string.Equals(direction, "horizontal", StringComparison.OrdinalIgnoreCase) || + string.Equals(direction, "row", StringComparison.OrdinalIgnoreCase)) + element.style.flexDirection = FlexDirection.Row; + else if (string.Equals(direction, "vertical", StringComparison.OrdinalIgnoreCase) || + string.Equals(direction, "column", StringComparison.OrdinalIgnoreCase)) + element.style.flexDirection = FlexDirection.Column; + } + if (TryGet(layout, "flexWrap", out var flexWrap)) + element.style.flexWrap = string.Equals(flexWrap, "wrap", StringComparison.OrdinalIgnoreCase) ? Wrap.Wrap : Wrap.NoWrap; + if (TryGet(layout, "alignItems", out var alignItems) && TryParseAlign(alignItems, out var align)) + element.style.alignItems = align; + if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify)) + element.style.justifyContent = justify; + if (TryGet(layout, "width", out var width)) + element.style.width = ParseLength(width); + if (TryGet(layout, "minWidth", out var minWidth)) + element.style.minWidth = ParseLength(minWidth); + if (TryGet(layout, "maxWidth", out var maxWidth)) + element.style.maxWidth = ParseLength(maxWidth); + if (TryGet(layout, "height", out var height)) + element.style.height = ParseLength(height); + if (TryGet(layout, "minHeight", out var minHeight)) + element.style.minHeight = ParseLength(minHeight); + if (TryGet(layout, "maxHeight", out var maxHeight)) + element.style.maxHeight = ParseLength(maxHeight); + if (TryGet(layout, "padding", out var padding)) + ApplyBoxLength(padding, value => + { + element.style.paddingTop = value; + element.style.paddingRight = value; + element.style.paddingBottom = value; + element.style.paddingLeft = value; + }); + if (TryGet(layout, "margin", out var margin)) + ApplyBoxLength(margin, value => + { + element.style.marginTop = value; + element.style.marginRight = value; + element.style.marginBottom = value; + element.style.marginLeft = value; + }); + if (TryGet(layout, "overflow", out var overflow)) + element.style.overflow = string.Equals(overflow, "hidden", StringComparison.OrdinalIgnoreCase) + ? Overflow.Hidden + : Overflow.Visible; + + if (TryGet(style, "background", out var background) && TryParseColor(background, out var backgroundColor)) + element.style.backgroundColor = backgroundColor; + if (TryGet(style, "color", out var color) && TryParseColor(color, out var textColor)) + element.style.color = textColor; + if (TryGet(style, "borderWidth", out var borderWidth)) + ApplyBoxFloat(borderWidth, value => + { + element.style.borderTopWidth = value; + element.style.borderRightWidth = value; + element.style.borderBottomWidth = value; + element.style.borderLeftWidth = value; + }); + if (TryGet(style, "borderColor", out var borderColor) && TryParseColor(borderColor, out var border)) + { + element.style.borderTopColor = border; + element.style.borderRightColor = border; + element.style.borderBottomColor = border; + element.style.borderLeftColor = border; + } + if (TryGet(style, "borderRadius", out var borderRadius)) + ApplyBoxLength(borderRadius, value => + { + element.style.borderTopLeftRadius = value; + element.style.borderTopRightRadius = value; + element.style.borderBottomRightRadius = value; + element.style.borderBottomLeftRadius = value; + }); + if (TryGet(style, "fontSize", out var fontSize)) + element.style.fontSize = ParseLength(fontSize); + if (TryGet(style, "fontWeight", out var fontWeight) && + (string.Equals(fontWeight, "bold", StringComparison.OrdinalIgnoreCase) || fontWeight == "700")) + element.style.unityFontStyleAndWeight = FontStyle.Bold; + } + + private static bool TryGet(IReadOnlyDictionary values, string key, out string value) + { + if (values.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value)) + return true; + + value = ""; + return false; + } + + private static StyleLength ParseLength(string value) + { + value = FirstToken(value); + if (string.Equals(value, "auto", StringComparison.OrdinalIgnoreCase)) + return StyleKeyword.Auto; + if (value.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(value.Substring(0, value.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + return new Length(percent, LengthUnit.Percent); + if (value.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + value = value.Substring(0, value.Length - 2); + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var pixels)) + return new Length(pixels, LengthUnit.Pixel); + + return StyleKeyword.Null; + } + + private static StyleFloat ParseFloat(string value) + { + value = FirstToken(value); + if (value.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + value = value.Substring(0, value.Length - 2); + return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var pixels) + ? pixels + : StyleKeyword.Null; + } + + private static void ApplyBoxLength(string value, Action apply) + { + apply(ParseLength(value)); + } + + private static void ApplyBoxFloat(string value, Action apply) + { + apply(ParseFloat(value)); + } + + private static bool TryParseColor(string value, out Color color) + { + value = value.Trim(); + if (value.StartsWith("var(", StringComparison.Ordinal)) + { + color = default; + return false; + } + return ColorUtility.TryParseHtmlString(value, out color); + } + + private static bool TryParseAlign(string value, out Align align) + { + switch (value) + { + case "center": + align = Align.Center; + return true; + case "end": + case "flex-end": + align = Align.FlexEnd; + return true; + case "stretch": + align = Align.Stretch; + return true; + case "start": + case "flex-start": + align = Align.FlexStart; + return true; + default: + align = Align.Auto; + return false; + } + } + + private static bool TryParseJustify(string value, out Justify justify) + { + switch (value) + { + case "center": + justify = Justify.Center; + return true; + case "end": + case "flex-end": + justify = Justify.FlexEnd; + return true; + case "space-between": + justify = Justify.SpaceBetween; + return true; + case "space-around": + justify = Justify.SpaceAround; + return true; + case "start": + case "flex-start": + justify = Justify.FlexStart; + return true; + default: + justify = Justify.FlexStart; + return false; + } + } + + private static string FirstToken(string value) + { + return (value ?? "").Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? ""; + } + + private static string NormalizeKind(string kind) + { + if (string.IsNullOrWhiteSpace(kind)) + return ""; + + if (kind == "panel.dialogue") + return "panel"; + if (kind.StartsWith("text.", StringComparison.Ordinal)) + return "text"; + if (kind.StartsWith("control.button.", StringComparison.Ordinal)) + return "control.button"; + if (kind.StartsWith("control.text.", StringComparison.Ordinal)) + return "control.text"; + + return kind; + } + + private static string SafeName(string value) + { + return string.IsNullOrWhiteSpace(value) ? "eve.component" : value; + } + + private static string SafeClass(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "unknown"; + + var characters = value.ToCharArray(); + for (var index = 0; index < characters.Length; index++) + { + if (!char.IsLetterOrDigit(characters[index])) + characters[index] = '-'; + } + + return new string(characters).Trim('-').ToLowerInvariant(); + } + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs.meta b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs.meta new file mode 100644 index 0000000..ab8e870 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bef5882c69652c54fb26b07d63e5bcdd \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs new file mode 100644 index 0000000..b58ae10 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public sealed class EveUiToolkitSurfaceOptions + { + public static EveUiToolkitSurfaceOptions Default { get; } = new EveUiToolkitSurfaceOptions(); + + public EveUiToolkitSurfaceOptions( + Func? embeddedDocumentResolver = null, + IReadOnlyList? pluginProjectionAdapters = null) + { + EmbeddedDocumentResolver = embeddedDocumentResolver; + PluginProjectionAdapters = pluginProjectionAdapters ?? new IEveUiToolkitPluginProjectionAdapter[] + { + new SaiVisualNovelUiToolkitProjectionAdapter(), + new NornGraphUiToolkitProjectionAdapter(), + new TeXMathUiToolkitProjectionAdapter() + }; + } + + public Func? EmbeddedDocumentResolver { get; } + + public IReadOnlyList PluginProjectionAdapters { get; } + + public IEveUiToolkitPluginProjectionAdapter? FindPluginProjectionAdapter(EveSurfaceComponent component) + { + return PluginProjectionAdapters.FirstOrDefault(adapter => adapter.CanLower(component)); + } + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs.meta b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs.meta new file mode 100644 index 0000000..a0208aa --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e2fd027205cc9214eaa7dd84fe75d7bd \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef new file mode 100644 index 0000000..9fc5a53 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef @@ -0,0 +1,16 @@ +{ + "name": "GameCult.Eve.UnityUIToolkit", + "rootNamespace": "GameCult.Eve.UnityUIToolkit", + "references": [ + "GameCult.Eve.Surface" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef.meta b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef.meta new file mode 100644 index 0000000..e9be7c4 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 093779c1124fb5241b9c16e18173520a +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/IEveUiToolkitPluginProjectionAdapter.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/IEveUiToolkitPluginProjectionAdapter.cs new file mode 100644 index 0000000..8e99b61 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/IEveUiToolkitPluginProjectionAdapter.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public interface IEveUiToolkitPluginProjectionAdapter + { + string PluginId { get; } + + IReadOnlyList Capabilities { get; } + + bool CanLower(EveSurfaceComponent component); + + VisualElement Lower( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink); + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/IEveUiToolkitPluginProjectionAdapter.cs.meta b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/IEveUiToolkitPluginProjectionAdapter.cs.meta new file mode 100644 index 0000000..f8b26b7 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/IEveUiToolkitPluginProjectionAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e93b6d6e2364ef4a91766cbf5a44b62 diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/NornGraphUiToolkitProjectionAdapter.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/NornGraphUiToolkitProjectionAdapter.cs new file mode 100644 index 0000000..d18d7cf --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/NornGraphUiToolkitProjectionAdapter.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using GameCult.Mesh; +using UnityEngine; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public sealed class NornGraphUiToolkitProjectionAdapter : IEveUiToolkitPluginProjectionAdapter + { + public string PluginId => "norn.graph"; + + public IReadOnlyList Capabilities { get; } = new[] + { + "embed.norn" + }; + + public bool CanLower(EveSurfaceComponent component) + { + return string.Equals(component.Kind, "embed.norn", StringComparison.Ordinal); + } + + public VisualElement Lower( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var graph = new VisualElement(); + graph.AddToClassList("eve-plugin-projection"); + graph.AddToClassList("eve-plugin-norn-graph"); + graph.AddToClassList("norn-graph"); + graph.style.flexDirection = FlexDirection.Column; + + var title = component.GetProp("label", component.GetProp("title", "Norn graph")); + var titleLabel = new Label(title); + titleLabel.AddToClassList("norn-graph-title"); + titleLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + graph.Add(titleLabel); + + var mode = component.GetProp("mode", component.GetProp("layoutMode", "embedded")); + var modeLabel = new Label(mode); + modeLabel.AddToClassList("norn-graph-mode"); + graph.Add(modeLabel); + + var focusCommand = component.GetProp("command", component.GetProp("focusCommand", "graph.focus")); + graph.RegisterCallback(_ => EmitGraphCommand(document, component, focusCommand, commandSink)); + + return graph; + } + + private static void EmitGraphCommand( + EveSurfaceDocument document, + EveSurfaceComponent component, + string command, + Action? commandSink) + { + if (commandSink == null || string.IsNullOrWhiteSpace(command)) + return; + + commandSink(new EveSurfaceCommandRequest( + document.ProviderId, + document.Surface.Id, + ResolveOperation(document, command), + CultMesh.OperationPayload(component.Props), + DateTimeOffset.UtcNow, + "unity-uitoolkit")); + } + + private static CultMeshOperationInvocationDescriptor ResolveOperation( + EveSurfaceDocument document, + string command) + { + foreach (var template in document.Commands) + { + if (string.Equals(template.Command, command, StringComparison.Ordinal)) + return CultMesh.OperationInvocation(template.Operation); + } + + return CultMesh.OperationInvocation(command); + } + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/NornGraphUiToolkitProjectionAdapter.cs.meta b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/NornGraphUiToolkitProjectionAdapter.cs.meta new file mode 100644 index 0000000..07345b2 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/NornGraphUiToolkitProjectionAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 729936c97f3c45f7bba0fcd6de8b40f5 diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/SaiVisualNovelUiToolkitProjectionAdapter.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/SaiVisualNovelUiToolkitProjectionAdapter.cs new file mode 100644 index 0000000..9bf0c19 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/SaiVisualNovelUiToolkitProjectionAdapter.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using GameCult.Mesh; +using UnityEngine; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public sealed class SaiVisualNovelUiToolkitProjectionAdapter : IEveUiToolkitPluginProjectionAdapter + { + public string PluginId => "sai.vn"; + + public IReadOnlyList Capabilities { get; } = new[] + { + "vn.stage", + "story.choose", + "story.continue", + "story.jump" + }; + + public bool CanLower(EveSurfaceComponent component) + { + switch (component.Kind) + { + case "vn.stage": + case "panel.dialogue": + case "text.dialogue": + case "rail.actions": + return true; + case "control.button": + return IsStoryCommand(component); + default: + return false; + } + } + + public VisualElement Lower( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + switch (component.Kind) + { + case "vn.stage": + return Stage(component); + case "panel.dialogue": + return DialoguePanel(component); + case "text.dialogue": + return DialogueText(component); + case "rail.actions": + return ActionRail(); + case "control.button": + return StoryButton(component, document, commandSink); + default: + return new VisualElement(); + } + } + + private static VisualElement Stage(EveSurfaceComponent component) + { + var stage = new VisualElement(); + stage.AddToClassList("eve-plugin-projection"); + stage.AddToClassList("eve-plugin-sai-vn"); + stage.AddToClassList("sai-vn-stage"); + stage.style.flexGrow = 1; + stage.style.flexDirection = FlexDirection.Column; + + var storyId = component.GetProp("storyId"); + var currentPath = component.GetProp("currentPath"); + if (!string.IsNullOrWhiteSpace(storyId) || !string.IsNullOrWhiteSpace(currentPath)) + { + var state = new Label(string.IsNullOrWhiteSpace(currentPath) + ? storyId + : $"{storyId}:{currentPath}"); + state.AddToClassList("sai-vn-state"); + state.style.unityFontStyleAndWeight = FontStyle.Bold; + stage.Add(state); + } + + return stage; + } + + private static VisualElement DialoguePanel(EveSurfaceComponent component) + { + var panel = new VisualElement(); + panel.AddToClassList("eve-plugin-projection"); + panel.AddToClassList("eve-plugin-sai-vn"); + panel.AddToClassList("sai-vn-dialogue"); + panel.style.flexDirection = FlexDirection.Column; + + var speaker = component.GetProp("speaker"); + if (!string.IsNullOrWhiteSpace(speaker)) + { + var speakerLabel = new Label(speaker); + speakerLabel.AddToClassList("sai-vn-speaker"); + speakerLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + panel.Add(speakerLabel); + } + + var scene = component.GetProp("scene"); + if (!string.IsNullOrWhiteSpace(scene)) + { + var sceneLabel = new Label(scene); + sceneLabel.AddToClassList("sai-vn-scene"); + panel.Add(sceneLabel); + } + + return panel; + } + + private static VisualElement DialogueText(EveSurfaceComponent component) + { + var text = new Label(component.GetProp("text")); + text.AddToClassList("eve-plugin-projection"); + text.AddToClassList("eve-plugin-sai-vn"); + text.AddToClassList("sai-vn-dialogue-text"); + return text; + } + + private static VisualElement ActionRail() + { + var rail = new VisualElement(); + rail.AddToClassList("eve-plugin-projection"); + rail.AddToClassList("eve-plugin-sai-vn"); + rail.AddToClassList("sai-vn-actions"); + rail.style.flexDirection = FlexDirection.Row; + rail.style.flexWrap = Wrap.Wrap; + return rail; + } + + private static Button StoryButton( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var label = component.GetProp("label", component.GetProp("title", "Continue")); + var command = StoryCommand(component); + var button = new Button(() => EmitStoryCommand(document, component, command, commandSink)) { text = label }; + button.AddToClassList("eve-plugin-projection"); + button.AddToClassList("eve-plugin-sai-vn"); + button.AddToClassList("sai-vn-story-command"); + button.AddToClassList($"sai-vn-command-{SafeClass(command)}"); + return button; + } + + private static bool IsStoryCommand(EveSurfaceComponent component) + { + return StoryCommand(component).StartsWith("story.", StringComparison.Ordinal); + } + + private static string StoryCommand(EveSurfaceComponent component) + { + return component.GetProp("command", component.GetProp("action", component.GetProp("type"))); + } + + private static void EmitStoryCommand( + EveSurfaceDocument document, + EveSurfaceComponent component, + string command, + Action? commandSink) + { + if (commandSink == null || string.IsNullOrWhiteSpace(command)) + return; + + commandSink(new EveSurfaceCommandRequest( + document.ProviderId, + document.Surface.Id, + ResolveOperation(document, command), + CultMesh.OperationPayload(component.Props), + DateTimeOffset.UtcNow, + "unity-uitoolkit")); + } + + private static CultMeshOperationInvocationDescriptor ResolveOperation( + EveSurfaceDocument document, + string command) + { + foreach (var template in document.Commands) + { + if (string.Equals(template.Command, command, StringComparison.Ordinal)) + return CultMesh.OperationInvocation(template.Operation); + } + + return CultMesh.OperationInvocation(command); + } + + private static string SafeClass(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "unknown"; + + var characters = value.ToCharArray(); + for (var index = 0; index < characters.Length; index++) + { + if (!char.IsLetterOrDigit(characters[index])) + characters[index] = '-'; + } + + return new string(characters).Trim('-').ToLowerInvariant(); + } + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/SaiVisualNovelUiToolkitProjectionAdapter.cs.meta b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/SaiVisualNovelUiToolkitProjectionAdapter.cs.meta new file mode 100644 index 0000000..e87f0bd --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/SaiVisualNovelUiToolkitProjectionAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e8f6fc7d7ef4dd7b22ec33d4f81d321 diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/TeXMathUiToolkitProjectionAdapter.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/TeXMathUiToolkitProjectionAdapter.cs new file mode 100644 index 0000000..51ef3d0 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/TeXMathUiToolkitProjectionAdapter.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public sealed class TeXMathUiToolkitProjectionAdapter : IEveUiToolkitPluginProjectionAdapter + { + public string PluginId => "tex.math"; + + public IReadOnlyList Capabilities { get; } = new[] + { + "embed.tex", + "tex.inline", + "tex.block" + }; + + public bool CanLower(EveSurfaceComponent component) + { + return string.Equals(component.Kind, "embed.tex", StringComparison.Ordinal); + } + + public VisualElement Lower( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var tex = new VisualElement(); + tex.AddToClassList("eve-plugin-projection"); + tex.AddToClassList("eve-plugin-tex-math"); + tex.AddToClassList("tex-math"); + tex.AddToClassList($"tex-display-{SafeClass(component.GetProp("display", "inline"))}"); + tex.style.flexDirection = FlexDirection.Column; + + var label = component.GetProp("label", "TeX"); + if (!string.IsNullOrWhiteSpace(label)) + { + var labelElement = new Label(label); + labelElement.AddToClassList("tex-math-label"); + tex.Add(labelElement); + } + + var source = component.GetProp("source", component.GetProp("sourceUri", "")); + var sourceElement = new Label(source); + sourceElement.AddToClassList("tex-math-source-fallback"); + tex.Add(sourceElement); + + return tex; + } + + private static string SafeClass(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "unknown"; + + var characters = value.ToCharArray(); + for (var index = 0; index < characters.Length; index++) + { + if (!char.IsLetterOrDigit(characters[index])) + characters[index] = '-'; + } + + return new string(characters).Trim('-').ToLowerInvariant(); + } + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/TeXMathUiToolkitProjectionAdapter.cs.meta b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/TeXMathUiToolkitProjectionAdapter.cs.meta new file mode 100644 index 0000000..0bcd3dd --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/TeXMathUiToolkitProjectionAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6c655fae0c77064490eba9c3ed1fb4c \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests.meta b/packages/org.gamecult.eve.unity-uitoolkit/Tests.meta new file mode 100644 index 0000000..0966739 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 694b4921723a4d56a9f0f72ad2240feb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor.meta b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor.meta new file mode 100644 index 0000000..9ea2f73 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: acfcd889183e4bfbac82f8883deef300 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs new file mode 100644 index 0000000..a9d88fb --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using GameCult.Mesh; +using NUnit.Framework; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit.Tests +{ + public sealed class EveUiToolkitSurfaceLowererTests + { + [Test] + public void DefaultOptionsExposeSaiNornAndTeXProjectionAdapters() + { + var options = EveUiToolkitSurfaceOptions.Default; + + Assert.That(options.FindPluginProjectionAdapter(Component("stage", "vn.stage"))?.PluginId, Is.EqualTo("sai.vn")); + Assert.That(options.FindPluginProjectionAdapter(Component("graph", "embed.norn"))?.PluginId, Is.EqualTo("norn.graph")); + Assert.That(options.FindPluginProjectionAdapter(Component("math", "embed.tex"))?.PluginId, Is.EqualTo("tex.math")); + } + + [Test] + public void SaiStageLowersThroughRuntimeProjectionAdapter() + { + var document = Document(Component( + "stage", + "vn.stage", + new Dictionary + { + ["storyId"] = "sai.fixture", + ["currentPath"] = "intro" + })); + + var root = new EveUiToolkitSurfaceLowerer().Lower(document); + + Assert.That(root.ClassListContains("eve-plugin-projection"), Is.True); + Assert.That(root.ClassListContains("eve-plugin-sai-vn"), Is.True); + Assert.That(root.ClassListContains("sai-vn-stage"), Is.True); + } + + [Test] + public void NornGraphLowersThroughRuntimeProjectionAdapter() + { + var document = Document(Component( + "graph", + "embed.norn", + new Dictionary + { + ["label"] = "World graph", + ["mode"] = "embedded" + })); + + var root = new EveUiToolkitSurfaceLowerer().Lower(document); + + Assert.That(root.ClassListContains("eve-plugin-projection"), Is.True); + Assert.That(root.ClassListContains("eve-plugin-norn-graph"), Is.True); + Assert.That(root.ClassListContains("norn-graph"), Is.True); + } + + [Test] + public void TeXMathLowersThroughRuntimeProjectionAdapterAsSourceFallback() + { + var document = Document(Component( + "math", + "embed.tex", + new Dictionary + { + ["label"] = "Bifrost voting weight", + ["source"] = "\\\\mathrm{votes}(p)=1+\\\\lfloor\\\\log_b(1+p)\\\\rfloor", + ["display"] = "block" + })); + + var root = new EveUiToolkitSurfaceLowerer().Lower(document); + + Assert.That(root.ClassListContains("eve-plugin-projection"), Is.True); + Assert.That(root.ClassListContains("eve-plugin-tex-math"), Is.True); + Assert.That(root.ClassListContains("tex-math"), Is.True); + Assert.That(root.ClassListContains("tex-display-block"), Is.True); + } + + [Test] + public void EmbeddedDocumentSlotMountsResolverBackedChildSurface() + { + var child = Document( + Component("child-root", "text", new Dictionary { ["text"] = "Nested surface" }), + surfaceId: "child-surface"); + var parentRoot = new EveSurfaceComponent( + "parent-root", + "surface", + EmptyProps(), + Array.Empty(), + Array.Empty(), + new[] + { + new EveEmbeddedDocumentSlot( + "child-slot", + "child-surface", + EveSurfaceDocument.SchemaId, + "inline") + }); + var lowerer = new EveUiToolkitSurfaceLowerer(new EveUiToolkitSurfaceOptions( + embeddedDocumentResolver: _ => child, + pluginProjectionAdapters: Array.Empty())); + + var root = lowerer.Lower(Document(parentRoot)); + var embedded = root.Query(className: "eve-embedded-document").First(); + + Assert.That(embedded, Is.Not.Null); + Assert.That(embedded.name, Is.EqualTo("child-slot")); + Assert.That(embedded.ClassListContains("eve-embedded-kind-inline"), Is.True); + } + + private static EveSurfaceDocument Document(EveSurfaceComponent root, string surfaceId = "test-surface") + { + return new EveSurfaceDocument( + "gamecult.eve.unity.tests", + "runtime.test", + "Unity UI Toolkit tests", + 1, + "2026-07-08T00:00:00Z", + new EveSurfaceTree(surfaceId, root, Array.Empty()), + Array.Empty()); + } + + private static EveSurfaceComponent Component( + string id, + string kind, + IReadOnlyDictionary? props = null) + { + return new EveSurfaceComponent( + id, + kind, + props ?? EmptyProps(), + Array.Empty()); + } + + private static IReadOnlyDictionary EmptyProps() + { + return new Dictionary(StringComparer.Ordinal); + } + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs.meta b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs.meta new file mode 100644 index 0000000..c6725d8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2921e30d39fe49098bc3763c538ed67c diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/GameCult.Eve.UnityUIToolkit.Tests.asmdef b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/GameCult.Eve.UnityUIToolkit.Tests.asmdef new file mode 100644 index 0000000..380920e --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/GameCult.Eve.UnityUIToolkit.Tests.asmdef @@ -0,0 +1,27 @@ +{ + "name": "GameCult.Eve.UnityUIToolkit.Tests", + "rootNamespace": "GameCult.Eve.UnityUIToolkit.Tests", + "references": [ + "GameCult.Eve.UnityUIToolkit", + "GameCult.Eve.Surface", + "GameCult.Mesh" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "GameCult.Caching.dll", + "GameCult.Mesh.dll", + "MessagePack.Annotations.dll" + ], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "noEngineReferences": false +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/GameCult.Eve.UnityUIToolkit.Tests.asmdef.meta b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/GameCult.Eve.UnityUIToolkit.Tests.asmdef.meta new file mode 100644 index 0000000..e9af2a3 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/GameCult.Eve.UnityUIToolkit.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 353e1aea044b46ac98835959a19fe0ac +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json new file mode 100644 index 0000000..0c99a6f --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json @@ -0,0 +1,239 @@ +{ + "schema": "gamecult.eve.runtime_capability.v1", + "runtimeId": "unity-uitoolkit", + "title": "Unity / UI Toolkit", + "owner": "EveUnity", + "incubation": { + "ownerRepo": "EveUnity", + "currentHostRepo": "EveUnity", + "splitTarget": "EveUnity", + "graduationTrigger": "Graduated: EveUnity owns the UI Toolkit package, plugin projection adapters, package build, and Unity tests." + }, + "supportedFeatures": [ + "embeddedDocuments" + ], + "supportedPlugins": [ + { + "pluginId": "sai.vn", + "capabilities": [ + "vn.stage", + "story.choose", + "story.continue", + "story.jump" + ], + "projectionAdapter": "SaiVisualNovelUiToolkitProjectionAdapter" + }, + { + "pluginId": "norn.graph", + "capabilities": [ + "embed.norn" + ], + "projectionAdapter": "NornGraphUiToolkitProjectionAdapter" + }, + { + "pluginId": "tex.math", + "capabilities": [ + "embed.tex", + "tex.inline", + "tex.block" + ], + "projectionAdapter": "TeXMathUiToolkitProjectionAdapter" + } + ], + "unsupportedPlugins": [], + "commandTransport": { + "schema": "gamecult.eve.command_invocation.v1", + "emitter": "EveUiToolkitSurfaceLowerer.EmitCommand", + "clientId": "unity-uitoolkit" + }, + "worldSurfaceLowering": [ + { + "targetId": "unity-uitoolkit", + "surfaceKinds": ["interactive-world", "interactive-world-editor"], + "projectionKinds": ["provider-authored-world-surface", "provider-authored-world-editor-surface"], + "supportLevel": "ui-toolkit-semantic-command-surface", + "ownership": "runtime-lowers-provider-world-surface-without-owning-world-state", + "evidencePaths": [ + "packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs", + "packages/org.gamecult.eve.unity-uitoolkit/Runtime/TeXMathUiToolkitProjectionAdapter.cs", + "packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs", + "web/fixtures/aetheria.provider-advertisement.json", + "web/fixtures/eve-world-smoke.provider-advertisement.json" + ] + } + ], + "lifecycle": { + "release": { + "ownerRepo": "EveUnity", + "status": "upm-package-artifact-smoke", + "packageName": "org.gamecult.eve.unity-uitoolkit", + "packageManifest": "packages/org.gamecult.eve.unity-uitoolkit/package.json", + "releaseContract": { + "ownerRepo": "EveUnity", + "packageName": "org.gamecult.eve.unity-uitoolkit", + "packageRoot": "packages/org.gamecult.eve.unity-uitoolkit", + "versionSource": "packages/org.gamecult.eve.unity-uitoolkit/package.json", + "tagPattern": "eveunity-uitoolkit-v{version}", + "artifactKind": "upm-package", + "requestSchema": "gamecult.eve.runtime_release_request.v1", + "requestBuilder": "scripts/pack-uitoolkit.ps1", + "artifactBuilder": "scripts/pack-uitoolkit.ps1", + "artifactPattern": "artifacts/eveunity-uitoolkit-release/{version}/org.gamecult.eve.unity-uitoolkit-{version}.tgz", + "publishProof": "Tagged UPM release is created from the EveUnity repo and references this package root.", + "requiredPackageDependencies": [ + { + "packageName": "org.gamecult.eve.surface", + "version": "0.1.0", + "packageManager": "upm", + "ownerRepo": "EveUnity", + "purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents." + } + ] + }, + "evidencePaths": [ + "packages/org.gamecult.eve.unity-uitoolkit/package.json", + "packages/org.gamecult.eve.unity-uitoolkit/README.md", + "scripts/pack-uitoolkit.ps1" + ], + "pendingProofs": [ + "Tagged UPM release cut from EveUnity rather than Eve incubation." + ] + }, + "test": { + "ownerRepo": "EveUnity", + "status": "batchmode-editmode-tests-and-consumer-build-smoke", + "command": "powershell -ExecutionPolicy Bypass -File .\\scripts\\run-uitoolkit-tests.ps1", + "testContract": { + "ownerRepo": "EveUnity", + "runnerKind": "unity-editmode-batchmode", + "runnerScript": "scripts/run-uitoolkit-tests.ps1", + "consumerProject": "E:\\Projects\\Aetheria", + "unityExeDefault": "C:\\Program Files\\Unity\\Hub\\Editor\\6000.4.2f1\\Editor\\Unity.exe", + "packageName": "org.gamecult.eve.unity-uitoolkit", + "testAssembly": "GameCult.Eve.UnityUIToolkit.Tests", + "testPlatform": "EditMode", + "resultsArtifact": "artifacts/aetheria-unity-editmode/{stamp}/unity-editmode-results.xml", + "logArtifact": "artifacts/aetheria-unity-editmode/{stamp}/unity-editmode.log", + "manifestMutation": "Temporarily adds org.gamecult.eve.unity-uitoolkit to Aetheria Packages/manifest.json testables and restores the manifest after the run.", + "managedAssemblyDependencies": [ + { + "assemblyName": "GameCult.Caching.dll", + "packageId": "GameCult.Caching", + "packageManager": "nuget", + "ownerRepo": "CultLib", + "resolutionMode": "Unity precompiled reference during EveUnity incubation.", + "dependencyContract": { + "sourceAuthority": "CultLib", + "packageSource": "nuget", + "packageId": "GameCult.Caching", + "assemblyName": "GameCult.Caching.dll", + "versionPolicy": "CultLib-owned package version stream.", + "unityResolution": "EveUnity consumes this as a Unity precompiled reference during incubation.", + "handoffRequirement": "EveUnity split must resolve this through CultLib's NuGet/package feed or a documented Unity import without vendoring CultLib ownership into Eve." + } + }, + { + "assemblyName": "GameCult.Mesh.dll", + "packageId": "GameCult.Mesh", + "packageManager": "nuget", + "ownerRepo": "CultLib", + "resolutionMode": "Unity precompiled reference during EveUnity incubation.", + "dependencyContract": { + "sourceAuthority": "CultLib", + "packageSource": "nuget", + "packageId": "GameCult.Mesh", + "assemblyName": "GameCult.Mesh.dll", + "versionPolicy": "CultLib-owned package version stream.", + "unityResolution": "EveUnity consumes this as a Unity precompiled reference during incubation.", + "handoffRequirement": "EveUnity split must resolve this through CultLib's NuGet/package feed or a documented Unity import without vendoring CultLib ownership into Eve." + } + }, + { + "assemblyName": "MessagePack.Annotations.dll", + "packageId": "MessagePack.Annotations", + "packageManager": "nuget", + "ownerRepo": "CultLib", + "resolutionMode": "Unity precompiled reference during EveUnity incubation.", + "dependencyContract": { + "sourceAuthority": "CultLib", + "packageSource": "nuget", + "packageId": "MessagePack.Annotations", + "assemblyName": "MessagePack.Annotations.dll", + "versionPolicy": "CultLib-owned transitive package version stream.", + "unityResolution": "EveUnity consumes this as a Unity precompiled reference during incubation.", + "handoffRequirement": "EveUnity split must resolve this through CultLib's NuGet/package feed or a documented Unity import without vendoring CultLib ownership into Eve." + } + } + ] + }, + "evidencePaths": [ + "packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/GameCult.Eve.UnityUIToolkit.Tests.asmdef", + "packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs", + "scripts/run-uitoolkit-tests.ps1" + ], + "pendingProofs": [ + "Cut and publish the first tagged EveUnity UI Toolkit package release." + ] + }, + "capture": { + "ownerRepo": "EveUnity", + "status": "pending-editor-capture", + "captureContract": { + "ownerRepo": "EveUnity", + "runtimeId": "unity-uitoolkit", + "targetId": "unity-uitoolkit", + "requestSchema": "gamecult.eve.runtime_capture_request.v1", + "requestBuilder": "tools/eveunity/eveunity-capture-contract.mjs", + "advertisementPath": "web/fixtures/aetheria.provider-advertisement.json", + "captureKind": "unity-editor-or-batchmode-png", + "artifactKind": "png", + "artifactPattern": "artifacts/eveunity-uitoolkit-capture/{stamp}/unity-uitoolkit.png", + "conformanceAttachment": "runtime.captureArtifacts[]", + "requiredProvider": "aetheria", + "requiredSurface": "aetheria.daemon.game", + "additionalProviderSurfaces": [ + { + "providerId": "eve.world-smoke", + "surfaceId": "eve.world-smoke.surface", + "purpose": "Generic non-product interactive-world capture control." + }, + { + "providerId": "aetheria", + "surfaceId": "aetheria.daemon.editor", + "purpose": "Aetheria interactive-world-editor capture control." + } + ], + "authority": "Capture observes runtime projection only; provider state, command acceptance, and receipts stay provider-owned.", + "publishProof": "Unity editor or batchmode capture artifact is attached to the runtime conformance report from EveUnity." + }, + "evidencePaths": [ + "tools/eveunity/eveunity-capture-contract.mjs", + "tools/eveunity/eveunity-capture-contract.test.mjs", + "scripts/run-eveunity-capture-contract-smoke.ps1", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs", + "scripts/run-eveunity-uitoolkit-capture-smoke.ps1", + "web/fixtures/aetheria.provider-advertisement.json" + ], + "pendingProofs": [ + "Unity batchmode or editor capture artifact attached to the runtime conformance report." + ] + } + }, + "capture": { + "status": "json-projection", + "note": "Unity UI Toolkit attaches a provider-advertisement-backed gamecult.eve.unity_uitoolkit_projection.v1 JSON projection artifact; Unity editor or batchmode PNG capture remains an EveUnity split blocker.", + "artifacts": [ + { + "kind": "json-projection", + "schema": "gamecult.eve.unity_uitoolkit_projection.v1", + "path": "artifacts/eveunity-uitoolkit-capture/latest/unity-uitoolkit-projection.json", + "runtimeId": "unity-uitoolkit", + "providerId": "aetheria", + "surfaceId": "aetheria.daemon.game", + "sourceSurfacePath": "web/fixtures/aetheria-world-surface.json", + "requestPath": "artifacts/eveunity-uitoolkit-capture/latest/capture-request.json" + } + ] + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json.meta b/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json.meta new file mode 100644 index 0000000..0dd0209 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c3f7a6e51d3f4bbd8d892922299df744 diff --git a/packages/org.gamecult.eve.unity-uitoolkit/package.json b/packages/org.gamecult.eve.unity-uitoolkit/package.json new file mode 100644 index 0000000..cf91087 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/package.json @@ -0,0 +1,18 @@ +{ + "name": "org.gamecult.eve.unity-uitoolkit", + "displayName": "Eve UI Toolkit Lowering", + "version": "0.1.0", + "unity": "2021.3", + "description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.", + "dependencies": { + "org.gamecult.eve.surface": "0.1.0" + }, + "keywords": [ + "Eve", + "CultUI", + "UI Toolkit", + "Unity" + ], + "license": "MPL-2.0", + "category": "Scripting" +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/package.json.meta b/packages/org.gamecult.eve.unity-uitoolkit/package.json.meta new file mode 100644 index 0000000..5f395fc --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6c6c93dde27b8dc42bf3866cd5c6c3cb +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/pack-uitoolkit.ps1 b/scripts/pack-uitoolkit.ps1 new file mode 100644 index 0000000..0ac531c --- /dev/null +++ b/scripts/pack-uitoolkit.ps1 @@ -0,0 +1,12 @@ +param([string] $OutputRoot = "artifacts\packages") + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$packageRoot = Join-Path $repoRoot "packages\org.gamecult.eve.unity-uitoolkit" +$output = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $repoRoot $OutputRoot } +New-Item -ItemType Directory -Force -Path $output | Out-Null +$packed = npm pack $packageRoot --pack-destination $output +if ($LASTEXITCODE -ne 0) { throw "EveUnity UI Toolkit package failed to pack" } +$artifact = Join-Path $output ($packed | Select-Object -Last 1) +if (-not (Test-Path -LiteralPath $artifact)) { throw "EveUnity UI Toolkit artifact was not produced: $artifact" } +Write-Host "EveUnity UI Toolkit package: $artifact" diff --git a/scripts/run-uitoolkit-tests.ps1 b/scripts/run-uitoolkit-tests.ps1 new file mode 100644 index 0000000..b81546e --- /dev/null +++ b/scripts/run-uitoolkit-tests.ps1 @@ -0,0 +1,45 @@ +param( + [string] $AetheriaRoot = "E:\Projects\Aetheria", + [string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe", + [string] $OutputRoot = "artifacts\uitoolkit-consumer" +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$manifestPath = Join-Path $AetheriaRoot "Packages\manifest.json" +$output = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $repoRoot $OutputRoot } +foreach ($required in @($manifestPath, $UnityExe, (Join-Path $repoRoot "packages\org.gamecult.eve.unity-uitoolkit"))) { + if (-not (Test-Path -LiteralPath $required)) { throw "Required EveUnity UI Toolkit test path not found: $required" } +} + +$runRoot = Join-Path $output (Get-Date -Format "yyyyMMddTHHmmss") +New-Item -ItemType Directory -Force -Path $runRoot | Out-Null +$resultsPath = Join-Path $runRoot "unity-editmode-results.xml" +$logPath = Join-Path $runRoot "unity-editmode.log" +$originalManifest = Get-Content -LiteralPath $manifestPath -Raw +try { + $manifest = $originalManifest | ConvertFrom-Json + $testables = @($manifest.testables) | Where-Object { $_ } + if ($testables -notcontains "org.gamecult.eve.unity-uitoolkit") { $testables += "org.gamecult.eve.unity-uitoolkit" } + if ($manifest.PSObject.Properties.Name -contains "testables") { $manifest.testables = $testables } + else { $manifest | Add-Member -NotePropertyName testables -NotePropertyValue $testables } + $manifest | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $manifestPath + $arguments = @( + "-batchmode", "-projectPath", $AetheriaRoot, + "-runTests", "-testPlatform", "EditMode", + "-assemblyNames", "GameCult.Eve.UnityUIToolkit.Tests", + "-testResults", $resultsPath, "-logFile", $logPath + ) + $process = Start-Process -FilePath $UnityExe -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { + Get-Content -LiteralPath $logPath -Tail 120 + throw "EveUnity UI Toolkit tests failed with exit code $($process.ExitCode)" + } +} finally { + Set-Content -NoNewline -LiteralPath $manifestPath -Value $originalManifest +} + +[xml]$results = Get-Content -LiteralPath $resultsPath -Raw +$run = $results.SelectSingleNode("//test-run") +if ($null -eq $run -or [int]$run.total -eq 0 -or [int]$run.failed -gt 0) { throw "EveUnity UI Toolkit tests did not pass: $resultsPath" } +Write-Host "EveUnity UI Toolkit tests: $($run.passed) passed, $($run.total) total"