Make docked refit atomic

This commit is contained in:
Meta Vens 2026-07-17 00:46:58 +01:00
commit d01a784d8f
15 changed files with 1088 additions and 127 deletions

View file

@ -168,6 +168,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
ProjectileContactCannotKill,
MissingWorldPhysicsOwnerCannotAdvanceShips,
DockingUsesRealBaysAndFossilUndockRules,
RefitIsAtomicAndUsesTypedPlacement,
TractorRampsAndPullsThroughYmirWithoutTeleportingCargo,
PickupIsCapacityCheckedExactlyOnceAndExpires,
TradePurchaseDerivesAcceptanceFromDaemonState,
@ -393,6 +394,290 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
"successful undocking must release the exact occupied bay");
}
private static void RefitIsAtomicAndUsesTypedPlacement()
{
static AetheriaRuntimeCatalogItem ShapedItem(
string key,
string category,
string hardpointType,
int width,
int height,
params (int X, int Y)[] cells)
{
var item = CatalogItem(key);
item.Category = category;
item.HardpointType = hardpointType;
item.ShapeWidth = width;
item.ShapeHeight = height;
item.OccupiedCells = cells.Length;
item.ShapeCells = cells.Select(cell => new AetheriaRuntimeShapeCell(cell.X, cell.Y)).ToArray();
return item;
}
var hull = HullCatalogItem("refit-hull", 4, 3, 1);
hull.Hardpoints =
[
new AetheriaRuntimeHardpoint(
"Weapon",
2,
0,
1,
2,
2,
[new AetheriaRuntimeShapeCell(0, 0), new AetheriaRuntimeShapeCell(0, 1)],
"",
"Clockwise",
0)
];
var weapon = ShapedItem(
"refit-weapon",
AetheriaRuntimeItemCategories.Weapon,
"Weapon",
2,
1,
(0, 0),
(1, 0));
var blocker = ShapedItem("refit-blocker", AetheriaRuntimeItemCategories.Gear, "", 1, 1, (0, 0));
var cargoBay = ShapedItem("refit-cargo-bay", AetheriaRuntimeItemCategories.CargoBay, "", 1, 1, (0, 0));
cargoBay.InteriorShapeWidth = 4;
cargoBay.InteriorShapeHeight = 3;
cargoBay.InteriorOccupiedCells = 12;
cargoBay.InteriorShapeCells = Enumerable.Range(0, 3)
.SelectMany(y => Enumerable.Range(0, 4).Select(x => new AetheriaRuntimeShapeCell(x, y)))
.ToArray();
var catalog = new AetheriaRuntimeCatalogSnapshot([hull, weapon, blocker, cargoBay], [], []);
var station = Entity(0, 0, "player");
station.Kind = "station";
station.CargoBays =
[
new AetheriaRuntimeLoadoutItemSlotCommit
{
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 }
}
];
station.CargoContents = [new AetheriaRuntimeCargoBayLoadoutCommit()];
station.DockingBays =
[
new AetheriaRuntimeLoadoutItemSlotCommit
{
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "station-refit-dock", Quantity = 1 }
}
];
station.DockingBayAssignments = [1];
station.DockingBayContents = [new AetheriaRuntimeCargoBayLoadoutCommit()];
station.ChildEntityIndices = [1];
var ship = Entity(1, 0, "player");
ship.HullItemKey = hull.ItemKey;
ship.Equipment =
[
new AetheriaRuntimeLoadoutItemSlotCommit
{
X = 0,
Y = 0,
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = blocker.ItemKey,
Quality = 0.8,
Durability = 0.7,
Quantity = 1,
Enabled = false,
OverrideShutdown = true,
Temperature = 321
}
}
];
ship.CargoBays =
[
new AetheriaRuntimeLoadoutItemSlotCommit
{
X = 3,
Y = 2,
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 }
}
];
ship.CargoContents =
[
new AetheriaRuntimeCargoBayLoadoutCommit
{
Items =
[
new AetheriaRuntimeLoadoutItemSlotCommit
{
X = 0,
Y = 0,
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = weapon.ItemKey,
Quality = 0.9,
Durability = 0.6,
Quantity = 1,
Enabled = true,
Temperature = 444
}
},
new AetheriaRuntimeLoadoutItemSlotCommit
{
X = 1,
Y = 0,
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = blocker.ItemKey, Quantity = 1 }
},
new AetheriaRuntimeLoadoutItemSlotCommit
{
X = 2,
Y = 0,
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 }
}
]
}
];
var run = new AetheriaRuntimeRunCheckpointCommit
{
RunId = "refit-smoke",
CurrentZoneIndex = 0,
CurrentEntityKey = "zone.0.entity.1",
Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [station, ship] }]
};
var context = new AetheriaRuntimeDaemonOperationContext { Catalog = catalog };
AetheriaRuntimeDaemonCommandDocument Equip(
string id,
string sourceKind,
int sourceIndex,
string itemKey,
int sourceX,
int sourceY,
int destinationX,
int destinationY)
{
var command = AetheriaRuntimeDaemonCommandDocument.Create(
AetheriaRuntimeDaemonCommandKinds.EquipItem,
"pilot",
run.RunId,
1,
run.CurrentEntityKey);
command.CommandId = id;
command.TextValue = itemKey;
command.EquipmentTransfer = new AetheriaRuntimeEquipmentTransferCommand
{
SourceKind = sourceKind,
OriginEntityKey = run.CurrentEntityKey,
OriginIndex = sourceIndex,
SourceX = sourceX,
SourceY = sourceY,
DestinationEntityKey = run.CurrentEntityKey,
DestinationX = destinationX,
DestinationY = destinationY,
HasDestinationPosition = true
};
return command;
}
var equipWeapon = AetheriaRuntimeDaemonOperations.Execute(
run,
[Equip("refit-weapon", AetheriaRuntimeRefitSourceKinds.Cargo, 0, weapon.ItemKey, 0, 0, 2, 0)],
context);
Require(equipWeapon.AppliedCommandIds.Contains("refit-weapon"),
"a docked cargo weapon must equip through the daemon transaction");
var equippedWeapon = ship.Equipment.Single(slot => slot.Item.ItemKey == weapon.ItemKey);
RequireEqual("Clockwise", equippedWeapon.Rotation,
"hardpoint equipment must inherit the authored hardpoint rotation");
RequireNear(0.9, equippedWeapon.Item.Quality, 0.000001,
"refit must preserve the exact item instance quality");
RequireNear(444, equippedWeapon.Item.Temperature, 0.000001,
"refit must preserve the exact item instance temperature");
var rejectedSource = ship.CargoContents[0].Items.Single(slot => slot.Item.ItemKey == blocker.ItemKey);
var rejectedBefore = (
Count: ship.CargoContents[0].Items.Count,
X: rejectedSource.X,
Y: rejectedSource.Y,
Quality: rejectedSource.Item.Quality,
Durability: rejectedSource.Item.Durability,
Temperature: rejectedSource.Item.Temperature);
var overlap = AetheriaRuntimeDaemonOperations.Execute(
run,
[Equip("refit-overlap", AetheriaRuntimeRefitSourceKinds.Cargo, 0, blocker.ItemKey, 1, 0, 0, 0)],
context);
RequireEqual(AetheriaRuntimeDaemonRejectionReasons.RefitNoFit,
overlap.RejectedCommandReasons["refit-overlap"],
"occupied interior cells must reject with the daemon-owned fit reason");
var rejectedAfter = ship.CargoContents[0].Items.Single(slot => slot.Item.ItemKey == blocker.ItemKey);
RequireEqual(rejectedBefore.Count, ship.CargoContents[0].Items.Count,
"rejected refit must not remove the source item");
Require(rejectedBefore.X == rejectedAfter.X && rejectedBefore.Y == rejectedAfter.Y &&
rejectedBefore.Quality == rejectedAfter.Item.Quality &&
rejectedBefore.Durability == rejectedAfter.Item.Durability &&
rejectedBefore.Temperature == rejectedAfter.Item.Temperature,
"rejected refit must preserve every source instance field");
var equipBay = AetheriaRuntimeDaemonOperations.Execute(
run,
[Equip("refit-bay", AetheriaRuntimeRefitSourceKinds.Cargo, 0, cargoBay.ItemKey, 2, 0, 1, 2)],
context);
Require(equipBay.AppliedCommandIds.Contains("refit-bay") && ship.CargoBays.Count == 2 && ship.CargoContents.Count == 2,
"catalog cargo-bay category must route into aligned typed bay and contents collections");
ship.CargoContents[1].Items =
[
new AetheriaRuntimeLoadoutItemSlotCommit
{
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = blocker.ItemKey, Quantity = 1 }
}
];
var storeBay = AetheriaRuntimeDaemonCommandDocument.Create(
AetheriaRuntimeDaemonCommandKinds.StoreItem,
"pilot",
run.RunId,
1,
run.CurrentEntityKey);
storeBay.CommandId = "refit-nonempty-bay";
storeBay.TextValue = cargoBay.ItemKey;
storeBay.StoreItem = new AetheriaRuntimeStoreItemCommand
{
SourceKind = AetheriaRuntimeRefitSourceKinds.CargoBay,
OriginEntityKey = run.CurrentEntityKey,
SourceEquipmentIndex = 1,
DestinationEntityKey = "zone.0.entity.0",
DestinationCargoIndex = 0,
DestinationX = 0,
DestinationY = 0,
HasDestinationPosition = true
};
var nonempty = AetheriaRuntimeDaemonOperations.Execute(run, [storeBay], context);
RequireEqual(AetheriaRuntimeDaemonRejectionReasons.RefitBayNotEmpty,
nonempty.RejectedCommandReasons[storeBay.CommandId],
"a nonempty cargo bay must reject unequip before any mutation");
RequireEqual(2, ship.CargoBays.Count,
"nonempty bay rejection must preserve the installed bay");
var frame = AetheriaRuntimeDaemonFrameDocument.Create(run, "daemon", "session", 8, 0.16, 0.02);
frame.RejectedCommandIds = nonempty.RejectedCommandIds;
frame.RejectedCommandReasons = nonempty.RejectedCommandReasons;
var fact = AetheriaRuntimeCommittedCommandFactDocument.FromRejectedCommand(frame, storeBay, "aetheria.local");
var receipt = AetheriaRuntimeDaemonReceiptProjector.Project(
fact,
AetheriaRuntimeDaemonGameSurfaceBuilder.SurfaceId);
RequireEqual(AetheriaRuntimeDaemonRejectionReasons.RefitBayNotEmpty, fact.RejectionReason,
"refit rejection reason must survive the committed command fact");
Require(receipt.State == "denied" &&
receipt.Message.Contains(AetheriaRuntimeDaemonRejectionReasons.RefitBayNotEmpty, StringComparison.Ordinal),
"generic Eve receipt must expose the exact refit rejection reason");
station.DockingBayAssignments = [-1];
station.ChildEntityIndices = [];
var deployed = AetheriaRuntimeDaemonOperations.Execute(
run,
[Equip("refit-deployed", AetheriaRuntimeRefitSourceKinds.Equipment, 0, blocker.ItemKey, int.MinValue, int.MinValue, 1, 1)],
context);
RequireEqual(AetheriaRuntimeDaemonRejectionReasons.RefitRequiresDocked,
deployed.RejectedCommandReasons["refit-deployed"],
"deployed ships must not refit even when the requested placement would fit");
Require(ship.Equipment.Any(slot => slot.Item.ItemKey == blocker.ItemKey),
"deployed refit rejection must preserve installed equipment");
}
private static void VolumeSurfaceKeepsNativeShaderAbiInAssetVariant()
{
var player = Entity(0, 0, "player");
@ -1854,9 +2139,9 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
entity.HullItemKey = hull.ItemKey;
entity.Equipment =
[
new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = gear.ItemKey } },
new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey } }
new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = gear.ItemKey } }
];
entity.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey } }];
entity.CargoContents = [new AetheriaRuntimeCargoBayLoadoutCommit { Items = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = ore.ItemKey, Quantity = 4 } }] }];
RequireNear(20, AetheriaRuntimeCargoCapacityQueries.Capacity(entity, catalog), 0.000001,
@ -1874,13 +2159,10 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
miner.AgentTaskCapabilities = [AetheriaRuntimeAgentTaskTypes.Mine];
miner.Equipment =
[
new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "mining-tool", Enabled = true, Quality = 1, Durability = 1 } },
new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "miner-cargo", Enabled = true, Quality = 1, Durability = 1 } }
];
home.Equipment =
[
new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "home-cargo", Enabled = true, Quality = 1, Durability = 1 } }
new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "mining-tool", Enabled = true, Quality = 1, Durability = 1 } }
];
miner.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "miner-cargo", Enabled = true, Quality = 1, Durability = 1 } }];
home.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "home-cargo", Enabled = true, Quality = 1, Durability = 1 } }];
var minerHull = CatalogItem("miner-hull");
minerHull.HullCapacity = PerformanceStat(3);
var homeHull = CatalogItem("home-hull");
@ -2111,7 +2393,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
first.Equipment.All(value => value.ItemKey != "cheap-turret-controller"),
"ship control hardpoints must require the cockpit role even when the wrong controller is cheaper");
Require(first.Equipment.Any(value => value.ItemKey == "cannon") &&
first.Equipment.Any(value => value.ItemKey == "cargo-bay") &&
first.CargoBays.Any(value => value.ItemKey == "cargo-bay") &&
first.Equipment.Any(value => value.ItemKey == "capacitor") &&
first.Equipment.Any(value => value.ItemKey == "ship-sensor") &&
first.Equipment.All(value => value.ItemKey != "station-sensor"),
@ -2130,8 +2412,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
"generated loadouts must carry daemon-authored source and selection provenance");
Require(rareSelections > 0 && rareSelections < 200,
$"rare catalog candidates must remain possible without becoming a default loadout path (selected {rareSelections}/2000)");
Require(station.Equipment.Any(value => value.ItemKey == "docking-bay") &&
station.Equipment.Any(value => value.ItemKey == "cargo-bay") &&
Require(station.DockingBays.Any(value => value.ItemKey == "docking-bay") &&
station.CargoBays.Any(value => value.ItemKey == "cargo-bay") &&
station.Equipment.Any(value => value.ItemKey == "capacitor") &&
station.Equipment.Any(value => value.ItemKey == "cheap-turret-controller") &&
station.Equipment.Any(value => value.ItemKey == "station-sensor") &&
@ -2154,6 +2436,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
private static IEnumerable<string> LoadoutKeys(AetheriaDaemonLoadout loadout) =>
new[] { loadout.HullItemKey }
.Concat(loadout.Equipment.Select(value => value.ItemKey))
.Concat(loadout.CargoBays.Select(value => value.ItemKey))
.Concat(loadout.DockingBays.Select(value => value.ItemKey))
.Concat(loadout.Cargo.Select(value => value.Item.ItemKey));
private static AetheriaRuntimeBehaviorValue PerformanceStat(double value) => new(
@ -3794,6 +4078,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
X = 0, Y = 0, Item = new AetheriaRuntimeLoadoutItemCommit
{ ItemKey = "thermal-gear", Quality = 1, Durability = 1, Enabled = true }
}];
entity.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit
{
X = 1, Y = 0, Item = new AetheriaRuntimeLoadoutItemCommit
{ ItemKey = "thermal-cargo-bay", Quality = 1, Durability = 1, Enabled = true }
}];
var hull = HullCatalogItem("thermal-hull", 2, 1, 0);
hull.Mass = 10;
hull.SpecificHeat = 2;
@ -3804,7 +4093,13 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
gear.Mass = 4;
gear.SpecificHeat = 5;
gear.Conductivity = 3;
var catalog = new AetheriaRuntimeCatalogSnapshot([hull, gear], [], []);
var cargoBay = CatalogItem("thermal-cargo-bay");
cargoBay.Category = AetheriaRuntimeItemCategories.CargoBay;
cargoBay.ShapeCells = [new AetheriaRuntimeShapeCell(0, 0)];
cargoBay.Mass = 6;
cargoBay.SpecificHeat = 2;
cargoBay.Conductivity = 2;
var catalog = new AetheriaRuntimeCatalogSnapshot([hull, gear, cargoBay], [], []);
AetheriaRuntimeThermalSimulation.EnsureTopology(entity, catalog);
@ -3812,8 +4107,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
var conductivity = entity.StatGrids.Single(value => value.Name == "conductivity");
RequireNear(30, mass.Values[0], 0.000001,
"occupied cell thermal mass must include its proportional equipment mass");
RequireNear(10, mass.Values[1], 0.000001,
"hull thermal mass must be divided across authored hull cells");
RequireNear(22, mass.Values[1], 0.000001,
"typed cargo bays must contribute thermal mass at their authored hull cells");
RequireNear(3, conductivity.Values[0], 0.000001,
"occupied cell conductivity must come from installed equipment");
RequireNear(280, entity.StatGrids.Single(value => value.Name == "temperature").Values[1], 0.000001,
@ -4270,7 +4565,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
var cargoBay = CatalogItem("pickup-cargo-bay"); cargoBay.InteriorOccupiedCells = 1;
var catalog = new AetheriaRuntimeCatalogSnapshot([hull, salvage, cargoBay], [], []);
var ship = Entity(0, 0, "player"); ship.HullItemKey = hull.ItemKey; ship.CargoContents = [Cargo()];
ship.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 } }];
ship.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 } }];
var zone = new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [ship], DroppedPickups = [new AetheriaRuntimeDroppedPickupCommit { PickupIndex = 7, PositionX = 10, Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = salvage.ItemKey, Quantity = 1 }, LifetimeSeconds = 30 }] };
var run = new AetheriaRuntimeRunCheckpointCommit { CurrentZoneIndex = 0, CurrentEntityKey = "zone.0.entity.0", Zones = [zone] };
var forbiddenCommand = AetheriaRuntimeDaemonCommandDocument.Create(AetheriaRuntimeDaemonCommandKinds.PickUpLoot, "pilot", "pickup-smoke", 0, "zone.0.entity.0");
@ -4358,7 +4653,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
station.DockingBayAssignments = [1];
station.ChildEntityIndices = [1];
var ship = Entity(1, 0, "player");
ship.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit
ship.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit
{
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 }
}];
@ -4423,7 +4718,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
var station = Entity(0, 0, "player");
station.Kind = "station";
station.HullItemKey = hull.ItemKey;
station.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit
station.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit
{
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 }
}];
@ -4478,7 +4773,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
AetheriaRuntimeRunCheckpointCommit Scenario(bool full)
{
var ship = Entity(0, 0, "player"); ship.HullItemKey = hull.ItemKey;
ship.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 } }];
ship.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit { Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 } }];
ship.CargoContents = [full ? Cargo((salvage.ItemKey, 1, 0, 0)) : Cargo()];
return new AetheriaRuntimeRunCheckpointCommit { CurrentZoneIndex = 0, CurrentEntityKey = "zone.0.entity.0", Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [ship], DroppedPickups = [new AetheriaRuntimeDroppedPickupCommit { PickupIndex = 10, PositionX = 20, Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = salvage.ItemKey, Quantity = 1 }, LifetimeSeconds = 30 }] }] };
}
@ -4634,7 +4929,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
var cargoBay = CatalogItem("restart-contact-cargo-bay"); cargoBay.InteriorOccupiedCells = 1;
var catalog = new AetheriaRuntimeCatalogSnapshot([hull, salvage, cargoBay], [], []);
var ship = Entity(0, 0, "player"); ship.HullItemKey = hull.ItemKey;
ship.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit
ship.CargoBays = [new AetheriaRuntimeLoadoutItemSlotCommit
{
Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = cargoBay.ItemKey, Quantity = 1 }
}];

