From 6c2bcd6afbf1f799c501f7ffd19172806efb6c8f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 14:36:43 +0100 Subject: [PATCH] Complete daemon-owned Terminus service loop --- .../AetheriaDaemonLoadoutGenerator.cs | 26 +++ .../AetheriaDaemonZoneGenerator.cs | 11 +- Aetheria.State.Daemon/Program.cs | 19 +- Aetheria.State.Verify/Program.cs | 2 +- .../Tests/DaemonRuntimeDocumentTests.cs | 196 +++++++++++++++++- .../AetheriaRuntimeCargoCapacityQueries.cs | 24 ++- .../Runtime/AetheriaRuntimeDaemonDocuments.cs | 3 +- .../AetheriaRuntimeDaemonOperationClient.cs | 6 + .../AetheriaRuntimeDaemonOperations.cs | 169 +++++++++++++-- .../AetheriaRuntimeDaemonOperationsClient.cs | 15 ++ .../AetheriaRuntimeDaemonSimulation.cs | 33 ++- ...AetheriaRuntimeDaemonSimulationSettings.cs | 2 + ...AetheriaRuntimeInputCapabilityDocuments.cs | 66 +++++- docs/aetheria-perfect-machine-map.md | 7 + docs/original-game-migration-matrix.md | 10 +- docs/original-game-simulation-spec.md | 12 +- docs/original-game-specification.md | 7 + docs/renderless-aetheria-architecture.md | 6 + 18 files changed, 560 insertions(+), 54 deletions(-) diff --git a/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs index 7bf2eb6f8..e3bf999de 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonLoadoutGenerator.cs @@ -81,6 +81,10 @@ public sealed class AetheriaDaemonLoadoutGenerator var cargo = PackCargo(cargoBay, availabilityFactionKey, scenarioCargo, string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase)); + var sensorArrayCount = string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase) ? 2 : 1; + for (var sensorIndex = 0; sensorIndex < sensorArrayCount; sensorIndex++) + TryAddFreeSpaceItem(hull, availabilityFactionKey, occupied, slots, 2, item => + IsGear(item) && HasBehavior(item, "Sensor")); var selectedKeys = new[] { (Role: "hull", Key: hull.ItemKey) } .Concat(slots.Select(value => (Role: "equipment", Key: value.ItemKey))) .Concat(cargo.Select(value => (Role: "cargo", Key: value.Item.ItemKey))); @@ -146,6 +150,28 @@ public sealed class AetheriaDaemonLoadoutGenerator return selected.Item; } + private AetheriaRuntimeCatalogItem? TryAddFreeSpaceItem( + AetheriaRuntimeCatalogItem hull, + string factionKey, + HashSet<(int X, int Y)> occupied, + List slots, + double sizeExponent, + Func filter) + { + var candidates = Available(factionKey) + .Where(filter) + .Select(item => (Item: item, Fit: FindFit(hull, item, occupied))) + .Where(value => value.Fit.HasValue) + .ToArray(); + var selected = PickWeighted(candidates, sizeExponent, factionKey, value => value.Item); + if (selected.Item == null || !selected.Fit.HasValue) + return null; + var fit = selected.Fit.Value; + slots.Add(Slot(fit.X, fit.Y, selected.Item.ItemKey)); + Reserve(occupied, selected.Item, fit.X, fit.Y, fit.Rotation); + return selected.Item; + } + private AetheriaLoadoutItemSlot[] PackCargo( AetheriaRuntimeCatalogItem cargoBay, string factionKey, diff --git a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs index 64e253396..1ed940500 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs @@ -90,7 +90,7 @@ internal static class AetheriaDaemonZoneGenerator new AetheriaDroppedPickupSnapshot { PickupIndex = 0, - Position = Vec3(-15, 0, -5), + Position = Vec3(-40, 0, -29), Velocity = Vec3(0, 0, 0), Item = new AetheriaLoadoutItem { ItemKey = "scrap-metal", Quantity = 1 } } @@ -216,7 +216,7 @@ internal static class AetheriaDaemonZoneGenerator var entities = new[] { Entity(loadouts, availabilityFactions, "Anchor Station", "station", -50, -30, 0, 0, "player", 760, keys[6], [keys[1], keys[2], keys[3], keys[4], keys[6], keys[7], keys[11]], ["repair-parts", "reactor-fuel", "drone-core"]), - Entity(loadouts, availabilityFactions, "Vanguard One", "ship", -40, -30, 10, 4, "player", 540, keys[6], [keys[0], keys[2], keys[4], keys[6], keys[7]], ["coilgun-ammo", "field-rations"]), + Entity(loadouts, availabilityFactions, "Vanguard One", "ship", -40, -30, 0, 0, "player", 540, keys[6], [keys[0], keys[2], keys[4], keys[6], keys[7]], ["coilgun-ammo", "field-rations"]), Entity(loadouts, availabilityFactions, "Wing Two", "ship", 145, 125, -5, 7, "player", 450, keys[6], [keys[0], keys[1], keys[6], keys[8]], ["sensor-buoy", "shield-cell"]), Entity(loadouts, availabilityFactions, "Torch Three", "ship", -235, 210, 8, -4, "player", 500, keys[7], [keys[0], keys[1], keys[7]], ["micro-missile", "coolant-pack"]), Entity(loadouts, availabilityFactions, "Foundry Tug", "ship", 330, -155, -2, 3, "player", 390, keys[6], [keys[0], keys[1], keys[6], keys[10]], ["ore-canister", "field-rations"]), @@ -230,6 +230,13 @@ internal static class AetheriaDaemonZoneGenerator }; foreach (var index in new[] { 1, 2, 3, 4 }) entities[index].HomeEntityKey = keys[0]; + entities[1].CargoContents = + [ + new AetheriaCargoBayLoadout + { + Items = Array.Empty() + } + ]; entities[2].AgentTaskCapabilities = ["attack", "defend", "explore"]; entities[3].AgentTaskCapabilities = ["attack", "defend"]; entities[4].AgentTaskCapabilities = ["mine", "haul", "tow", "explore"]; diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 3003218d8..3296ddab6 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -199,13 +199,15 @@ static async Task TickAsync( policyRejectedCommandIds); var terminus = string.Equals(ingressState.GameMode, AetheriaGameSessionState.TerminusMode, StringComparison.Ordinal); var simulationClockCommands = authorizedCommands - .Where(command => command.Kind == AetheriaRuntimeDaemonCommandKinds.SetSimulationRate) + .Where(command => command.Kind == AetheriaRuntimeDaemonCommandKinds.SetSimulationRate || + command.Kind == AetheriaRuntimeDaemonCommandKinds.AdvanceSimulationStep) .ToArray(); if (!terminus && simulationClockCommands.Length > 0) { policyRejectedCommandIds.AddRange(simulationClockCommands.Select(command => command.CommandId)); authorizedCommands = authorizedCommands - .Where(command => command.Kind != AetheriaRuntimeDaemonCommandKinds.SetSimulationRate) + .Where(command => command.Kind != AetheriaRuntimeDaemonCommandKinds.SetSimulationRate && + command.Kind != AetheriaRuntimeDaemonCommandKinds.AdvanceSimulationStep) .ToArray(); } else if (simulationClockCommands.Length > 0) @@ -217,7 +219,7 @@ static async Task TickAsync( if (terminus && !advanceSimulation) { authorizedCommands = authorizedCommands - .Where(command => command.Kind == AetheriaRuntimeDaemonCommandKinds.SetSimulationRate) + .Where(command => !AetheriaRuntimeDaemonOperations.RequiresSimulationStep(command.Kind)) .ToArray(); } var simulationTimeSeconds = (currentFrame?.SimulationTimeSeconds ?? 0) + @@ -369,6 +371,11 @@ static async Task ApplySimulationClockCommandsAsync( .ReadAsync().ConfigureAwait(false) ?? new AetheriaGameSessionState(); foreach (var command in commands.OrderBy(command => command.IssuedAtUtc, StringComparer.Ordinal)) { + if (command.Kind == AetheriaRuntimeDaemonCommandKinds.AdvanceSimulationStep) + { + ingressState.SimulationStepAccumulator += 1; + continue; + } if (AetheriaRuntimeDaemonOperations.IsSupportedSimulationRate(command.ScalarValue)) session.SimulationRate = command.ScalarValue; } @@ -1526,10 +1533,12 @@ static async Task PublishDaemonApiDocumentsAsync( .ConfigureAwait(false); var gameSession = await node.MutableDocument(AetheriaStateNode.GameSessionStateKey) .ReadAsync().ConfigureAwait(false); + var inputCatalog = AetheriaRuntimeCatalogStore.OpenReadOnly(node.StatePath); await node.MutableDocument(AetheriaRuntimeVerseRecordKeys.PilotInputCapability) .ReplaceAsync(AetheriaRuntimeInputCapabilityDocument.FromFrame( result.Frame, - string.Equals(gameSession?.Mode, AetheriaGameSessionState.TerminusMode, StringComparison.Ordinal)).ToEveDocument()) + string.Equals(gameSession?.Mode, AetheriaGameSessionState.TerminusMode, StringComparison.Ordinal), + inputCatalog).ToEveDocument()) .ConfigureAwait(false); var mainMenuState = await node.MutableDocument(AetheriaStateNode.MainMenuStateKey) .ReadAsync() @@ -2687,7 +2696,7 @@ static async Task ReadRuntimeRunCheckpointA .ToArray(), GenerationSeed = run.GenerationSeed, CurrentEntityKey = run.CurrentEntityKey ?? "", - Credits = 1000 + Credits = 1000000 }; } diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index c471f55f2..605f50e76 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -11054,7 +11054,7 @@ static void RequireTypedDaemonCommandPayloads(string root) "return Submit((client, frame) => client.SetTarget(frame, targetEntityKey));", "return Send((client, frame) => client.TransferCargoItem(", "command.CargoTransfer.OriginEntityKey", - "command.TradePurchase.TotalPrice", + "ReadPayloadString(request, \"itemKey\", \"\")", "command.LootPickup.ItemKey", "command.LoadoutRestore.TemplateName", "command.EquipmentTransfer.SourceKind", diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 02a090766..082bdde4e 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -2036,6 +2036,8 @@ public class DaemonRuntimeDocumentTests FactionKey = "player", IsActive = true, TargetEntityIndex = 1, + Visibility = 0.37, + VisibilitySourceCount = 2, PositionX = 0, PositionZ = 0 }, @@ -2178,7 +2180,9 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(1.25, weapon.BurstInterval, 0.0001); Assert.AreEqual(88, zone.Entities[0].StatGrids.Single(grid => grid.Name == "hull").Values[0], 0.0001); Assert.AreEqual(66, zone.Entities[1].StatGrids.Single(grid => grid.Name == "hull").Values[0], 0.0001); - Assert.AreEqual(222, zone.Entities[0].Visibility, 0.0001); + Assert.AreEqual(0.37, zone.Entities[0].Visibility, 0.0001); + Assert.AreEqual(2, zone.Entities[0].VisibilitySourceCount); + Assert.IsTrue(zone.Entities[0].Contacts.Single(contact => contact.TargetEntityIndex == 1).Visible); var frame = AetheriaRuntimeDaemonFrameDocument.Create( run, @@ -2192,6 +2196,84 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(120, frame.SimulationSettings.AttackRange, 0.0001); } + [Test] + public void SensorReachDerivesFromInstalledEquipmentRatherThanEntityKind() + { + var unitStat = new AetheriaRuntimeBehaviorValue( + "performance-stat", "", 0, false, "", "", + new[] + { + NumberValue(1), NumberValue(1), NumberValue(1), + NumberValue(0), NumberValue(1), EmptyBehaviorValue("stat-recipe") + }, + Array.Empty()); + var catalog = new AetheriaRuntimeCatalogSnapshot( + new[] + { + CatalogItem("sensor", new[] + { + new AetheriaRuntimeBehaviorPayload( + 0, "Sensor", 1, + new[] { new AetheriaRuntimeBehaviorField(3, unitStat) }) + }) + }, + Array.Empty(), + Array.Empty()); + AetheriaRuntimeLoadoutItemSlotCommit Sensor() => new() + { + Item = new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = "sensor", Quality = 1, Durability = 1, Enabled = true + } + }; + var ship = new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 0, Kind = "ship", FactionKey = "player", IsActive = true, + Equipment = new[] { Sensor() }, Visibility = 0.4 + }; + var station = new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 1, Kind = "station", FactionKey = "player", IsActive = true, + Equipment = new[] { Sensor() }, PositionZ = 10, Visibility = 0.6 + }; + var arrayShip = new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 2, Kind = "ship", FactionKey = "player", IsActive = true, + Equipment = new[] { Sensor(), Sensor() }, PositionZ = 20, Visibility = 0.8 + }; + var target = new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 3, Kind = "ship", FactionKey = "neutral", IsActive = true, + PositionX = 250 + }; + var run = new AetheriaRuntimeRunCheckpointCommit + { + CurrentZoneIndex = 0, + Zones = new[] { new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = new[] { ship, station, arrayShip, target } } } + }; + var defaults = AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault; + var settings = new AetheriaRuntimeDaemonSimulationSettings( + defaults.PawnSpeed, defaults.RaiderSpeed, defaults.AttackRange, defaults.AttackHoldRatio, + defaults.PawnProjectileDamage, defaults.RaiderProjectileDamage, defaults.WeaponCooldownSeconds, + defaults.ProjectileSpeed, defaults.ProjectileRadius, defaults.ProjectileLifetimeSeconds, + defaults.ProjectileSpawnOffset, defaults.ProjectileHeatScale, defaults.HeatDissipationPerSecond, + 999, 200, defaults.PlayerStationHull, defaults.HostileStationHull, defaults.PlayerEntityHull, + defaults.RaiderEntityHull, defaults.StationShield, defaults.EntityShield); + + AetheriaRuntimeDaemonSimulation.Step( + run, new AetheriaRuntimeDaemonIntentState(), 0.1, settings, + new PassthroughPhysicalPayloadPhysics(), new PassthroughWorldPhysics(), catalog); + + Assert.IsFalse(ship.Contacts.Single(contact => contact.TargetEntityIndex == 3).Visible); + Assert.IsFalse(station.Contacts.Single(contact => contact.TargetEntityIndex == 3).Visible, + "Entity kind must not grant a station privileged sensor reach."); + Assert.IsTrue(arrayShip.Contacts.Single(contact => contact.TargetEntityIndex == 3).Visible, + "Additional installed arrays should increase reach regardless of entity kind."); + Assert.AreEqual(0.4, ship.Visibility, 0.0001); + Assert.AreEqual(0.6, station.Visibility, 0.0001); + Assert.AreEqual(0.8, arrayShip.Visibility, 0.0001); + } + [Test] public void TerminusPilotTractorScoopsCargoFromTargetedWreck() { @@ -4190,10 +4272,36 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(0.25, run.Zones[0].Entities[0].ShutdownPerformance, 0.0001); } + [Test] + public void DaemonOperationsClassifiesPausedCommandsBySimulationDependency() + { + Assert.IsFalse(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.TradePurchase)); + Assert.IsFalse(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.SetTarget)); + Assert.IsFalse(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.TransferCargoItem)); + Assert.IsFalse(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.SetSimulationRate)); + Assert.IsFalse(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.AdvanceSimulationStep)); + Assert.IsTrue(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.SetMoveVector)); + Assert.IsTrue(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup)); + Assert.IsTrue(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.DockNearest)); + Assert.IsTrue(AetheriaRuntimeDaemonOperations.RequiresSimulationStep( + AetheriaRuntimeDaemonCommandKinds.Undock)); + } + [Test] public void DaemonOperationsOwnsTradePurchaseCreditsAndCargoInDaemonState() { var run = RunWithTwoEntities(); + run.CurrentEntityKey = "zone.0.entity.1"; + run.Zones[0].Entities[0].DockingBayAssignments = new[] { 1 }; + run.Zones[0].Entities[0].ChildEntityIndices = new[] { 1 }; var command = AetheriaRuntimeDaemonCommandDocument.Create( AetheriaRuntimeDaemonCommandKinds.TradePurchase, "codex", @@ -4206,8 +4314,8 @@ public class DaemonRuntimeDocumentTests command.TradePurchase.PurchaseKind = "commodity"; command.TradePurchase.ItemKey = "ore"; command.TradePurchase.Quantity = 5; - command.TradePurchase.UnitPrice = 40; - command.TradePurchase.TotalPrice = 200; + command.TradePurchase.UnitPrice = 0; + command.TradePurchase.TotalPrice = 0; command.TradePurchase.StationEntityKey = "zone.0.entity.0"; command.TradePurchase.StationCargoIndex = 0; command.TradePurchase.TargetEntityKey = "zone.0.entity.1"; @@ -4216,7 +4324,16 @@ public class DaemonRuntimeDocumentTests command.TradePurchase.SourceY = 3; command.TradePurchase.CreatesDockedShip = false; - var result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { command }); + var result = AetheriaRuntimeDaemonOperations.Execute( + run, + new[] { command }, + new AetheriaRuntimeDaemonOperationContext + { + Catalog = new AetheriaRuntimeCatalogSnapshot( + new[] { CatalogItem("ore", Array.Empty(), price: 40) }, + Array.Empty(), + Array.Empty()) + }); var stationCargo = run.Zones[0].Entities[0].CargoContents[0].Items; var targetCargo = run.Zones[0].Entities[1].CargoContents[0].Items; @@ -4229,10 +4346,58 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(5, targetCargo[0].Item.Quantity); } + [Test] + public void DaemonOperationsRejectsTradeWhenCurrentEntityIsNotDockedWithoutMutatingState() + { + var run = RunWithTwoEntities(); + var command = AetheriaRuntimeDaemonCommandDocument.Create( + AetheriaRuntimeDaemonCommandKinds.TradePurchase, + "codex", + "session-trade-undocked", + 30, + "zone.0.entity.1"); + command.TradePurchase.ItemKey = "ore"; + command.TradePurchase.Quantity = 1; + command.TradePurchase.StationEntityKey = "zone.0.entity.0"; + command.TradePurchase.StationCargoIndex = 0; + command.TradePurchase.TargetEntityKey = "zone.0.entity.1"; + command.TradePurchase.TargetCargoIndex = 0; + command.TradePurchase.SourceX = 2; + command.TradePurchase.SourceY = 3; + + var result = AetheriaRuntimeDaemonOperations.Execute( + run, + new[] { command }, + new AetheriaRuntimeDaemonOperationContext + { + Catalog = new AetheriaRuntimeCatalogSnapshot( + new[] { CatalogItem("ore", Array.Empty(), price: 40) }, + Array.Empty(), + Array.Empty()) + }); + + Assert.AreEqual(0, result.AppliedCommandIds.Count); + Assert.AreEqual(1, result.RejectedCommandIds.Count); + Assert.AreEqual(1000, run.Credits); + Assert.AreEqual(12, run.Zones[0].Entities[0].CargoContents[0].Items[0].Item.Quantity); + Assert.AreEqual(0, run.Zones[0].Entities[1].CargoContents[0].Items.Count); + } + [Test] public void DaemonOperationsCreatesPurchasedDockedShipInDaemonState() { var run = RunWithTwoEntities(); + run.Zones[0].Entities[1].DockingBayAssignments = new[] { 0, -1 }; + run.Zones[0].Entities[1].ChildEntityIndices = new[] { 0 }; + run.Zones[0].Entities[1].CargoContents[0].Items = new[] + { + new AetheriaRuntimeLoadoutItemSlotCommit + { + X = 4, + Y = 5, + Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "starter-hull", Quantity = 1 } + } + }; var command = AetheriaRuntimeDaemonCommandDocument.Create( AetheriaRuntimeDaemonCommandKinds.TradePurchase, "codex", @@ -4247,10 +4412,23 @@ public class DaemonRuntimeDocumentTests command.TradePurchase.Quantity = 1; command.TradePurchase.UnitPrice = 300; command.TradePurchase.TotalPrice = 300; + command.TradePurchase.StationEntityKey = "zone.0.entity.1"; + command.TradePurchase.StationCargoIndex = 0; command.TradePurchase.TargetEntityKey = "zone.0.entity.1"; + command.TradePurchase.SourceX = 4; + command.TradePurchase.SourceY = 5; command.TradePurchase.CreatesDockedShip = true; - var result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { command }); + var result = AetheriaRuntimeDaemonOperations.Execute( + run, + new[] { command }, + new AetheriaRuntimeDaemonOperationContext + { + Catalog = new AetheriaRuntimeCatalogSnapshot( + new[] { CatalogItem("starter-hull", Array.Empty(), price: 300, hullType: "ship") }, + Array.Empty(), + Array.Empty()) + }); var zone = run.Zones[0]; Assert.AreEqual(1, result.AppliedCommandIds.Count); @@ -5437,7 +5615,9 @@ public class DaemonRuntimeDocumentTests private static AetheriaRuntimeCatalogItem CatalogItem( string itemKey, - IReadOnlyList behaviorPayloads) + IReadOnlyList behaviorPayloads, + int price = 0, + string hullType = "") { return new AetheriaRuntimeCatalogItem( itemKey, @@ -5445,7 +5625,7 @@ public class DaemonRuntimeDocumentTests "", "", "", - 0, + price, 0, 0, 0, @@ -5461,7 +5641,7 @@ public class DaemonRuntimeDocumentTests Array.Empty(), behaviorPayloads, "", - "", + hullType, behaviorPayloads.Select(payload => payload.Kind).ToArray(), 1, false, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCargoCapacityQueries.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCargoCapacityQueries.cs index 779ee1470..e85c3f77f 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCargoCapacityQueries.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCargoCapacityQueries.cs @@ -36,9 +36,31 @@ namespace GameCult.Aetheria.State.Verse Math.Max(0, Capacity(entity, catalog) - Occupied(entity, catalog)); public static int UnitsThatFit(AetheriaRuntimeEntitySnapshotCommit? entity, AetheriaRuntimeCatalogSnapshot? catalog, string itemKey) + { + return UnitsThatFit(entity, catalog, itemKey, 0); + } + + public static int UnitsThatFit( + AetheriaRuntimeEntitySnapshotCommit? entity, + AetheriaRuntimeCatalogSnapshot? catalog, + string itemKey, + int cargoIndex) { var volume = catalog?.FindItem(itemKey ?? "")?.Volume ?? 0; - return volume <= 0 ? int.MaxValue : Math.Max(0, (int)Math.Floor(Available(entity, catalog) / volume)); + if (volume <= 0) + return int.MaxValue; + var cargoBays = (entity?.Equipment ?? Array.Empty()) + .Select(slot => catalog?.FindItem(slot?.Item?.ItemKey ?? "")) + .Where(item => item != null && item.InteriorOccupiedCells > 0) + .ToArray(); + if (cargoIndex < 0 || cargoIndex >= cargoBays.Length) + return 0; + var capacity = cargoBays[cargoIndex]!.InteriorOccupiedCells; + var bay = (entity?.CargoContents ?? Array.Empty()) + .ElementAtOrDefault(cargoIndex); + var occupied = (bay?.Items ?? Array.Empty()) + .Sum(slot => Volume(slot?.Item, catalog!)); + return Math.Max(0, (int)Math.Floor((capacity - occupied) / volume)); } private static double Volume(AetheriaRuntimeLoadoutItemCommit? item, AetheriaRuntimeCatalogSnapshot catalog) => diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs index 44ec57606..df23c6e83 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs @@ -195,7 +195,8 @@ namespace GameCult.Aetheria.State.Verse ToggleShieldEnabled, IssueAgentTask, CancelAgentTask, - SetSimulationRate + SetSimulationRate, + AdvanceSimulationStep } [CultDocument("gamecult.aetheria.daemon_provider_advertisement", "gamecult.aetheria.daemon_provider_advertisement.v1")] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs index 7861f540a..396da0bc4 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs @@ -165,6 +165,12 @@ namespace GameCult.Aetheria.State.Verse return Send(command); } + internal AetheriaRuntimeDaemonCommandEnvelope AdvanceSimulationStep( + AetheriaRuntimeDaemonFrameDocument? frame) + { + return Send(Create(AetheriaRuntimeDaemonCommandKinds.AdvanceSimulationStep, frame)); + } + internal AetheriaRuntimeDaemonCommandEnvelope SetLookDirection( AetheriaRuntimeDaemonFrameDocument? frame, double directionX, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index a4bd52ac4..7616c1ec7 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -143,7 +143,7 @@ namespace GameCult.Aetheria.State.Verse case AetheriaRuntimeDaemonCommandKinds.ToggleHullConductivity: return ApplyToggleHullConductivity(run, command); case AetheriaRuntimeDaemonCommandKinds.TradePurchase: - return ApplyTradePurchase(run, command); + return ApplyTradePurchase(run, command, context.Catalog); case AetheriaRuntimeDaemonCommandKinds.RestoreLoadout: return ApplyRestoreLoadout(run, command, context); case AetheriaRuntimeDaemonCommandKinds.SetMoveVector: @@ -181,6 +181,8 @@ namespace GameCult.Aetheria.State.Verse return ApplyCancelAgentTask(run, command); case AetheriaRuntimeDaemonCommandKinds.SetSimulationRate: return IsSupportedSimulationRate(command.ScalarValue); + case AetheriaRuntimeDaemonCommandKinds.AdvanceSimulationStep: + return true; default: return false; } @@ -191,6 +193,27 @@ namespace GameCult.Aetheria.State.Verse public static bool IsSupportedSimulationRate(double value) => SupportedSimulationRates.Any(rate => Math.Abs(value - rate) < 0.000001); + public static bool RequiresSimulationStep(AetheriaRuntimeDaemonCommandKinds kind) + { + switch (kind) + { + case AetheriaRuntimeDaemonCommandKinds.SetMoveVector: + case AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup: + case AetheriaRuntimeDaemonCommandKinds.SetWeaponGroupActive: + case AetheriaRuntimeDaemonCommandKinds.SetBehaviorActive: + case AetheriaRuntimeDaemonCommandKinds.ActivateConsumable: + case AetheriaRuntimeDaemonCommandKinds.SensorPing: + case AetheriaRuntimeDaemonCommandKinds.Dock: + case AetheriaRuntimeDaemonCommandKinds.DockNearest: + case AetheriaRuntimeDaemonCommandKinds.Undock: + case AetheriaRuntimeDaemonCommandKinds.Interact: + case AetheriaRuntimeDaemonCommandKinds.EnterWormhole: + return true; + default: + return false; + } + } + private static bool ApplyIssueAgentTask( AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeDaemonCommandDocument command) @@ -748,7 +771,7 @@ namespace GameCult.Aetheria.State.Verse var quantity = transfer.Quantity > 0 ? transfer.Quantity : (int)Math.Round(command.ScalarValue); - if (quantity <= 0 || quantity > AetheriaRuntimeCargoCapacityQueries.UnitsThatFit(destinationEntity, catalog, command.TextValue)) + if (quantity <= 0 || quantity > AetheriaRuntimeCargoCapacityQueries.UnitsThatFit(destinationEntity, catalog, command.TextValue, destinationCargoIndex)) return false; if (!TryRemoveCargoItemQuantity( originEntity, @@ -917,18 +940,58 @@ namespace GameCult.Aetheria.State.Verse private static bool ApplyTradePurchase( AetheriaRuntimeRunCheckpointCommit run, - AetheriaRuntimeDaemonCommandDocument command) + AetheriaRuntimeDaemonCommandDocument command, + AetheriaRuntimeCatalogSnapshot? catalog) { var purchase = command.TradePurchase ?? new AetheriaRuntimeTradePurchaseCommand(); - var totalPrice = purchase.TotalPrice; - if (totalPrice < 0 || run.Credits < totalPrice) + var itemKey = purchase.ItemKey ?? ""; + var typedItem = catalog?.FindItem(itemKey); + var quantity = Math.Max(1, purchase.Quantity); + if (typedItem == null || + !TryResolveDockParent(run, run.CurrentEntityKey, out var dockParentKey, out var dockParent)) return false; - if (purchase.CreatesDockedShip) + if (!TryFindStationStock( + dockParent, + itemKey, + purchase.StationCargoIndex, + purchase.SourceX, + purchase.SourceY, + out var stationCargoIndex, + out var stockSlot)) { - if (!ApplyCreateDockedShipPurchase(run, purchase, out var purchasedShipKey)) - return false; + return false; + } + var stationEntity = dockParent; + var unitPrice = AetheriaRuntimeDaemonTradeItemQueries.TradeItemValue( + typedItem, + stockSlot.Item, + catalog.TradeValueSettings).Price; + var totalPrice = checked(unitPrice * quantity); + if (unitPrice < 0 || run.Credits < totalPrice) + return false; + + var createsDockedShip = !string.IsNullOrWhiteSpace(typedItem.HullType); + if (createsDockedShip) + { + if (quantity != 1 || !HasAvailableDockingBay(dockParent)) + return false; + purchase.TargetEntityKey = dockParentKey; + if (!TryRemoveCargoItemQuantity( + stationEntity, + stationCargoIndex, + itemKey, + purchase.SourceX, + purchase.SourceY, + 1, + out var purchasedHull)) + return false; + if (!ApplyCreateDockedShipPurchase(run, purchase, out var purchasedShipKey)) + { + AddCargoItem(stationEntity, stationCargoIndex, purchasedHull); + return false; + } run.CurrentEntityKey = purchasedShipKey; run.Credits -= totalPrice; return true; @@ -936,24 +999,17 @@ namespace GameCult.Aetheria.State.Verse if (!TryResolveCargoBay( run, - purchase.StationEntityKey, - purchase.StationCargoIndex, - out var stationEntity, - out var stationCargoIndex, - out _) || - !TryResolveCargoBay( - run, - purchase.TargetEntityKey, - purchase.TargetCargoIndex, + run.CurrentEntityKey, + Math.Max(0, purchase.TargetCargoIndex), out var targetEntity, out var targetCargoIndex, - out _)) + out _) || + !IsEntityDockedAt(run, run.CurrentEntityKey, dockParent) || + quantity > AetheriaRuntimeCargoCapacityQueries.UnitsThatFit(targetEntity, catalog, itemKey, targetCargoIndex)) { return false; } - var itemKey = purchase.ItemKey ?? ""; - var quantity = Math.Max(1, purchase.Quantity); if (!TryRemoveCargoItemQuantity( stationEntity, stationCargoIndex, @@ -973,6 +1029,79 @@ namespace GameCult.Aetheria.State.Verse return true; } + private static bool TryResolveDockParent( + AetheriaRuntimeRunCheckpointCommit run, + string childEntityKey, + out string parentEntityKey, + out AetheriaRuntimeEntitySnapshotCommit parent) + { + parentEntityKey = ""; + parent = null!; + if (!TryResolveEntity(run, childEntityKey, out var zoneIndex, out var childIndex, out _)) + return false; + var zone = (run.Zones ?? Array.Empty()) + .FirstOrDefault(candidate => candidate != null && candidate.ZoneIndex == zoneIndex); + parent = (zone?.Entities ?? Array.Empty()) + .FirstOrDefault(entity => entity != null && + (entity.DockingBayAssignments ?? Array.Empty()).Contains(childIndex))!; + if (parent == null) + return false; + parentEntityKey = AetheriaRuntimeRunCheckpointCommit.EntityRecordKey(run.RunId, zoneIndex, parent.EntityIndex); + return true; + } + + private static bool IsEntityDockedAt( + AetheriaRuntimeRunCheckpointCommit run, + string entityKey, + AetheriaRuntimeEntitySnapshotCommit parent) + { + return TryResolveEntity(run, entityKey, out _, out var entityIndex, out _) && + (parent.DockingBayAssignments ?? Array.Empty()).Contains(entityIndex); + } + + private static bool HasAvailableDockingBay(AetheriaRuntimeEntitySnapshotCommit parent) => + (parent.DockingBayAssignments ?? Array.Empty()).Any(index => index < 0); + + private static bool TryFindCargoItem( + AetheriaRuntimeCargoBayLoadoutCommit cargo, + string itemKey, + int x, + int y, + out AetheriaRuntimeLoadoutItemSlotCommit slot) + { + slot = (cargo?.Items ?? Array.Empty()) + .FirstOrDefault(candidate => candidate?.Item != null && + string.Equals(candidate.Item.ItemKey ?? "", itemKey, StringComparison.Ordinal) && + candidate.X == x && candidate.Y == y)!; + return slot != null; + } + + private static bool TryFindStationStock( + AetheriaRuntimeEntitySnapshotCommit station, + string itemKey, + int requestedCargoIndex, + int requestedX, + int requestedY, + out int cargoIndex, + out AetheriaRuntimeLoadoutItemSlotCommit slot) + { + cargoIndex = -1; + slot = null!; + var cargo = station.CargoContents ?? Array.Empty(); + IEnumerable indices = requestedCargoIndex >= 0 && requestedCargoIndex < cargo.Count + ? new[] { requestedCargoIndex } + : Enumerable.Range(0, cargo.Count); + foreach (var index in indices) + { + if (TryFindCargoItem(cargo[index], itemKey, requestedX, requestedY, out slot)) + { + cargoIndex = index; + return true; + } + } + return false; + } + private static bool ApplyCreateDockedShipPurchase( AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeTradePurchaseCommand purchase, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs index 1cacb3dcb..6393df83d 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationsClient.cs @@ -482,6 +482,7 @@ public sealed class AetheriaRuntimeDaemonOperationsClient AetheriaRuntimeDaemonCommandKinds.SetSimulationRate => client.SetSimulationRate( frame, ReadPayloadDouble(request, "scalarValue", 0.0)), + AetheriaRuntimeDaemonCommandKinds.AdvanceSimulationStep => client.AdvanceSimulationStep(frame), AetheriaRuntimeDaemonCommandKinds.SetLookDirection => client.SetLookDirection( frame, ReadPayloadDouble(request, "directionX", 0.0), @@ -528,6 +529,20 @@ public sealed class AetheriaRuntimeDaemonOperationsClient AetheriaRuntimeDaemonCommandKinds.CancelAgentTask => client.CancelAgentTask( frame, ReadPayloadString(request, "taskId", "")), + AetheriaRuntimeDaemonCommandKinds.TradePurchase => client.TradePurchase( + frame, + "", + ReadPayloadString(request, "itemKey", ""), + (int)ReadPayloadDouble(request, "quantity", 1), + 0, + 0, + ReadPayloadString(request, "stationEntityKey", ""), + (int)ReadPayloadDouble(request, "stationCargoIndex", -1), + ReadPayloadString(request, "targetEntityKey", ""), + (int)ReadPayloadDouble(request, "targetCargoIndex", -1), + (int)ReadPayloadDouble(request, "sourceX", int.MinValue), + (int)ReadPayloadDouble(request, "sourceY", int.MinValue), + false), _ => AetheriaRuntimeDaemonSurfaceCommandCatalog.TrySubmitArgumentless( client, frame, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 8c9145c5c..b02f9f917 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -62,7 +62,7 @@ namespace GameCult.Aetheria.State.Verse frameId, simulationTimeSeconds); AetheriaRuntimeMiningSimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); AetheriaRuntimeSurveySimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); - RefreshContacts(entities, settings); + RefreshContacts(entities, settings, catalog); } } @@ -1724,10 +1724,12 @@ namespace GameCult.Aetheria.State.Verse private static void RefreshContacts( IReadOnlyList entities, - AetheriaRuntimeDaemonSimulationSettings settings) + AetheriaRuntimeDaemonSimulationSettings settings, + AetheriaRuntimeCatalogSnapshot? catalog) { foreach (var observer in entities) { + var sensorRange = ResolveSensorRange(observer, settings, catalog); var contacts = new List(); foreach (var target in entities) { @@ -1735,7 +1737,7 @@ namespace GameCult.Aetheria.State.Verse continue; var distance = Math.Sqrt(DistanceSq(observer, target)); - var visible = distance <= ResolveSensorRange(observer, settings); + var visible = distance <= sensorRange; if (!visible && !Hostile(observer, target)) continue; @@ -1749,8 +1751,6 @@ namespace GameCult.Aetheria.State.Verse } observer.Contacts = contacts; - observer.Visibility = observer.IsActive ? ResolveSensorRange(observer, settings) : 0; - observer.VisibilitySourceCount = observer.IsActive ? 1 : 0; } } @@ -2066,10 +2066,27 @@ namespace GameCult.Aetheria.State.Verse private static double ResolveSensorRange( AetheriaRuntimeEntitySnapshotCommit entity, - AetheriaRuntimeDaemonSimulationSettings settings) + AetheriaRuntimeDaemonSimulationSettings settings, + AetheriaRuntimeCatalogSnapshot? catalog) { - return string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase) - ? settings.StationSensorRange + if (!entity.IsActive) + return 0; + + var equipmentStates = (entity.EquipmentStates ?? Array.Empty()) + .Where(state => state != null) + .ToDictionary(state => state.EquipmentIndex); + var sensitivitySquared = AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, "Sensor") + .Where(sensor => sensor.Item.Enabled && sensor.Item.Durability > 0 && + (!equipmentStates.TryGetValue(sensor.EquipmentIndex, out var state) || state.Online)) + .Select(sensor => Math.Max(0, sensor.EvaluateStat( + 3, + equipmentStates.TryGetValue(sensor.EquipmentIndex, out var state) + ? state.ThermalPerformance + : 1))) + .Select(sensitivity => sensitivity * sensitivity) + .Sum(); + return sensitivitySquared > 0 + ? settings.EntitySensorRange * Math.Sqrt(sensitivitySquared) : settings.EntitySensorRange; } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs index 599c2d61d..63df148d4 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs @@ -141,7 +141,9 @@ namespace GameCult.Aetheria.State.Verse public double ProjectileSpawnOffset { get; } public double ProjectileHeatScale { get; } public double HeatDissipationPerSecond { get; } + [System.Obsolete("Sensor reach derives from installed equipment. Retained only for snapshot compatibility.")] public double StationSensorRange { get; } + public double FallbackSensorRange => EntitySensorRange; public double EntitySensorRange { get; } public double PlayerStationHull { get; } public double HostileStationHull { get; } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeInputCapabilityDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeInputCapabilityDocuments.cs index 61b75c884..724b3d063 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeInputCapabilityDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeInputCapabilityDocuments.cs @@ -59,7 +59,8 @@ namespace GameCult.Aetheria.State.Verse public static AetheriaRuntimeInputCapabilityDocument FromFrame( AetheriaRuntimeDaemonFrameDocument frame, - bool includeSimulationClock = false) + bool includeSimulationClock = false, + AetheriaRuntimeCatalogSnapshot? catalog = null) { var run = frame?.Run ?? new AetheriaRuntimeRunCheckpointCommit(); var entity = run.Zones.SelectMany(zone => zone.Entities).FirstOrDefault(candidate => @@ -68,6 +69,7 @@ namespace GameCult.Aetheria.State.Verse if (includeSimulationClock) { actions.Add(Action("simulation.pause", "Pause", "SetSimulationRate", "simulation", "terminus-clock", ("scalarValue", "0"))); + actions.Add(Action("simulation.step", "Advance One Step", "AdvanceSimulationStep", "simulation", "terminus-clock")); actions.Add(Action("simulation.rate.quarter", "Quarter Speed", "SetSimulationRate", "simulation", "terminus-clock", ("scalarValue", "0.25"))); actions.Add(Action("simulation.rate.half", "Half Speed", "SetSimulationRate", "simulation", "terminus-clock", ("scalarValue", "0.5"))); actions.Add(Action("simulation.rate.realtime", "Real Time", "SetSimulationRate", "simulation", "terminus-clock", ("scalarValue", "1"))); @@ -84,10 +86,72 @@ namespace GameCult.Aetheria.State.Verse actions.AddRange((entity.WeaponGroups ?? Array.Empty>()).Select((_, index) => Action($"weapon-group.{index}.fire", $"Fire Weapon Group {index + 1}", "FireWeaponGroup", "weapon-group", $"{run.CurrentEntityKey}#weapon-group/{index}", ("weaponGroup", index.ToString())))); actions.AddRange((entity.Equipment ?? Array.Empty()).Where(slot => slot?.Item != null).Select((slot, index) => Action($"equipment.{index}.activate", $"Activate {slot.Item.ItemKey}", "SetBehaviorActive", "equipment", $"{run.CurrentEntityKey}#equipment/{index}", ("equipmentIndex", index.ToString()), ("behaviorIndex", "0"), ("active", "true")))); actions.AddRange((entity.CargoContents ?? Array.Empty()).SelectMany(bay => bay.Items).Where(slot => slot?.Item != null).Select((slot, index) => Action($"cargo.{slot.Item.ItemKey}.{index}.use", $"Use {slot.Item.ItemKey}", "ActivateConsumable", "consumable", $"{run.CurrentEntityKey}#cargo/{index}", ("itemKey", slot.Item.ItemKey)))); + actions.AddRange(TradeActions(run, entity, catalog)); } return new AetheriaRuntimeInputCapabilityDocument { Version = frame?.FrameId ?? 0, Actions = actions.ToArray(), DefaultProfiles = BuildDefaultProfiles(actions) }; } + private static IEnumerable TradeActions( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeEntitySnapshotCommit entity, + AetheriaRuntimeCatalogSnapshot? catalog) + { + if (catalog == null) + yield break; + var zone = (run.Zones ?? Array.Empty()) + .FirstOrDefault(candidate => candidate != null && candidate.ZoneIndex == run.CurrentZoneIndex); + var parent = (zone?.Entities ?? Array.Empty()) + .FirstOrDefault(candidate => candidate != null && + (candidate.DockingBayAssignments ?? Array.Empty()).Contains(entity.EntityIndex)); + if (parent == null) + yield break; + + var stationKey = AetheriaRuntimeRunCheckpointCommit.EntityRecordKey( + run.RunId, + run.CurrentZoneIndex, + parent.EntityIndex); + var cargo = parent.CargoContents ?? Array.Empty(); + for (var bayIndex = 0; bayIndex < cargo.Count; bayIndex++) + { + foreach (var slot in cargo[bayIndex]?.Items ?? Array.Empty()) + { + var itemKey = slot?.Item?.ItemKey ?? ""; + var typedItem = catalog.FindItem(itemKey); + if (typedItem == null || slot?.Item == null || slot.Item.Quantity <= 0) + continue; + var value = AetheriaRuntimeDaemonTradeItemQueries.TradeItemValue( + typedItem, + slot.Item, + catalog.TradeValueSettings).Price; + var createsShip = !string.IsNullOrWhiteSpace(typedItem.HullType); + var canReceive = createsShip + ? (parent.DockingBayAssignments ?? Array.Empty()).Any(index => index < 0) + : AetheriaRuntimeCargoCapacityQueries.UnitsThatFit(entity, catalog, itemKey, 0) > 0; + var action = Action( + $"trade.buy.{StableToken(itemKey)}.{bayIndex}.{slot.X}.{slot.Y}", + $"Buy {typedItem.Name} ({value})", + "TradePurchase", + "trade", + $"{stationKey}#cargo/{bayIndex}/{slot.X}/{slot.Y}", + ("itemKey", itemKey), + ("quantity", "1"), + ("stationEntityKey", stationKey), + ("stationCargoIndex", bayIndex.ToString()), + ("targetEntityKey", run.CurrentEntityKey), + ("targetCargoIndex", "0"), + ("sourceX", slot.X.ToString()), + ("sourceY", slot.Y.ToString())); + action.Availability = value >= 0 && run.Credits >= value && canReceive + ? "available" + : "unavailable"; + yield return action; + } + } + } + + private static string StableToken(string value) => + new string((value ?? "").Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '-').ToArray()).Trim('-'); + private static IEnumerable CoreActions() { yield return Action("pilot.scoop", "Scoop", "SetTractorPower", "ship", "pilot", ("scalarValue", "1")); diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 5852871ac..a35fb411d 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -255,6 +255,13 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. equipped-item and active-consumable behavior instances through narrow behavior-owned restore methods; construction/loadout blueprints do not own behavior replay. +- Passive contact reach is daemon-owned and derived from enabled installed + `Sensor` behavior sensitivity. Arrays combine by root-sum-square, so larger + hulls can buy reach by devoting real equipment volume, mass, power, cooling, + and computation to sensing. Entity kind is not an input. Cargo is not + installed equipment. Contact refresh consumes this derived reach but does + not write `Visibility`; emitted signature remains owned by thermal, + propulsion, weapons, reflectors, explicit visibility sources, and pings. `EntityConstructionBlueprint` still exists for construction/loadout projection, but those live hull grids and session scalars are not blueprint fields and cannot be restored by the old blueprint projector path. diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 897489ec2..0bc74c85b 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -26,10 +26,10 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Thermal/energy | cell conduction, capacitor/reactor/radiator network, and cockpit thermal exposure | daemon | ship/equipment power, temperature, medical state, risk and death feedback | schematic/post effects | owner-exists; wired; exclusive; projected; generic timing and native asset delivery lowered: catalog hull and installed-item shapes derive cell mass/conductivity/topology; local temperature derives thermal performance, wear potential and online state; all consumers share capacitor-first/reactor-residual transactions; powered radiators pump before reactor settlement; reactor heat then enters the same cell network before conduction/radiation; cockpit-cell temperature drives the fossil nonlinear heatstroke/hypothermia accumulation, linear recovery, severe-risk crossings, and ordinary exactly-once destruction with a typed cause; Eve exposes cockpit, risk, heatstroke source-weight, phasing and death-transition facts; EveUnity generically resolves the original severe pulse plus live/reconnect-safe cause-specific death crossfade; Aetheria deterministically translates the five broken legacy profiles into clean URP profiles, advertises them by semantic role through CultMesh CDN, and binds them in the canonical client through a weight-only native sink; rotated equipment topology, parent/child radiation, schematic composition, legacy SSR/AO/roundness equivalents, canonical render-pipeline activation and rendered parity remain incomplete | multi-consumer ordering, multi-radiator visibility, rotated topology, generic schematic meters, renderer-feature parity, active URP capture and live/reconnect death-fade screenshots | | Weapons | ServerShared behaviors + Unity effects | daemon plus Ymir geometry/kinematics | action capability, weapon state, shot/damage receipts | effects/audio/HUD | instant and constant resource/timing lifecycles are daemon-owned; charged weapons accept one semantic request, spool without requiring a solution, hold at full charge, commit automatically when a solution appears, and perform deterministic once-per-second malfunction hazard checks after the authored safe hold interval with increasing persisted risk; generic Eve `weapon.state` exposes charge hold/risk and feedback exposes readiness, commit, refusal, and malfunction chronology; committed direct damage now enters the shared daemon armor-cell transaction | guided persistent payloads, visibility/wear, remaining impact-distribution parity, and Unity effect reconciliation | | Destruction/drops | hull death, 25% equipment rolls, unconditional cargo drops, 25-unit launch, 30-second pickups | daemon | destruction identity, death/drop events, typed pickups | provider-advertised destruction effect and pickups | owner-exists; wired; exclusive; projected: direct and deployable lethal damage commit one deterministic destruction transaction, clear live references, retain only a non-rendered identity tombstone, and emit exact pickups/events; the reserved client `DestroyEntity` command is rejected; Aetheria advertises its original big explosion as `effect.feedback.entity.destroyed`, lowered generically by EveUnity | equipment-destruction-before-hull parity and multiplayer reconnect chronology | -| Tractor/scoop | analog tractor and physical timed pickups | daemon + Ymir | tractor power, pickup body and receipt | pickup world entity plus beam/interaction feedback | fossil 2/s power ramp and authored forward radius/traction/distance feed pickup bodies into the shared Ymir world; shield contact collects exactly once when capacity permits or applies the fossil 25-unit rejection kick; 30s daemon lifetime; instant cargo teleport deleted; current-zone pickups now occupy ordinary rows in the shared Eve entity SoA body with stable logical pickup identity and the provider-owned pickup asset; the generic Unity witness sees a paused deterministic salvage pickup, flies through it, and observes daemon/Ymir removal | add generic tractor beam plus rejected-capacity feedback and cargo-delta assertion | -| Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | live generic Unity witness now proves advertised dock/undock actions, daemon bay assignment, dock-parent inclusion in the SoA view, station sensor-contact sharing, generic camera retargeting, controlled-subject hiding, movement suppression, and restoration on undock; the starter station and local raider form a deterministic Terminus departure proof | restore fossil component-prerequisite rejection reasons, exact undock placement, wormhole priority, reconnect, and docked inventory/trade surfaces | +| Tractor/scoop | analog tractor and physical timed pickups | daemon + Ymir | tractor power, pickup body and receipt | pickup world entity plus beam/interaction feedback | fossil 2/s power ramp and authored forward radius/traction/distance feed pickup bodies into the shared Ymir world; shield contact collects exactly once when capacity permits or applies the fossil 25-unit rejection kick; 30s daemon lifetime; instant cargo teleport deleted; current-zone pickups occupy ordinary rows in the shared Eve entity SoA body with stable logical identity and provider-owned assets; the generic Unity witness activates scoop while paused, advances fixed daemon steps, and observes Ymir contact remove the starter salvage | add generic tractor beam plus rejected-capacity feedback and explicit cargo-delta witness fact | +| Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | live generic Unity witness now proves advertised dock/undock actions, daemon bay assignment, dock-parent inclusion in the SoA view, sharing of the dock parent's equipment-derived contact picture, generic camera retargeting, controlled-subject hiding, movement suppression, and restoration on undock; the starter station and local raider form a deterministic Terminus departure proof | restore fossil component-prerequisite rejection reasons, exact undock placement, wormhole priority, reconnect, and docked inventory/trade surfaces | | Inventory/equipment | inventory panels + ServerShared | daemon | inventory/equipment surfaces | generic UI | owner-exists; wired/projected in fragments; not proven | all transfer directions and rejection | -| Trade | trade UI/economy | daemon | stock, quote, purchase commands | generic UI | purchase owner wired/projected; not proven | atomic purchase and capacity | +| Trade | trade UI/economy | daemon | stock, quote, purchase commands | generic UI | daemon derives station, stock row, catalog item, current price, quantity, credits, target capacity, and hull-vs-cargo result; Eve advertises only currently available stock actions; generic Unity live witness receives an authoritative purchase receipt while paused | sell flow, quote expiry, contested stock, and full trade surface parity | | Action bar/input | input shell and rebinding UI | daemon advertises actions; client owns bindings | input capability and action descriptors | universal binding UI | contracts wired; dynamic original behavior source absent | dynamic equipment/cargo actions | | Pilot camera | ARPG follow/dock cameras | Eve view semantics + runtime | camera rig and semantic view id | EveUnity camera rig | basic follow lowered; dock/view parity absent | authored framing and dock transition | | Render channels | Unity layers/culling masks | provider variant + runtime | semantic view id | native layer policy | specification work; incomplete uncommitted prototype | no map glyphs in pilot view | @@ -39,10 +39,10 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Hit/lock/warnings | `ActionGameManager` feedback | daemon event facts | transient feedback stream | VFX/UI/audio | bounded deduplicated game-event ledger and Eve `feedback.stream` wired for pickup outcomes, projectile launch/impact, entity damage, alive-to-dead destruction, authoritative weapon reload chronology, and episode-deduplicated `weapon.fire.refused` facts for insufficient energy or missing ammunition; refusal identity, authored weapon, target, reason, and remaining magazine state are runtime-independent cockpit inputs | add lock transitions, thermal and docking event families, negative transaction fixtures, and runtime effect consumption | | Assets | `Assets/Resources` | Aetheria provider | asset catalog + CultMesh CDN | runtime cache/load | wired; basic lowering; not live-proven remotely | cold/warm remote-only live load and cache proof | | Save/resume | original persistence paths | daemon/CultCache | published current state | clients rehydrate | owner-exists; not parity-proven | mid-loop restart | -| Terminus loop | original game composition | daemon/Ymir/Eve | pilot surface | Aetheria.Unity | partial live scenario proof: generic Unity discovers the provider, lowers the pilot world and assets, flies through Ymir, sees and collects daemon-authored salvage, pauses, queues targeting/fire, resumes, and presents the authoritative shot and receipts | destruction-created loot, dock, and trade remain before the minimum loop is proven | +| Terminus loop | original game composition | daemon/Ymir/Eve | pilot surface | Aetheria.Unity | live minimum-loop proof: generic Unity discovers the provider, lowers the pilot world/assets, single-steps dock and undock while paused, buys daemon-priced station stock, single-steps Ymir salvage collection, resumes, moves, targets, fires, and presents authoritative receipts/shot outcome | destruction-created loot pickup, richer trade content, docking transitions, and sustained combat encounters | | Starbridge | original/shared architecture intent | one daemon session | pilot + commander surfaces | Unity + Electron | commander task board and autonomous explore execution wired; Electron Eve lowering and split-target proof missing | commander orders plus four pilots and autonomous workers | | Behavior engine | `ServerShared/Behaviors` composition | daemon | executable action/status projection | action bar and feedback | tick reconciles equipped catalog payloads into persistent behavior state; shared evaluated-stat query proven; execution families remain partial | every behavior family, ordering, resource transaction and modifier lifecycle | -| Sensors/visibility | decaying sources and observer information thresholds | daemon | contacts and ping events | indicators/maps | reference | threshold, decay, ping and classification timelines | +| Sensors/visibility | installed sensor sensitivity, decaying signature sources, and observer information thresholds | daemon | contacts and ping events | indicators/maps | passive reach now derives from enabled installed Sensor behaviors with diminishing-return array composition regardless of entity kind; contact refresh no longer overwrites emitted visibility; the old station range survives only as obsolete snapshot compatibility | author station/ship sensor loadout budgets, then prove power/thermal shutdown, threshold decay, ping, and classification timelines | | AI/tasks | corporation task scheduler plus agent state machines | daemon | commander task board, corporation survey ledger, and shared semantic controls | Eve commander/pilot clients | point exploration, attack with optimum-range control, same-zone haul, mining/offload, resource surveying, physical station towing, persistent orbit patrol, route-aware assignment, and autonomous return home wired through shared commands; attack admission rejects missing/friendly targets and unavailable weapon groups before durable queueing; capability/priority assignment and rejection behavior proven; generic Electron commander lowering proves the worker roster | cross-zone logistics execution, cancellation, reconnect, and multi-client command transfer | | Consumables | cargo-backed timed behavior containers | daemon | dynamic actions and active effects | action bar/schematic | reference | consume, stacking, duration and cleanup | diff --git a/docs/original-game-simulation-spec.md b/docs/original-game-simulation-spec.md index 200c6df20..c80ec6fc6 100644 --- a/docs/original-game-simulation-spec.md +++ b/docs/original-game-simulation-spec.md @@ -103,11 +103,19 @@ consume another configured cargo item. ## Sensors And Visibility Visibility sources are independently identified and decayed. Passive sensors -accumulate observer-relative information from sensitivity, signature, and -range. Threshold transitions create/remove classified contacts. Ping consumes +accumulate observer-relative information from installed sensor sensitivity, +target signature, and distance. Passive reach is derived from enabled installed +sensor arrays; multiple arrays combine with diminishing returns. Stations have +no class bonus: their advantage comes from spending hull volume, mass, power, +cooling, and computation on sensor equipment ships cannot afford. Cargo-held +sensors contribute nothing. Threshold transitions create/remove classified contacts. Ping consumes energy, raises source visibility, expands in space, and grants information on crossing. Hostility uses faction ownership, security, and presence rules. +Sensor reach and emitted visibility are separate quantities. Contact refresh +must never overwrite the thermal, propulsion, weapon, reflector, and ping +sources that make an entity detectable. + ## AI Agents own root/current state, task, controls, acceleration, and transitions. diff --git a/docs/original-game-specification.md b/docs/original-game-specification.md index 5e8d6644d..2b23fe194 100644 --- a/docs/original-game-specification.md +++ b/docs/original-game-specification.md @@ -176,6 +176,13 @@ Visibility is simulated and observer-relative. Sensors accumulate information, contacts distinguish visible/known/hostile state, pings alter visibility, and target selection is constrained by available contacts and game rules. +Passive sensor capability belongs to installed equipment, not to the `station` +or `ship` entity kind. A station generally sees farther because its size and +mass budget supports more and larger arrays together with the required power, +cooling, and computation. An equivalently equipped ship and station have the +same capability. Docked craft may consume the station's contact picture, but +do not inherit a magical range value. + The pilot can select explicitly, cycle or choose nearest targets, and obtain a lock where required by equipment. The UI distinguishes target existence, visibility to target, target visibility, hostility, lock progress, shields, and diff --git a/docs/renderless-aetheria-architecture.md b/docs/renderless-aetheria-architecture.md index ed3a57e5d..27682a7b4 100644 --- a/docs/renderless-aetheria-architecture.md +++ b/docs/renderless-aetheria-architecture.md @@ -99,6 +99,12 @@ compression so a physically larger world does not require the player to watch uneventful coasting in real time. Supported requested rates currently range from pause through `128x`; Starbridge remains real-time-owned. +While paused, `AdvanceSimulationStep` advances exactly one fixed daemon step +without changing the persistent requested rate. Eve advertises this as +`simulation.step`, giving players, bots, and conformance fixtures a precise +tactical inspection primitive instead of briefly unpausing and racing the +publication cadence. + The requested simulation rate is not a renderer time scale. The daemon advances the deterministic fixed-step simulation repeatedly and publishes one coherent sample of the resulting state. Unity, Electron, bots, and other Eve clients may