Lower advertised held input values

This commit is contained in:
Meta Vens 2026-07-15 11:14:22 +01:00
commit 140e1bd963
11 changed files with 317 additions and 21 deletions

View file

@ -192,6 +192,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
WitnessReceipt lookReceipt = null;
WitnessReceipt focusReceipt = null;
WitnessReceipt tractorReceipt = null;
WitnessReceipt tractorReleaseReceipt = null;
WitnessReceipt actionReceipt = null;
EveUnityShotReceipt observedShot = null;
EveUnityCombatPresentation observedCombat = null;
@ -202,6 +203,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
float aimDotDistance = 0f;
int cockpitProgressCount = 0;
float tractorBeamPower = 0f;
float tractorReleasedPower = 1f;
int tractorBeamParticleSystemCount = 0;
try
@ -385,7 +387,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
Assert.That(beamPresentation.ActivationActionId, Is.Not.Empty,
"The beam presentation does not name an advertised generic action.");
var tractorVersion = runtime.ActiveVersion;
var tractor = host.SubmitAdvertisedActionIntent(playerId, beamPresentation.ActivationActionId);
var tractor = host.SubmitAdvertisedActionValueIntent(playerId, beamPresentation.ActivationActionId, 1f);
var tractorDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < tractorDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != tractor.CommandId ||
@ -600,6 +602,29 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(mapCapturePath)));
File.WriteAllBytes(mapCapturePath, pixels.EncodeToPNG());
Assert.That(new FileInfo(mapCapturePath).Length, Is.GreaterThan(1024));
var tractorReleaseVersion = runtime.ActiveVersion;
var tractorRelease = host.SubmitAdvertisedActionValueIntent(
playerId,
beamPresentation.ActivationActionId,
0f);
var tractorReleaseDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < tractorReleaseDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != tractorRelease.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) ||
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion ||
EveUnityBeamPresentation.FindAll(runtime.ActiveProjection).Single().Power >
beamPresentation.ActivationThreshold))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
AssertReconciledProviderReceipt(runtime.LastReceipt, tractorRelease.CommandId,
provider.Selection.ProviderId, provider.Selection.SurfaceId, tractorReleaseVersion);
tractorReleaseReceipt = WitnessReceipt.From("beam-release", runtime.LastReceipt);
beamRenderer.RefreshNow();
Assert.That(beamRenderer.TryGetPower(beamPresentation.Id, out tractorReleasedPower), Is.True);
Assert.That(tractorReleasedPower, Is.Zero,
"Released held input did not return the daemon-authored tractor presentation to zero.");
WriteWitnessFacts(
initialVersion,
runtime.ActiveVersion,
@ -615,6 +640,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
lookReceipt,
focusReceipt,
tractorReceipt,
tractorReleaseReceipt,
actionReceipt,
observedShot,
shieldRatioBefore,
@ -631,6 +657,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
pilotBrightPixelCount,
cockpitProgressCount,
tractorBeamPower,
tractorReleasedPower,
tractorBeamParticleSystemCount,
playerRendererFacts);
}
@ -665,6 +692,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
WitnessReceipt look,
WitnessReceipt targeting,
WitnessReceipt tractor,
WitnessReceipt tractorRelease,
WitnessReceipt action,
EveUnityShotReceipt shot,
float shieldRatioBefore,
@ -681,6 +709,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
int pilotBrightPixelCount,
int cockpitProgressCount,
float tractorBeamPower,
float tractorReleasedPower,
int tractorBeamParticleSystemCount,
RendererFact[] playerRendererFacts)
{
@ -695,6 +724,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
aimPresentation = look != null && aimDotDistance >= 49.9f,
targeting = targeting != null,
beamPresentation = tractor != null && tractorBeamPower > 0.01f && tractorBeamParticleSystemCount > 0,
beamRelease = tractorRelease != null && tractorReleasedPower == 0f,
action = action != null,
combatPresentation = shot != null,
shotId = shot?.ShotId ?? "",
@ -721,13 +751,14 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
pilotBrightPixelCount = pilotBrightPixelCount,
cockpitProgressCount = cockpitProgressCount,
tractorBeamPower = tractorBeamPower,
tractorReleasedPower = tractorReleasedPower,
tractorBeamParticleSystemCount = tractorBeamParticleSystemCount,
playerRenderers = playerRendererFacts,
mapChannelRendererCount = mapChannelRendererCount,
mapChangedPixels = mapChangedPixels,
pilotCameraExcludesMapChannel = pilotCameraExcludesMapChannel,
mapCameraIncludesMapChannel = mapCameraIncludesMapChannel,
receipts = new[] { movement, look, targeting, tractor, action }
receipts = new[] { movement, look, targeting, tractor, tractorRelease, action }
};
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
File.WriteAllText(path, JsonUtility.ToJson(document, true));
@ -743,6 +774,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
public bool aimPresentation;
public bool targeting;
public bool beamPresentation;
public bool beamRelease;
public bool action;
public bool combatPresentation;
public string shotId;
@ -769,6 +801,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
public int pilotBrightPixelCount;
public int cockpitProgressCount;
public float tractorBeamPower;
public float tractorReleasedPower;
public int tractorBeamParticleSystemCount;
public RendererFact[] playerRenderers;
public int mapChannelRendererCount;