View file

@ -83,8 +83,25 @@ public sealed class AetheriaDaemonLoadoutGenerator
var cargo = PackCargo(cargoBay, availabilityFactionKey,
string.Equals(entityKind, "station", StringComparison.OrdinalIgnoreCase));
var equipment = slots
.Where(value => !IsBay(_catalog.FindItem(value.ItemKey)))
.ToArray();
var cargoBays = slots
.Where(value => string.Equals(
_catalog.FindItem(value.ItemKey)?.Category,
AetheriaRuntimeItemCategories.CargoBay,
StringComparison.Ordinal))
.ToArray();
var dockingBays = slots
.Where(value => string.Equals(
_catalog.FindItem(value.ItemKey)?.Category,
AetheriaRuntimeItemCategories.DockingBay,
StringComparison.Ordinal))
.ToArray();
var selectedKeys = new[] { (Role: "hull", Key: hull.ItemKey) }
.Concat(slots.Select(value => (Role: "equipment", Key: value.ItemKey)))
.Concat(equipment.Select(value => (Role: "equipment", Key: value.ItemKey)))
.Concat(cargoBays.Select(value => (Role: "cargo-bay", Key: value.ItemKey)))
.Concat(dockingBays.Select(value => (Role: "docking-bay", Key: value.ItemKey)))
.Concat(cargo.Select(value => (Role: "cargo", Key: value.Item.ItemKey)));
var receipt = new AetheriaLoadoutGenerationReceipt
{
@ -94,7 +111,6 @@ public sealed class AetheriaDaemonLoadoutGenerator
PriceExponent = _priceExponent,
Selections = selectedKeys.Select(value => Selection(value.Role, value.Key, availabilityFactionKey)).ToArray()
};
var equipment = slots.ToArray();
var defaultWeaponGroup = equipment
.Select((slot, index) => (slot, index))
.Where(value => IsWeapon(_catalog.FindItem(value.slot.ItemKey)))
@ -103,7 +119,14 @@ public sealed class AetheriaDaemonLoadoutGenerator
var weaponGroups = defaultWeaponGroup.Length == 0
? Array.Empty<int[]>()
: new[] { defaultWeaponGroup };
return new AetheriaDaemonLoadout(hull.ItemKey, equipment, cargo, weaponGroups, receipt);
return new AetheriaDaemonLoadout(
hull.ItemKey,
equipment,
cargoBays,
dockingBays,
cargo,
weaponGroups,
receipt);
}
private AetheriaLoadoutGenerationSelection Selection(string role, string itemKey, string factionKey)
@ -348,34 +371,13 @@ public sealed class AetheriaDaemonLoadoutGenerator
(cells ?? Array.Empty<AetheriaRuntimeShapeCell>()).Select(value => (value.X, value.Y)).ToHashSet();
private static IEnumerable<(int X, int Y)> RotatedCells(AetheriaRuntimeCatalogItem item, int rotation)
{
foreach (var cell in item.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>())
{
yield return rotation switch
{
1 => (item.ShapeHeight - 1 - cell.Y, cell.X),
2 => (item.ShapeWidth - 1 - cell.X, item.ShapeHeight - 1 - cell.Y),
3 => (cell.Y, item.ShapeWidth - 1 - cell.X),
_ => (cell.X, cell.Y)
};
}
}
=> AetheriaRuntimeEquipmentGridGeometry.RotatedCells(item, rotation);
private static int ParseRotation(string value) => value switch
{
"Clockwise" or "Right" or "Rotate90" => 1,
"Half" or "Rotate180" => 2,
"CounterClockwise" or "Left" or "Rotate270" => 3,
_ => 0
};
private static int ParseRotation(string value) =>
AetheriaRuntimeEquipmentGridGeometry.ParseRotation(value);
private static string RotationName(int rotation) => (((rotation % 4) + 4) % 4) switch
{
1 => "Clockwise",
2 => "Half",
3 => "CounterClockwise",
_ => "None"
};
private static string RotationName(int rotation) =>
AetheriaRuntimeEquipmentGridGeometry.RotationName(rotation);
private static bool HasBehavior(AetheriaRuntimeCatalogItem item, string kind) =>
(item.BehaviorKinds ?? Array.Empty<string>()).Contains(kind, StringComparer.Ordinal);
@ -390,6 +392,11 @@ public sealed class AetheriaDaemonLoadoutGenerator
(item.BehaviorKinds ?? Array.Empty<string>()).Any(kind =>
kind.Contains("Weapon", StringComparison.Ordinal)));
private static bool IsBay(AetheriaRuntimeCatalogItem? item) =>
item != null &&
(string.Equals(item.Category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal) ||
string.Equals(item.Category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal));
private static AetheriaEntityItemSlot Slot(int x, int y, string itemKey, int rotation = 0) => new()
{
Position = new AetheriaGridCoord { X = x, Y = y }, ItemKey = itemKey,
@ -411,6 +418,8 @@ public sealed class AetheriaDaemonLoadoutGenerator
public sealed record AetheriaDaemonLoadout(
string HullItemKey,
AetheriaEntityItemSlot[] Equipment,
AetheriaEntityItemSlot[] CargoBays,
AetheriaEntityItemSlot[] DockingBays,
AetheriaLoadoutItemSlot[] Cargo,
int[][] WeaponGroups,
AetheriaLoadoutGenerationReceipt Receipt);

View file

@ -420,16 +420,23 @@ internal static class AetheriaDaemonZoneGenerator
StatGrid("heat", 0)
],
Equipment = loadout.Equipment,
CargoBays = loadout.CargoBays,
DockingBays = loadout.DockingBays,
WeaponGroups = loadout.WeaponGroups
.Select(indices => new AetheriaWeaponGroupSnapshot { EquipmentIndices = indices })
.ToArray(),
CargoContents =
[
new AetheriaCargoBayLoadout
CargoContents = loadout.CargoBays
.Select((_, index) => new AetheriaCargoBayLoadout
{
Items = loadout.Cargo
}
]
Items = index == 0 ? loadout.Cargo : []
})
.ToArray(),
DockingBayContents = loadout.DockingBays
.Select(_ => new AetheriaCargoBayLoadout())
.ToArray(),
DockingBayAssignments = loadout.DockingBays
.Select(_ => -1)
.ToArray()
};
}

