Complete daemon-owned Terminus service loop

This commit is contained in:
Meta Vens 2026-07-13 14:36:43 +01:00
commit 6c2bcd6afb
18 changed files with 559 additions and 53 deletions

View file

@ -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<AetheriaEntityItemSlot> slots,
double sizeExponent,
Func<AetheriaRuntimeCatalogItem, bool> 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,

View file

@ -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<AetheriaLoadoutItemSlot>()
}
];
entities[2].AgentTaskCapabilities = ["attack", "defend", "explore"];
entities[3].AgentTaskCapabilities = ["attack", "defend"];
entities[4].AgentTaskCapabilities = ["mine", "haul", "tow", "explore"];

View file

@ -199,13 +199,15 @@ static async Task<AetheriaRuntimeDaemonTickResult> 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<AetheriaRuntimeDaemonTickResult> 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<AetheriaGameSessionState>(AetheriaStateNode.GameSessionStateKey)
.ReadAsync().ConfigureAwait(false);
var inputCatalog = AetheriaRuntimeCatalogStore.OpenReadOnly(node.StatePath);
await node.MutableDocument<EveInputCapabilityDocument>(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<AetheriaMainMenuState>(AetheriaStateNode.MainMenuStateKey)
.ReadAsync()
@ -2687,7 +2696,7 @@ static async Task<AetheriaRuntimeRunCheckpointCommit?> ReadRuntimeRunCheckpointA
.ToArray(),
GenerationSeed = run.GenerationSeed,
CurrentEntityKey = run.CurrentEntityKey ?? "",
Credits = 1000
Credits = 1000000
};
}

View file

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

View file

@ -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<AetheriaRuntimeBehaviorMapEntry>());
var catalog = new AetheriaRuntimeCatalogSnapshot(
new[]
{
CatalogItem("sensor", new[]
{
new AetheriaRuntimeBehaviorPayload(
0, "Sensor", 1,
new[] { new AetheriaRuntimeBehaviorField(3, unitStat) })
})
},
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>());
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<AetheriaRuntimeBehaviorPayload>(), price: 40) },
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>())
});
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<AetheriaRuntimeBehaviorPayload>(), price: 40) },
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>())
});
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<AetheriaRuntimeBehaviorPayload>(), price: 300, hullType: "ship") },
Array.Empty<AetheriaRuntimeCorporation>(),
Array.Empty<AetheriaRuntimeNameFile>())
});
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<AetheriaRuntimeBehaviorPayload> behaviorPayloads)
IReadOnlyList<AetheriaRuntimeBehaviorPayload> 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<AetheriaRuntimeHardpoint>(),
behaviorPayloads,
"",
"",
hullType,
behaviorPayloads.Select(payload => payload.Kind).ToArray(),
1,
false,

View file

@ -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<AetheriaRuntimeLoadoutItemSlotCommit>())
.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<AetheriaRuntimeCargoBayLoadoutCommit>())
.ElementAtOrDefault(cargoIndex);
var occupied = (bay?.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Sum(slot => Volume(slot?.Item, catalog!));
return Math.Max(0, (int)Math.Floor((capacity - occupied) / volume));
}
private static double Volume(AetheriaRuntimeLoadoutItemCommit? item, AetheriaRuntimeCatalogSnapshot catalog) =>

View file

@ -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")]

View file

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

View file

@ -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<AetheriaRuntimeZoneSnapshotCommit>())
.FirstOrDefault(candidate => candidate != null && candidate.ZoneIndex == zoneIndex);
parent = (zone?.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
.FirstOrDefault(entity => entity != null &&
(entity.DockingBayAssignments ?? Array.Empty<int>()).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<int>()).Contains(entityIndex);
}
private static bool HasAvailableDockingBay(AetheriaRuntimeEntitySnapshotCommit parent) =>
(parent.DockingBayAssignments ?? Array.Empty<int>()).Any(index => index < 0);
private static bool TryFindCargoItem(
AetheriaRuntimeCargoBayLoadoutCommit cargo,
string itemKey,
int x,
int y,
out AetheriaRuntimeLoadoutItemSlotCommit slot)
{
slot = (cargo?.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.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<AetheriaRuntimeCargoBayLoadoutCommit>();
IEnumerable<int> 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,

View file

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

View file

@ -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<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeDaemonSimulationSettings settings)
AetheriaRuntimeDaemonSimulationSettings settings,
AetheriaRuntimeCatalogSnapshot? catalog)
{
foreach (var observer in entities)
{
var sensorRange = ResolveSensorRange(observer, settings, catalog);
var contacts = new List<AetheriaRuntimeEntityContactCommit>();
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<AetheriaRuntimeEquipmentStateCommit>())
.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;
}

View file

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

View file

@ -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<IReadOnlyList<int>>()).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<AetheriaRuntimeLoadoutItemSlotCommit>()).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<AetheriaRuntimeCargoBayLoadoutCommit>()).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<AetheriaRuntimeInputActionDocument> TradeActions(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeEntitySnapshotCommit entity,
AetheriaRuntimeCatalogSnapshot? catalog)
{
if (catalog == null)
yield break;
var zone = (run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
.FirstOrDefault(candidate => candidate != null && candidate.ZoneIndex == run.CurrentZoneIndex);
var parent = (zone?.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
.FirstOrDefault(candidate => candidate != null &&
(candidate.DockingBayAssignments ?? Array.Empty<int>()).Contains(entity.EntityIndex));
if (parent == null)
yield break;
var stationKey = AetheriaRuntimeRunCheckpointCommit.EntityRecordKey(
run.RunId,
run.CurrentZoneIndex,
parent.EntityIndex);
var cargo = parent.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
for (var bayIndex = 0; bayIndex < cargo.Count; bayIndex++)
{
foreach (var slot in cargo[bayIndex]?.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
{
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<int>()).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<AetheriaRuntimeInputActionDocument> CoreActions()
{
yield return Action("pilot.scoop", "Scoop", "SetTractorPower", "ship", "pilot", ("scalarValue", "1"));

View file

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

View file

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

View file

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

View file

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

View file

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