View file

@ -50,7 +50,7 @@
"dependencies": {
"org.gamecult.cultlib": "1.0.13",
"org.gamecult.eve.plugin-fields": "0.1.1",
"org.gamecult.eve.surface": "0.2.1",
"org.gamecult.eve.surface": "0.2.2",
"com.unity.mathematics": "1.3.2",
"com.unity.modules.particlesystem": "1.0.0"
}

View file

@ -29,6 +29,14 @@ namespace GameCult.Eve.Surface
[Key(5)] public string Availability { get; set; } = "available";
[Key(6)] public string SourceRef { get; set; } = "";
[Key(7)] public Dictionary<string, string> Payload { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
[Key(8)] public EveInputValueDocument? InputValue { get; set; }
}
[MessagePackObject]
public sealed class EveInputValueDocument
{
[Key(0)] public string Model { get; set; } = "";
[Key(1)] public string PayloadKey { get; set; } = "";
}
[MessagePackObject]

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.eve.surface",
"displayName": "Eve Surface Contract",
"version": "0.2.1",
"version": "0.2.2",
"unity": "2021.3",
"description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.",
"dependencies": {

View file

@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using GameCult.Eve.Surface;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityAdvertisedInputAction
{
public const string ButtonHoldValueModel = "button-hold.v1";
public const string AxisValueModel = "axis.v1";
private readonly EveInputActionDocument _action;
private EveUnityAdvertisedInputAction(EveInputActionDocument action)
{
_action = action;
}
public string ActionId => _action.ActionId ?? "";
public string Operation => _action.Operation ?? "";
public string Availability => _action.Availability ?? "";
public string InputValueModel => _action.InputValue?.Model ?? "";
public string InputValuePayloadKey => _action.InputValue?.PayloadKey ?? "";
public bool IsButtonHold => string.Equals(InputValueModel, ButtonHoldValueModel, StringComparison.Ordinal);
public static EveUnityAdvertisedInputAction Resolve(EveInputCapabilityDocument? capability, string actionId)
{
if (capability == null)
throw new InvalidOperationException("The provider did not advertise an input capability.");
var action = (capability.Actions ?? Array.Empty<EveInputActionDocument>()).FirstOrDefault(candidate =>
string.Equals(candidate?.ActionId, actionId, StringComparison.Ordinal));
if (action == null || string.IsNullOrWhiteSpace(action.Operation))
throw new InvalidOperationException($"Input action '{actionId}' is not advertised with an operation.");
if (!string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Input action '{actionId}' is not available.");
return new EveUnityAdvertisedInputAction(action);
}
public IReadOnlyDictionary<string, string> BuildPayload(string entityId, float? inputValue = null)
{
var payload = _action.Payload == null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(_action.Payload, StringComparer.Ordinal);
payload["entityId"] = entityId ?? "";
payload["actionId"] = ActionId;
if (inputValue.HasValue)
{
if (float.IsNaN(inputValue.Value) || float.IsInfinity(inputValue.Value))
throw new ArgumentOutOfRangeException(nameof(inputValue), "Input action value must be finite.");
if (string.IsNullOrWhiteSpace(InputValueModel) || string.IsNullOrWhiteSpace(InputValuePayloadKey))
throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a value payload.");
payload[InputValuePayloadKey] = inputValue.Value.ToString("R", CultureInfo.InvariantCulture);
}
return payload;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c7555b03e7f9451aa891e92958b845c5

View file

@ -203,21 +203,21 @@ namespace GameCult.Eve.UnityScene
string actionId,
DateTimeOffset? issuedAt = null)
{
var capability = InputCapability ??
throw new InvalidOperationException("The provider did not advertise an input capability.");
var action = Array.Find(capability.Actions ?? Array.Empty<EveInputActionDocument>(), candidate =>
string.Equals(candidate?.ActionId, actionId, StringComparison.Ordinal));
if (action == null || string.IsNullOrWhiteSpace(action.Operation))
throw new InvalidOperationException($"Input action '{actionId}' is not advertised with an operation.");
if (!string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Input action '{actionId}' is not available.");
var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId);
return RequireRuntime().SubmitCommandIntent(action.Operation, action.BuildPayload(entityId), issuedAt);
}
var payload = action.Payload == null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(action.Payload, StringComparer.Ordinal);
payload["entityId"] = entityId ?? "";
payload["actionId"] = action.ActionId;
return RequireRuntime().SubmitCommandIntent(action.Operation, payload, issuedAt);
public EveSurfaceCommandRequest SubmitAdvertisedActionValueIntent(
string entityId,
string actionId,
float inputValue,
DateTimeOffset? issuedAt = null)
{
var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId);
return RequireRuntime().SubmitCommandIntent(
action.Operation,
action.BuildPayload(entityId, inputValue),
issuedAt);
}
public void Disconnect()

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Eve.Surface;
using UnityEngine;
@ -32,6 +33,10 @@ namespace GameCult.Eve.UnityScene
private bool _hasLookYaw;
private float _pendingLookHorizontal;
private string _lookOwnerKey = "";
private readonly Dictionary<string, HeldBindingState> _heldBindings =
new Dictionary<string, HeldBindingState>(StringComparer.Ordinal);
public Func<string, bool?>? DigitalControlReader { get; set; }
public EveUnityPlayableWorldClientHost? Host
{
@ -39,6 +44,7 @@ namespace GameCult.Eve.UnityScene
set
{
if (ReferenceEquals(host, value)) return;
ReleaseHeldActions();
host = value;
ResetLookState();
}
@ -105,6 +111,21 @@ namespace GameCult.Eve.UnityScene
resolvedActionId);
}
public EveSurfaceCommandRequest? SubmitActionValue(
string actionId,
float inputValue,
DateTimeOffset? issuedAt = null)
{
var resolvedHost = ResolveHost();
if (resolvedHost?.ActiveWorld == null || string.IsNullOrWhiteSpace(actionId))
return null;
return resolvedHost.SubmitAdvertisedActionValueIntent(
resolvedHost.ActiveWorld.PlayerEntityId,
actionId,
inputValue,
issuedAt);
}
public EveSurfaceCommandRequest? SubmitLookInput(
float horizontalDelta,
DateTimeOffset? issuedAt = null)
@ -178,11 +199,98 @@ namespace GameCult.Eve.UnityScene
_nextCommandAt = Time.unscaledTime + Math.Max(0.01f, commandIntervalSeconds);
SubmitMoveVectorInput(Input.GetAxisRaw(horizontalAxis), Input.GetAxisRaw(verticalAxis));
SubmitPendingLookInput();
SubmitChangedHeldActions();
if (!string.IsNullOrWhiteSpace(actionButton) && Input.GetButtonDown(actionButton))
SubmitPrimaryAction();
}
private void SubmitChangedHeldActions()
{
var resolvedHost = ResolveHost();
var capability = resolvedHost?.InputCapability;
if (resolvedHost?.ActiveWorld == null || capability == null)
return;
var profile = (capability.DefaultProfiles ?? Array.Empty<EveInputProfileDocument>())
.FirstOrDefault(value => string.Equals(value.DeviceClass, "keyboard-mouse", StringComparison.Ordinal));
var retained = new HashSet<string>(StringComparer.Ordinal);
foreach (var binding in profile?.Bindings ?? Array.Empty<EveInputBindingDocument>())
{
var action = (capability.Actions ?? Array.Empty<EveInputActionDocument>())
.FirstOrDefault(candidate => string.Equals(candidate.ActionId, binding.ActionId, StringComparison.Ordinal));
if (action?.InputValue == null ||
!string.Equals(action.InputValue.Model, EveUnityAdvertisedInputAction.ButtonHoldValueModel, StringComparison.Ordinal) ||
!TryReadDigitalGesture(binding.Gesture, out var pressed))
continue;
var stateKey = string.IsNullOrWhiteSpace(binding.BindingId) ? binding.ActionId : binding.BindingId;
retained.Add(stateKey);
if (!_heldBindings.TryGetValue(stateKey, out var previous))
{
_heldBindings[stateKey] = new HeldBindingState(binding.ActionId, pressed);
if (pressed) SubmitActionValue(binding.ActionId, 1f);
continue;
}
if (previous.Pressed == pressed) continue;
_heldBindings[stateKey] = new HeldBindingState(binding.ActionId, pressed);
SubmitActionValue(binding.ActionId, pressed ? 1f : 0f);
}
foreach (var stale in _heldBindings.Keys.Where(key => !retained.Contains(key)).ToArray())
{
var previous = _heldBindings[stale];
if (previous.Pressed) TrySubmitRelease(previous.ActionId);
_heldBindings.Remove(stale);
}
}
private bool TryReadDigitalGesture(EveInputGestureDocument? gesture, out bool pressed)
{
pressed = false;
if (gesture == null ||
!(string.Equals(gesture.Kind, "direct", StringComparison.Ordinal) ||
string.Equals(gesture.Kind, "chord", StringComparison.Ordinal)))
return false;
var controls = gesture.Controls ?? Array.Empty<string>();
if (controls.Length == 0) return false;
foreach (var control in controls)
{
var value = DigitalControlReader?.Invoke(control) ?? ReadStandardDigitalControl(control);
if (!value.HasValue) return false;
if (!value.Value) return true;
}
pressed = true;
return true;
}
private static bool? ReadStandardDigitalControl(string control)
{
if (string.Equals(control, "mouse.primary", StringComparison.Ordinal)) return Input.GetMouseButton(0);
if (string.Equals(control, "mouse.secondary", StringComparison.Ordinal)) return Input.GetMouseButton(1);
if (string.Equals(control, "mouse.middle", StringComparison.Ordinal)) return Input.GetMouseButton(2);
const string keyboardPrefix = "keyboard.";
if (control != null && control.StartsWith(keyboardPrefix, StringComparison.Ordinal) &&
Enum.TryParse(control.Substring(keyboardPrefix.Length), true, out KeyCode key))
return Input.GetKey(key);
return null;
}
private void OnDisable() => ReleaseHeldActions();
private void ReleaseHeldActions()
{
foreach (var state in _heldBindings.Values)
if (state.Pressed) TrySubmitRelease(state.ActionId);
_heldBindings.Clear();
}
private void TrySubmitRelease(string actionId)
{
try { SubmitActionValue(actionId, 0f); }
catch (InvalidOperationException) { }
}
private EveUnityPlayableWorldClientHost? ResolveHost()
{
if (host != null)
@ -207,6 +315,18 @@ namespace GameCult.Eve.UnityScene
_pendingLookHorizontal = 0f;
_lookOwnerKey = "";
}
private readonly struct HeldBindingState
{
public HeldBindingState(string actionId, bool pressed)
{
ActionId = actionId ?? "";
Pressed = pressed;
}
public string ActionId { get; }
public bool Pressed { get; }
}
}
public readonly struct EveUnityPlayableWorldLookDirection