View file

@ -215,7 +215,8 @@ namespace GameCult.Aetheria.State.Verse
string itemKey,
int destinationX,
int destinationY,
bool hasDestinationPosition) =>
bool hasDestinationPosition,
string sourceKind = AetheriaRuntimeRefitSourceKinds.Equipment) =>
_operations.StoreItem(
originEntityKey,
sourceEquipmentIndex,
@ -224,7 +225,8 @@ namespace GameCult.Aetheria.State.Verse
itemKey,
destinationX,
destinationY,
hasDestinationPosition);
hasDestinationPosition,
sourceKind);
public bool TrySubmitSurfaceCommand(
EveSurfaceCommandRequest request,

View file

@ -33,10 +33,17 @@ namespace GameCult.Aetheria.State.Verse
return 0;
var equipment = (entity.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Sum(slot => Volume(slot?.Item, catalog));
var cargoBays = (entity.CargoBays ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Sum(slot => Volume(slot?.Item, catalog));
var dockingBays = (entity.DockingBays ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Sum(slot => Volume(slot?.Item, catalog));
var cargo = (entity.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>())
.SelectMany(bay => bay?.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Sum(slot => Volume(slot?.Item, catalog));
return equipment + cargo;
var dockingCargo = (entity.DockingBayContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>())
.SelectMany(bay => bay?.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Sum(slot => Volume(slot?.Item, catalog));
return equipment + cargoBays + dockingBays + cargo + dockingCargo;
}
public static double Available(AetheriaRuntimeEntitySnapshotCommit? entity, AetheriaRuntimeCatalogSnapshot? catalog) =>
@ -56,9 +63,8 @@ namespace GameCult.Aetheria.State.Verse
var volume = catalog?.FindItem(itemKey ?? "")?.Volume ?? 0;
if (volume <= 0)
return int.MaxValue;
var cargoBays = (entity?.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
var cargoBays = (entity?.CargoBays ?? 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;

View file

@ -1025,5 +1025,8 @@ namespace GameCult.Aetheria.State.Verse
[Key(6)]
public bool HasDestinationPosition { get; set; }
[Key(7)]
public string SourceKind { get; set; } = AetheriaRuntimeRefitSourceKinds.Equipment;
}
}

View file

@ -532,7 +532,8 @@ namespace GameCult.Aetheria.State.Verse
string itemKey,
int destinationX,
int destinationY,
bool hasDestinationPosition)
bool hasDestinationPosition,
string sourceKind = AetheriaRuntimeRefitSourceKinds.Equipment)
{
var command = Create(AetheriaRuntimeDaemonCommandKinds.StoreItem, frame);
command.TargetEntityKey = destinationEntityKey ?? "";
@ -547,6 +548,7 @@ namespace GameCult.Aetheria.State.Verse
command.StoreItem.DestinationX = destinationX;
command.StoreItem.DestinationY = destinationY;
command.StoreItem.HasDestinationPosition = hasDestinationPosition;
command.StoreItem.SourceKind = sourceKind ?? AetheriaRuntimeRefitSourceKinds.Equipment;
return Send(command);
}

View file

@ -71,6 +71,14 @@ namespace GameCult.Aetheria.State.Verse
public const string MissingPropulsion = "missing-propulsion";
public const string MissingReactor = "missing-reactor";
public const string DockingBayCargoNotEmpty = "docking-bay-cargo-not-empty";
public const string RefitRequiresDocked = "refit-requires-docked";
public const string RefitAccessDenied = "refit-access-denied";
public const string InvalidRefitSource = "invalid-refit-source";
public const string InvalidRefitItem = "invalid-refit-item";
public const string RefitItemMustBeSingle = "refit-item-must-be-single";
public const string RefitBayNotEmpty = "refit-bay-not-empty";
public const string RefitNoFit = "refit-no-fit";
public const string InvalidCargoDestination = "invalid-cargo-destination";
}
public static class AetheriaRuntimeDaemonOperations
@ -182,9 +190,9 @@ namespace GameCult.Aetheria.State.Verse
case AetheriaRuntimeDaemonCommandKinds.TransferCargoItem:
return ApplyTransferCargoItem(run, command, context.Catalog);
case AetheriaRuntimeDaemonCommandKinds.EquipItem:
return ApplyEquipItem(run, command);
return ApplyEquipItem(run, command, context);
case AetheriaRuntimeDaemonCommandKinds.StoreItem:
return ApplyStoreItem(run, command);
return ApplyStoreItem(run, command, context);
case AetheriaRuntimeDaemonCommandKinds.ToggleHullConductivity:
return ApplyToggleHullConductivity(run, command);
case AetheriaRuntimeDaemonCommandKinds.TradePurchase:
@ -850,73 +858,77 @@ namespace GameCult.Aetheria.State.Verse
private static bool ApplyEquipItem(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeDaemonCommandDocument command)
AetheriaRuntimeDaemonCommandDocument command,
AetheriaRuntimeDaemonOperationContext context)
{
var transfer = command.EquipmentTransfer ?? new AetheriaRuntimeEquipmentTransferCommand();
var sourceKind = transfer.SourceKind ?? "";
var originEntityKey = transfer.OriginEntityKey ?? "";
var originIndex = transfer.OriginIndex;
var sourceX = transfer.SourceX;
var sourceY = transfer.SourceY;
var originKey = transfer.OriginEntityKey ?? "";
var destinationKey = transfer.DestinationEntityKey ?? "";
if (!TryResolveEntity(run, originKey, out _, out _, out var origin) ||
!TryResolveEntity(run, destinationKey, out _, out _, out var destination))
return context.Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource);
if (!TryResolveDockParent(run, destinationKey, out _, out var parent))
return context.Reject(AetheriaRuntimeDaemonRejectionReasons.RefitRequiresDocked);
AetheriaRuntimeLoadoutItemSlotCommit slot;
if (string.Equals(sourceKind, "equipment", StringComparison.Ordinal))
var sourceKind = string.IsNullOrWhiteSpace(transfer.SourceKind)
? AetheriaRuntimeRefitSourceKinds.Cargo
: transfer.SourceKind;
var sourceIsCargo = string.Equals(sourceKind, AetheriaRuntimeRefitSourceKinds.Cargo, StringComparison.Ordinal);
if ((!sourceIsCargo && !ReferenceEquals(origin, destination)) ||
(sourceIsCargo &&
!ReferenceEquals(origin, destination) &&
!ReferenceEquals(origin, parent)))
{
if (!TryRemoveEquipmentItem(run, originEntityKey, originIndex, command.TextValue, out slot))
return false;
}
else
{
if (!TryResolveCargoBay(run, originEntityKey, originIndex, out var originEntity, out var originCargoIndex, out _) ||
!TryRemoveCargoItem(originEntity, originCargoIndex, command.TextValue, sourceX, sourceY, out slot))
{
return false;
}
return context.Reject(AetheriaRuntimeDaemonRejectionReasons.RefitAccessDenied);
}
if (!TryResolveEntity(run, transfer.DestinationEntityKey, out _, out _, out var destinationEntity))
return false;
slot.X = transfer.HasDestinationPosition
? transfer.DestinationX
: slot.X;
slot.Y = transfer.HasDestinationPosition
? transfer.DestinationY
: slot.Y;
AddEquipmentItem(destinationEntity, slot);
return true;
return AetheriaRuntimeRefitTransactions.TryEquip(
origin,
sourceKind,
transfer.OriginIndex,
transfer.SourceX,
transfer.SourceY,
destination,
command.TextValue ?? "",
transfer.DestinationX,
transfer.DestinationY,
transfer.HasDestinationPosition,
context.Catalog,
out var reason)
|| context.Reject(reason);
}
private static bool ApplyStoreItem(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeDaemonCommandDocument command)
AetheriaRuntimeDaemonCommandDocument command,
AetheriaRuntimeDaemonOperationContext context)
{
var store = command.StoreItem ?? new AetheriaRuntimeStoreItemCommand();
if (!TryRemoveEquipmentItem(
run,
store.OriginEntityKey,
store.SourceEquipmentIndex,
command.TextValue,
out var slot) ||
!TryResolveCargoBay(
run,
store.DestinationEntityKey,
store.DestinationCargoIndex,
out var destinationEntity,
out var destinationCargoIndex,
out _))
{
return false;
}
var originKey = store.OriginEntityKey ?? "";
var destinationKey = store.DestinationEntityKey ?? "";
if (!TryResolveEntity(run, originKey, out _, out _, out var origin) ||
!TryResolveEntity(run, destinationKey, out _, out _, out var destination))
return context.Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource);
if (!TryResolveDockParent(run, originKey, out _, out var parent))
return context.Reject(AetheriaRuntimeDaemonRejectionReasons.RefitRequiresDocked);
if (!ReferenceEquals(destination, origin) && !ReferenceEquals(destination, parent))
return context.Reject(AetheriaRuntimeDaemonRejectionReasons.RefitAccessDenied);
slot.X = store.HasDestinationPosition
? store.DestinationX
: slot.X;
slot.Y = store.HasDestinationPosition
? store.DestinationY
: slot.Y;
AddCargoItem(destinationEntity, destinationCargoIndex, slot);
return true;
return AetheriaRuntimeRefitTransactions.TryStore(
origin,
string.IsNullOrWhiteSpace(store.SourceKind)
? AetheriaRuntimeRefitSourceKinds.Equipment
: store.SourceKind,
store.SourceEquipmentIndex,
destination,
store.DestinationCargoIndex,
command.TextValue ?? "",
store.DestinationX,
store.DestinationY,
store.HasDestinationPosition,
context.Catalog,
out var reason)
|| context.Reject(reason);
}
private static bool ApplyToggleHullConductivity(

View file

@ -353,7 +353,8 @@ public sealed class AetheriaRuntimeDaemonOperationsClient
string itemKey,
int destinationX,
int destinationY,
bool hasDestinationPosition)
bool hasDestinationPosition,
string sourceKind = AetheriaRuntimeRefitSourceKinds.Equipment)
{
return Send((client, frame) => client.StoreItem(
frame,
@ -364,7 +365,8 @@ public sealed class AetheriaRuntimeDaemonOperationsClient
itemKey,
destinationX,
destinationY,
hasDestinationPosition));
hasDestinationPosition,
sourceKind));
}
public bool TrySubmitSurfaceCommand(

View file

@ -33,9 +33,7 @@ namespace GameCult.Aetheria.State.Verse
if (pickup.AgeSeconds >= pickup.LifetimeSeconds ||
pickup.Item == null)
return AetheriaRuntimePickupContactResult.Ignored;
var cargoBayCount = (entity.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Select(slot => catalog?.FindItem(slot?.Item?.ItemKey ?? ""))
.Count(item => item != null && item.InteriorOccupiedCells > 0);
var cargoBayCount = entity.CargoBays?.Count ?? 0;
var cargoIndex = Enumerable.Range(0, cargoBayCount)
.Where(index => quantity <= AetheriaRuntimeCargoCapacityQueries.UnitsThatFit(
entity, catalog, pickup.Item.ItemKey ?? "", index))

View file

@ -0,0 +1,586 @@
using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace GameCult.Aetheria.State.Verse
{
public static class AetheriaRuntimeRefitSourceKinds
{
public const string Cargo = "cargo";
public const string Equipment = "equipment";
public const string CargoBay = "cargo-bay";
public const string DockingBay = "docking-bay";
}
public static class AetheriaRuntimeEquipmentGridGeometry
{
public static IReadOnlyList<(int X, int Y)> RotatedCells(
AetheriaRuntimeCatalogItem item,
int rotation)
{
if (item == null)
return Array.Empty<(int X, int Y)>();
return (item.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>())
.Select(cell => rotation switch
{
1 => (item.ShapeHeight - 1 - cell.Y, cell.X),
2 => (item.ShapeWidth - 1 - cell.X, item.ShapeHeight - 1 - cell.Y),
3 => (cell.Y, item.ShapeWidth - 1 - cell.X),
_ => (cell.X, cell.Y)
})
.ToArray();
}
public static int ParseRotation(string? value)
{
if (string.Equals(value, "Clockwise", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "Right", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "Rotate90", StringComparison.OrdinalIgnoreCase)) return 1;
if (string.Equals(value, "Half", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "Reversed", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "Rotate180", StringComparison.OrdinalIgnoreCase)) return 2;
if (string.Equals(value, "CounterClockwise", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "Rotate270", StringComparison.OrdinalIgnoreCase)) return 3;
return 0;
}
public static string RotationName(int rotation) => (((rotation % 4) + 4) % 4) switch
{
1 => "Clockwise",
2 => "Half",
3 => "CounterClockwise",
_ => "None"
};
}
public static class AetheriaRuntimeRefitTransactions
{
public static bool TryEquip(
AetheriaRuntimeEntitySnapshotCommit origin,
string sourceKind,
int sourceIndex,
int sourceX,
int sourceY,
AetheriaRuntimeEntitySnapshotCommit destination,
string itemKey,
int destinationX,
int destinationY,
bool hasDestinationPosition,
AetheriaRuntimeCatalogSnapshot? catalog,
out string rejectionReason)
{
rejectionReason = "";
if (origin == null || destination == null || catalog == null)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
var originPlan = new EntityPlan(origin);
var destinationPlan = ReferenceEquals(origin, destination)
? originPlan
: new EntityPlan(destination);
if (!originPlan.TryRemove(
sourceKind,
sourceIndex,
sourceX,
sourceY,
itemKey,
out var source,
out rejectionReason))
{
return false;
}
var item = catalog.FindItem(source.Slot.Item?.ItemKey ?? "");
if (item == null || source.Slot.Item == null)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitItem, out rejectionReason);
if (source.Slot.Item.Quantity != 1)
return Reject(AetheriaRuntimeDaemonRejectionReasons.RefitItemMustBeSingle, out rejectionReason);
if (!TryFindEquipmentPlacement(
destination,
destinationPlan,
item,
source.Slot,
destinationX,
destinationY,
hasDestinationPosition,
catalog,
out var placed,
out rejectionReason))
{
return false;
}
destinationPlan.AddInstalled(placed, item.Category, source.PreservedWeaponGroups);
originPlan.Apply();
if (!ReferenceEquals(originPlan, destinationPlan))
destinationPlan.Apply();
return true;
}
public static bool TryStore(
AetheriaRuntimeEntitySnapshotCommit origin,
string sourceKind,
int sourceIndex,
AetheriaRuntimeEntitySnapshotCommit destination,
int destinationCargoIndex,
string itemKey,
int destinationX,
int destinationY,
bool hasDestinationPosition,
AetheriaRuntimeCatalogSnapshot? catalog,
out string rejectionReason)
{
rejectionReason = "";
if (origin == null || destination == null || catalog == null)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
var originPlan = new EntityPlan(origin);
var destinationPlan = ReferenceEquals(origin, destination)
? originPlan
: new EntityPlan(destination);
if (!originPlan.TryRemove(
sourceKind,
sourceIndex,
int.MinValue,
int.MinValue,
itemKey,
out var source,
out rejectionReason))
{
return false;
}
if (ReferenceEquals(originPlan, destinationPlan) &&
string.Equals(sourceKind, AetheriaRuntimeRefitSourceKinds.CargoBay, StringComparison.Ordinal) &&
sourceIndex < destinationCargoIndex)
{
destinationCargoIndex--;
}
var item = catalog.FindItem(source.Slot.Item?.ItemKey ?? "");
if (item == null || source.Slot.Item == null)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitItem, out rejectionReason);
if (destinationCargoIndex < 0 || destinationCargoIndex >= destinationPlan.CargoBays.Count)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidCargoDestination, out rejectionReason);
var bayItem = catalog.FindItem(destinationPlan.CargoBays[destinationCargoIndex].Item?.ItemKey ?? "");
if (bayItem == null || destinationCargoIndex >= destinationPlan.CargoContents.Count)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidCargoDestination, out rejectionReason);
var occupied = OccupiedCargoCells(destinationPlan.CargoContents[destinationCargoIndex], catalog);
var rotation = ParseRotation(source.Slot.Rotation);
if (!TryFindPlacement(
Cells(bayItem.InteriorShapeCells),
Math.Max(0, bayItem.InteriorShapeWidth),
Math.Max(0, bayItem.InteriorShapeHeight),
item,
rotation,
occupied,
destinationX,
destinationY,
hasDestinationPosition,
out var x,
out var y))
{
return Reject(AetheriaRuntimeDaemonRejectionReasons.RefitNoFit, out rejectionReason);
}
var placed = CloneSlot(source.Slot);
placed.X = x;
placed.Y = y;
destinationPlan.CargoContents[destinationCargoIndex].Add(placed);
originPlan.Apply();
if (!ReferenceEquals(originPlan, destinationPlan))
destinationPlan.Apply();
return true;
}
private static bool TryFindEquipmentPlacement(
AetheriaRuntimeEntitySnapshotCommit destination,
EntityPlan plan,
AetheriaRuntimeCatalogItem item,
AetheriaRuntimeLoadoutItemSlotCommit source,
int requestedX,
int requestedY,
bool hasRequestedPosition,
AetheriaRuntimeCatalogSnapshot catalog,
out AetheriaRuntimeLoadoutItemSlotCommit placed,
out string rejectionReason)
{
placed = null!;
rejectionReason = "";
var hull = catalog.FindItem(destination.HullItemKey ?? "");
if (hull == null || Cells(item.ShapeCells).Count == 0)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitItem, out rejectionReason);
var occupied = OccupiedEquipmentCells(plan, catalog);
var hardpointItem = !string.IsNullOrWhiteSpace(item.HardpointType);
int x;
int y;
int rotation;
if (hardpointItem)
{
var candidates = (hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
.Where(hardpoint => hardpoint != null &&
string.Equals(hardpoint.Type, item.HardpointType, StringComparison.Ordinal))
.Where(hardpoint => !hasRequestedPosition ||
(hardpoint.PositionX == requestedX && hardpoint.PositionY == requestedY));
var fit = candidates
.Select(hardpoint => new
{
Hardpoint = hardpoint,
Rotation = ParseRotation(hardpoint.Rotation),
Target = Cells(hardpoint.ShapeCells)
})
.FirstOrDefault(candidate => RotatedCells(item, candidate.Rotation)
.All(cell => candidate.Target.Contains(cell) &&
!occupied.Contains((candidate.Hardpoint.PositionX + cell.X, candidate.Hardpoint.PositionY + cell.Y))));
if (fit == null)
return Reject(AetheriaRuntimeDaemonRejectionReasons.RefitNoFit, out rejectionReason);
x = fit.Hardpoint.PositionX;
y = fit.Hardpoint.PositionY;
rotation = fit.Rotation;
}
else
{
var hardpointCells = (hull.Hardpoints ?? Array.Empty<AetheriaRuntimeHardpoint>())
.Where(hardpoint => hardpoint != null)
.SelectMany(hardpoint => (hardpoint.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>())
.Select(cell => (hardpoint.PositionX + cell.X, hardpoint.PositionY + cell.Y)))
.ToHashSet();
occupied.UnionWith(hardpointCells);
rotation = ParseRotation(source.Rotation);
if (!TryFindPlacement(
Cells(hull.InteriorShapeCells),
Math.Max(0, hull.InteriorShapeWidth),
Math.Max(0, hull.InteriorShapeHeight),
item,
rotation,
occupied,
requestedX,
requestedY,
hasRequestedPosition,
out x,
out y))
{
return Reject(AetheriaRuntimeDaemonRejectionReasons.RefitNoFit, out rejectionReason);
}
}
placed = CloneSlot(source);
placed.X = x;
placed.Y = y;
placed.Rotation = RotationName(rotation);
return true;
}
private static bool TryFindPlacement(
HashSet<(int X, int Y)> target,
int width,
int height,
AetheriaRuntimeCatalogItem item,
int rotation,
HashSet<(int X, int Y)> occupied,
int requestedX,
int requestedY,
bool hasRequestedPosition,
out int x,
out int y)
{
x = 0;
y = 0;
var cells = RotatedCells(item, rotation).ToArray();
if (cells.Length == 0 || target.Count == 0)
return false;
bool Fits(int candidateX, int candidateY) =>
cells.All(cell => target.Contains((candidateX + cell.X, candidateY + cell.Y)) &&
!occupied.Contains((candidateX + cell.X, candidateY + cell.Y)));
if (hasRequestedPosition)
{
if (!Fits(requestedX, requestedY))
return false;
x = requestedX;
y = requestedY;
return true;
}
for (var candidateY = 0; candidateY < height; candidateY++)
for (var candidateX = 0; candidateX < width; candidateX++)
{
if (!Fits(candidateX, candidateY))
continue;
x = candidateX;
y = candidateY;
return true;
}
return false;
}
private static HashSet<(int X, int Y)> OccupiedEquipmentCells(
EntityPlan plan,
AetheriaRuntimeCatalogSnapshot catalog) =>
plan.Equipment.Concat(plan.CargoBays).Concat(plan.DockingBays)
.Where(slot => slot?.Item != null)
.SelectMany(slot =>
{
var item = catalog.FindItem(slot.Item.ItemKey ?? "");
return item == null
? Array.Empty<(int X, int Y)>()
: RotatedCells(item, ParseRotation(slot.Rotation))
.Select(cell => (slot.X + cell.X, slot.Y + cell.Y));
})
.ToHashSet();
private static HashSet<(int X, int Y)> OccupiedCargoCells(
IEnumerable<AetheriaRuntimeLoadoutItemSlotCommit> slots,
AetheriaRuntimeCatalogSnapshot catalog) =>
(slots ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Where(slot => slot?.Item != null)
.SelectMany(slot =>
{
var item = catalog.FindItem(slot.Item.ItemKey ?? "");
return item == null
? Array.Empty<(int X, int Y)>()
: RotatedCells(item, ParseRotation(slot.Rotation))
.Select(cell => (slot.X + cell.X, slot.Y + cell.Y));
})
.ToHashSet();
private static HashSet<(int X, int Y)> Cells(IReadOnlyList<AetheriaRuntimeShapeCell>? cells) =>
(cells ?? Array.Empty<AetheriaRuntimeShapeCell>())
.Select(cell => (cell.X, cell.Y))
.ToHashSet();
private static IEnumerable<(int X, int Y)> RotatedCells(AetheriaRuntimeCatalogItem item, int rotation)
=> AetheriaRuntimeEquipmentGridGeometry.RotatedCells(item, rotation);
private static int ParseRotation(string? value) =>
AetheriaRuntimeEquipmentGridGeometry.ParseRotation(value);
private static string RotationName(int rotation) =>
AetheriaRuntimeEquipmentGridGeometry.RotationName(rotation);
private static AetheriaRuntimeLoadoutItemSlotCommit CloneSlot(AetheriaRuntimeLoadoutItemSlotCommit slot)
{
var item = slot.Item;
return new AetheriaRuntimeLoadoutItemSlotCommit
{
X = slot.X,
Y = slot.Y,
Rotation = slot.Rotation ?? "None",
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = item?.ItemKey ?? "",
Quality = item?.Quality ?? 1,
Durability = item?.Durability ?? 1,
Quantity = item?.Quantity ?? 1,
Enabled = item?.Enabled ?? true,
OverrideShutdown = item?.OverrideShutdown ?? false,
Temperature = item?.Temperature ?? 0
}
};
}
private static bool Reject(string reason, out string rejectionReason)
{
rejectionReason = reason;
return false;
}
private sealed class SourceSelection
{
public AetheriaRuntimeLoadoutItemSlotCommit Slot { get; set; } = null!;
public IReadOnlyList<int> PreservedWeaponGroups { get; set; } = Array.Empty<int>();
}
private sealed class EntityPlan
{
private readonly AetheriaRuntimeEntitySnapshotCommit _entity;
private readonly List<List<int>> _weaponGroups;
public EntityPlan(AetheriaRuntimeEntitySnapshotCommit entity)
{
_entity = entity;
Equipment = (entity.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()).ToList();
CargoBays = (entity.CargoBays ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()).ToList();
DockingBays = (entity.DockingBays ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()).ToList();
CargoContents = CloneContents(entity.CargoContents, CargoBays.Count);
DockingContents = CloneContents(entity.DockingBayContents, DockingBays.Count);
DockingAssignments = (entity.DockingBayAssignments ?? Array.Empty<int>()).ToList();
while (DockingAssignments.Count < DockingBays.Count) DockingAssignments.Add(-1);
_weaponGroups = (entity.WeaponGroups ?? Array.Empty<IReadOnlyList<int>>())
.Select(group => (group ?? Array.Empty<int>()).ToList())
.ToList();
}
public List<AetheriaRuntimeLoadoutItemSlotCommit> Equipment { get; }
public List<AetheriaRuntimeLoadoutItemSlotCommit> CargoBays { get; }
public List<AetheriaRuntimeLoadoutItemSlotCommit> DockingBays { get; }
public List<List<AetheriaRuntimeLoadoutItemSlotCommit>> CargoContents { get; }
public List<List<AetheriaRuntimeLoadoutItemSlotCommit>> DockingContents { get; }
public List<int> DockingAssignments { get; }
public bool TryRemove(
string sourceKind,
int sourceIndex,
int sourceX,
int sourceY,
string itemKey,
out SourceSelection source,
out string rejectionReason)
{
source = null!;
rejectionReason = "";
if (string.Equals(sourceKind, AetheriaRuntimeRefitSourceKinds.Cargo, StringComparison.Ordinal))
{
if (sourceIndex < 0 || sourceIndex >= CargoContents.Count)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
var itemIndex = CargoContents[sourceIndex].FindIndex(slot => Matches(slot, itemKey, sourceX, sourceY));
if (itemIndex < 0)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
source = new SourceSelection { Slot = CloneSlot(CargoContents[sourceIndex][itemIndex]) };
CargoContents[sourceIndex].RemoveAt(itemIndex);
return true;
}
if (string.Equals(sourceKind, AetheriaRuntimeRefitSourceKinds.Equipment, StringComparison.Ordinal))
{
if (!TryTake(Equipment, sourceIndex, itemKey, out var slot))
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
var preservedGroups = _weaponGroups
.Select((group, index) => (group, index))
.Where(value => value.group.Contains(sourceIndex))
.Select(value => value.index)
.ToArray();
foreach (var group in _weaponGroups)
{
group.RemoveAll(index => index == sourceIndex);
for (var index = 0; index < group.Count; index++)
if (group[index] > sourceIndex) group[index]--;
}
source = new SourceSelection { Slot = CloneSlot(slot), PreservedWeaponGroups = preservedGroups };
return true;
}
if (string.Equals(sourceKind, AetheriaRuntimeRefitSourceKinds.CargoBay, StringComparison.Ordinal))
{
if (sourceIndex < 0 || sourceIndex >= CargoBays.Count || sourceIndex >= CargoContents.Count)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
if (CargoContents[sourceIndex].Count > 0)
return Reject(AetheriaRuntimeDaemonRejectionReasons.RefitBayNotEmpty, out rejectionReason);
if (!TryTake(CargoBays, sourceIndex, itemKey, out var slot))
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
CargoContents.RemoveAt(sourceIndex);
source = new SourceSelection { Slot = CloneSlot(slot) };
return true;
}
if (string.Equals(sourceKind, AetheriaRuntimeRefitSourceKinds.DockingBay, StringComparison.Ordinal))
{
if (sourceIndex < 0 || sourceIndex >= DockingBays.Count)
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
if ((sourceIndex < DockingContents.Count && DockingContents[sourceIndex].Count > 0) ||
(sourceIndex < DockingAssignments.Count && DockingAssignments[sourceIndex] >= 0))
{
return Reject(AetheriaRuntimeDaemonRejectionReasons.RefitBayNotEmpty, out rejectionReason);
}
if (!TryTake(DockingBays, sourceIndex, itemKey, out var slot))
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
if (sourceIndex < DockingContents.Count) DockingContents.RemoveAt(sourceIndex);
if (sourceIndex < DockingAssignments.Count) DockingAssignments.RemoveAt(sourceIndex);
source = new SourceSelection { Slot = CloneSlot(slot) };
return true;
}
return Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidRefitSource, out rejectionReason);
}
public void AddInstalled(
AetheriaRuntimeLoadoutItemSlotCommit slot,
string? category,
IReadOnlyList<int> preservedWeaponGroups)
{
if (string.Equals(category, AetheriaRuntimeItemCategories.CargoBay, StringComparison.Ordinal))
{
CargoBays.Add(slot);
CargoContents.Add(new List<AetheriaRuntimeLoadoutItemSlotCommit>());
return;
}
if (string.Equals(category, AetheriaRuntimeItemCategories.DockingBay, StringComparison.Ordinal))
{
DockingBays.Add(slot);
DockingContents.Add(new List<AetheriaRuntimeLoadoutItemSlotCommit>());
DockingAssignments.Add(-1);
return;
}
var equipmentIndex = Equipment.Count;
Equipment.Add(slot);
foreach (var groupIndex in preservedWeaponGroups)
if (groupIndex >= 0 && groupIndex < _weaponGroups.Count)
_weaponGroups[groupIndex].Add(equipmentIndex);
}
public void Apply()
{
_entity.Equipment = Equipment.ToArray();
_entity.CargoBays = CargoBays.ToArray();
_entity.DockingBays = DockingBays.ToArray();
_entity.CargoContents = CargoContents
.Select(items => new AetheriaRuntimeCargoBayLoadoutCommit { Items = items.ToArray() })
.ToArray();
_entity.DockingBayContents = DockingContents
.Select(items => new AetheriaRuntimeCargoBayLoadoutCommit { Items = items.ToArray() })
.ToArray();
_entity.DockingBayAssignments = DockingAssignments.ToArray();
_entity.WeaponGroups = _weaponGroups
.Select(group => (IReadOnlyList<int>)group.Distinct().OrderBy(index => index).ToArray())
.ToArray();
_entity.WeaponStates = Array.Empty<AetheriaRuntimeWeaponStateCommit>();
_entity.BehaviorStates = (_entity.BehaviorStates ?? Array.Empty<AetheriaRuntimeBehaviorStateCommit>())
.Where(state => state != null &&
!string.Equals(state.OwnerKind, AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, StringComparison.Ordinal))
.ToArray();
}
private static List<List<AetheriaRuntimeLoadoutItemSlotCommit>> CloneContents(
IReadOnlyList<AetheriaRuntimeCargoBayLoadoutCommit>? contents,
int count)
{
var result = (contents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>())
.Select(bay => (bay?.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()).ToList())
.ToList();
while (result.Count < count) result.Add(new List<AetheriaRuntimeLoadoutItemSlotCommit>());
return result;
}
private static bool TryTake(
List<AetheriaRuntimeLoadoutItemSlotCommit> slots,
int index,
string itemKey,
out AetheriaRuntimeLoadoutItemSlotCommit slot)
{
slot = null!;
if (index < 0 || index >= slots.Count || !Matches(slots[index], itemKey, int.MinValue, int.MinValue))
return false;
slot = slots[index];
slots.RemoveAt(index);
return true;
}
private static bool Matches(
AetheriaRuntimeLoadoutItemSlotCommit? slot,
string itemKey,
int x,
int y) =>
slot?.Item != null &&
string.Equals(slot.Item.ItemKey ?? "", itemKey ?? "", StringComparison.Ordinal) &&
(x == int.MinValue || slot.X == x) &&
(y == int.MinValue || slot.Y == y);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71bfaee0999d4660b89325e6434aada1

View file

@ -77,12 +77,19 @@ namespace GameCult.Aetheria.State.Verse
.Select(index => occupied.Contains(index) ? baseMass : 0).ToArray();
var conductivity = Enumerable.Range(0, mass.Length)
.Select(index => occupied.Contains(index) ? 1.0 : 0).ToArray();
var equipment = entity.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
for (var equipmentIndex = 0; equipmentIndex < equipment.Count; equipmentIndex++)
var installed = (entity.Equipment ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Concat(entity.CargoBays ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.Concat(entity.DockingBays ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.ToArray();
for (var equipmentIndex = 0; equipmentIndex < installed.Length; equipmentIndex++)
{
var slot = equipment[equipmentIndex];
var slot = installed[equipmentIndex];
var item = catalog.FindItem(slot?.Item?.ItemKey ?? "");
var cells = item?.ShapeCells ?? Array.Empty<AetheriaRuntimeShapeCell>();
var cells = item == null
? Array.Empty<(int X, int Y)>()
: AetheriaRuntimeEquipmentGridGeometry.RotatedCells(
item,
AetheriaRuntimeEquipmentGridGeometry.ParseRotation(slot?.Rotation));
var itemCellCount = Math.Max(1, cells.Count);
foreach (var cell in cells)
{

View file

@ -2889,6 +2889,36 @@ Docking ownership follows the fossil's entity and `ActionGameManager` path:
contact collection; the live witness remains responsible for camera/effect
timing.
Docked refit ownership follows the fossil's `Entity.ItemFits`, `TryEquip`, and
`TryUnequip` transaction:
- Owner: one daemon refit transaction owns source selection, destination fit,
category routing, and the atomic equipment/cargo mutation.
- Inputs: authoritative docking parentage, typed hull interior and hardpoint
geometry, typed item footprint/category/hardpoint type, current ordinary
equipment, cargo bays, docking bays and their contents, the exact source item
instance, and the requested destination cell.
- Outputs: either one committed move preserving the item instance fields and
updating the category-owned collection, or one exact rejection reason that
reaches the command fact and Eve receipt.
- Derived state: drag previews, valid-cell highlights, filters, sounds, and
animations are presentation. They may predict the daemon decision but cannot
commit it.
- Forbidden writers: Unity occupancy checks, renderer inventory graphs,
client-supplied acceptance/rejection reasons, and remove-first mutation paths
cannot decide or repair a refit.
- Shared paths: cargo-to-equipment equip, equipment reposition, equipment-to-
cargo store, reconnect, and loadout restore use the same typed collection and
placement semantics where their contracts overlap.
- Cut line: generated cargo and docking bays no longer masquerade as ordinary
equipment; unconditional destination appends and source removal before
destination validation are removed from refit operations.
- Verification layer: daemon smokes compare source and destination state before
and after rejection, cover hardpoint type/shape/rotation, interior occupancy,
deployed-state rejection, nonempty bay rejection, category routing, exact
receipts, and aligned typed bay collections. Reconnect remains a live proof
obligation.
Ymir restart ownership is deliberately private and asymmetric:
- Owner: `AetheriaYmirPersistenceCoordinator` owns reconstruction durability;

View file

@ -29,7 +29,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity.
| 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; ship contact collects exactly once when capacity permits or applies the fossil 25-unit rejection kick; Aetheria maps pickup collision only against ship bodies, so stations and other world bodies cannot become cargo writers; 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; `pilot.scoop` advertises a generic `button-hold.v1` scalar contract, the released client sends `1` on press and `0` on release through `SetTractorPower`, both receipts reconcile, and daemon power returns exactly to zero; the provider build removes the fossil's embedded tractor object from ship prefabs, and `beam.presentation` attaches the sole standalone effect to the SoA ship; released warm witnesses prove successful exactly-once Ymir collection and controlled-player `cargo-capacity` rejection with unchanged cargo | polish provider beam shape/attachment |
| Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | daemon docking now preserves the fossil transaction: stable zone order selects the first entity with a real empty bay inside the fixed 25-unit XZ center radius; undock checks installed Cockpit, Thruster-or-AetherDrive, Reactor, then empty bay cargo in source order; rejection reasons survive committed command facts into generic Eve receipts; success removes parentage without moving, stopping, or rotating the ship. Ymir world entities retain gravity/query bodies but do not invent ship/station rigidbody collision response that would make the 25-unit rule unreachable; ship/pickup Begin contacts remain enabled. The live generic Unity witness also proves advertised actions, bay assignment, dock-parent SoA inclusion and sensor sharing, camera retargeting, controlled-subject hiding, movement suppression, and restoration on undock | reconnect, docked inventory/trade surfaces, and authored dock-camera blend/effect chronology |
| Wormhole travel | `ActionGameManager.EnterWormhole` plus `Ship` enter/exit state | daemon fixed-step transition plus Ymir exclusion | current-entity transition state and feedback chronology | provider-selected generic transition lowering | command acceptance no longer moves an entity immediately; player interact, explicit travel, and agent routing derive the same canonical adjacent-zone wormhole and proximity gate, then persist four-second `entering`, authoritative transfer, four-second `exiting`, and one-frame `completed` phases. The daemon owns smootherstep XZ motion, the fossil 1000-unit visual-depth lever, deterministic 20-unit exit direction, 50-unit exit radius, target-adjacency discovery, and exactly-once start/transfer/completion events. Flight and every world-physics implementation receive no transitioning body; Ymir resumes on the tick after completion. Smoke serializes and rehydrates mid-entry and proves agents traverse multi-edge routes through the same command boundary | generic provider transition effect/camera lowering and live multi-zone pilot witness |
| Inventory/equipment | inventory panels + ServerShared | daemon | inventory/equipment surfaces | generic UI | owner-exists; wired/projected in fragments; not proven | all transfer directions and rejection |
| Inventory/equipment | `Entity.ItemFits`, `TryEquip`, `TryUnequip`, inventory panels + ServerShared | daemon atomic refit transaction | typed inventory/equipment state, commands, exact receipts | generic UI | generated cargo and docking bays now occupy their typed collections instead of masquerading as ordinary equipment; docked equip/reposition/store validates the complete destination on cloned state before committing either side, uses authored hull interior and hardpoint geometry, forces hardpoint rotation, rejects occupied cells and deployed ships, preserves exact item instance fields and weapon-group membership, routes bay categories with aligned content/assignment arrays, and refuses to unequip nonempty/occupied bays; exact reasons reach generic Eve receipts | cargo-to-cargo spatial placement, live generic refit controls, reconnect after accepted and rejected moves, and full fossil inventory chronology |
| 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; cargo actions are catalog-filtered to consumables the current actor can actually activate, including suppression of active non-stackable items | dynamic equipment behavior validity and runtime action-bar quantity/fill proof |
| Pilot camera | ARPG follow/dock cameras | Eve view semantics + runtime | camera rig and semantic view id | EveUnity camera rig | daemon dock state selects distinct contracts instead of collapsing both fossil cameras: undocked flight advertises the entity-forward `Third Person Rig` framing (distance 30, screen 0.64/0.19, FOV 60, lens 1-4096) and the shared `aim.presentation` convergence point as its look-at; Aetheria translates the fossil Cinemachine top-origin screen Y of `0.81` to Eve's bottom-origin viewport Y of `0.19`; EveUnity solves native pose while preserving both optical aim and advertised entity framing, without provider types; the retained dock projection remains separate | reproduce the dock camera's separate follow/look-at subjects, blend timeline, noise, and reconnect state |