View file

@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using GameCult.Eve.Surface;
using NUnit.Framework;
#nullable enable
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityAdvertisedInputActionTests
{
[Test]
public void HeldValueOverridesAdvertisedPayloadWithoutMutatingCapability()
{
var sourcePayload = new Dictionary<string, string>(StringComparer.Ordinal)
{
["scalarValue"] = "0"
};
var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument
{
Actions = new[]
{
new EveInputActionDocument
{
ActionId = "pilot.scoop",
Operation = "aetheria.daemon.commands.SetTractorPower",
Availability = "available",
Payload = sourcePayload,
InputValue = new EveInputValueDocument
{
Model = EveUnityAdvertisedInputAction.ButtonHoldValueModel,
PayloadKey = "scalarValue"
}
}
}
}, "pilot.scoop");
var pressed = action.BuildPayload("entity.4", 1f);
var released = action.BuildPayload("entity.4", 0f);
Assert.That(action.IsButtonHold, Is.True);
Assert.That(pressed["scalarValue"], Is.EqualTo("1"));
Assert.That(released["scalarValue"], Is.EqualTo("0"));
Assert.That(pressed["entityId"], Is.EqualTo("entity.4"));
Assert.That(pressed["actionId"], Is.EqualTo("pilot.scoop"));
Assert.That(sourcePayload["scalarValue"], Is.EqualTo("0"));
}
[Test]
public void ValueFailsClosedWhenActionDoesNotAdvertiseValueContract()
{
var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument
{
Actions = new[]
{
new EveInputActionDocument
{
ActionId = "pilot.dock",
Operation = "aetheria.daemon.commands.DockNearest",
Availability = "available"
}
}
}, "pilot.dock");
Assert.Throws<InvalidOperationException>(() => action.BuildPayload("entity.4", 1f));
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a60138c42bf44c2ba38309b21a886c93

View file

@ -1,13 +1,13 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.30",
"version": "0.3.31",
"unity": "2021.3",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.13",
"org.gamecult.eve.plugin-fields": "0.1.1",
"org.gamecult.eve.surface": "0.2.1",
"org.gamecult.eve.surface": "0.2.2",
"com.unity.mathematics": "1.3.2",
"com.unity.modules.particlesystem": "1.0.0"
},