diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index fc4964d6f..d9942f396 100644 Binary files a/.voidbot/state/aetheria.cc and b/.voidbot/state/aetheria.cc differ diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 9f837560f..57b604e0c 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -20,6 +20,7 @@ RequireInventoryEntityRenameCommitAuthority(root); RequireWeaponGroupCommitAuthority(root); RequireInventoryDoubleClickTransferCommitAuthority(root); RequireLootPickupCommitAuthority(root); +RequireEntityDestroyedCommitAuthority(root); RequireTradePurchaseCommitAuthority(root); RequireInventoryLoadoutRestoreCommitAuthority(root); RequireDockedCurrentShipCommitAuthority(root); @@ -471,6 +472,8 @@ Console.WriteLine("Hull conductivity authority: inventory UI toggles flow throug Console.WriteLine("Inventory entity rename authority: UI rename flows through gameplay checkpoint commits"); Console.WriteLine("Weapon group authority: UI assignment flows through gameplay checkpoint commits"); Console.WriteLine("Inventory transfer authority: UI transfer and drag/drop requests flow through gameplay checkpoint commits"); +Console.WriteLine("Loot pickup authority: collision pickup requests flow through gameplay checkpoint commits"); +Console.WriteLine("Entity destruction authority: hull-death observers flow through gameplay checkpoint commits"); Console.WriteLine("Trade purchase authority: UI buy requests flow through gameplay checkpoint commits"); Console.WriteLine("Inventory loadout restore authority: UI restore requests flow through gameplay checkpoint commits"); Console.WriteLine("Docked current-ship authority: UI selection requests flow through gameplay checkpoint commits"); @@ -1162,6 +1165,64 @@ static void RequireLootPickupCommitAuthority(string root) } } +static void RequireEntityDestroyedCommitAuthority(string root) +{ + var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); + var actionGameManager = File.Exists(actionGameManagerPath) + ? File.ReadAllText(actionGameManagerPath) + : throw new InvalidOperationException("Cannot verify entity destruction authority; ActionGameManager.cs is missing."); + + var requiredSymbols = new[] + { + "CommitEntityDestroyed", + "QueueRunCheckpoint(\"entity-destroyed\")", + "entity.Zone.Entities.Remove(entity)", + "zoneRenderer.DropItem(" + }; + + var missingSymbols = requiredSymbols + .Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingSymbols.Length > 0) + { + throw new InvalidOperationException( + "Entity destruction no longer has a gameplay-owned checkpoint commit primitive: " + + string.Join(", ", missingSymbols)); + } + + var entityInstancePath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "EntityInstance.cs"); + var entityInstance = File.Exists(entityInstancePath) + ? File.ReadAllText(entityInstancePath) + : throw new InvalidOperationException("Cannot verify entity destruction authority; EntityInstance.cs is missing."); + + var forbiddenSymbols = new[] + { + "ZoneRenderer.DropItem(", + "entity.Zone.Entities.Remove(entity)", + "Random.onUnitSphere", + "LootDropProbability" + }; + + var hits = File.ReadLines(entityInstancePath) + .Select((line, index) => new { LineNumber = index + 1, Line = line }) + .Where(line => forbiddenSymbols.Any(symbol => line.Line.Contains(symbol, StringComparison.Ordinal))) + .Select(line => $"{Path.GetRelativePath(root, entityInstancePath)}:{line.LineNumber}: {line.Line.Trim()}") + .ToArray(); + + if (hits.Length > 0) + { + throw new InvalidOperationException( + "EntityInstance still owns direct destruction/drop mutation: " + + string.Join("; ", hits)); + } + + if (!entityInstance.Contains("ActionGameManager.Instance?.CommitEntityDestroyed", StringComparison.Ordinal)) + { + throw new InvalidOperationException("EntityInstance no longer routes destruction through ActionGameManager."); + } +} + static void RequireInventoryLoadoutRestoreCommitAuthority(string root) { var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 7acac87c9..06cea354e 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1587,6 +1587,43 @@ public class ActionGameManager : MonoBehaviour return false; } + public bool CommitEntityDestroyed(Entity entity, ZoneRenderer zoneRenderer) + { + if (entity?.Zone == null) + return false; + + if (zoneRenderer != null) + { + foreach (var gear in entity.Equipment) + { + if (gear == entity.EquippedHull || Random.value >= zoneRenderer.Settings.LootDropProbability) + continue; + + zoneRenderer.DropItem( + entity.Position, + Random.onUnitSphere * zoneRenderer.Settings.LootDropVelocity, + gear.EquippableItem); + } + + foreach (var cargo in entity.CargoBays) + { + foreach (var item in cargo.Cargo.Keys.ToArray()) + { + zoneRenderer.DropItem( + entity.Position, + Random.onUnitSphere * zoneRenderer.Settings.LootDropVelocity, + item); + } + } + } + + if (!entity.Zone.Entities.Remove(entity)) + return false; + + QueueRunCheckpoint("entity-destroyed"); + return true; + } + public bool CommitCargoItemTransfer( EquippedCargoBay origin, EquippedCargoBay destination, diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index a0f0b860f..950dae92e 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -8,7 +8,6 @@ using UnityEngine; using Unity.Mathematics; using static Unity.Mathematics.math; using float2 = Unity.Mathematics.float2; -using Random = UnityEngine.Random; public class EntityInstance : MonoBehaviour { @@ -421,37 +420,18 @@ public class EntityInstance : MonoBehaviour _subscriptions.Add(Entity.HullDamage.Subscribe(_ => { - if (Entity.Hull.Durability < .01f) + if (!_destroyed && Entity.Hull.Durability < .01f) { if (!this) return; - _destroyed = true; - foreach (var gear in Entity.Equipment) - { - if (gear != Entity.EquippedHull && Random.value < ZoneRenderer.Settings.LootDropProbability) - { - ZoneRenderer.DropItem( - Entity.Position, - Random.onUnitSphere * ZoneRenderer.Settings.LootDropVelocity, - gear.EquippableItem); - } - } + if (ActionGameManager.Instance?.CommitEntityDestroyed(entity, ZoneRenderer) != true) + return; - foreach (var cargo in Entity.CargoBays) - { - foreach (var item in cargo.Cargo.Keys) - { - ZoneRenderer.DropItem( - Entity.Position, - Random.onUnitSphere * ZoneRenderer.Settings.LootDropVelocity, - item); - } - } + _destroyed = true; if (DestroyEffect != null) { var t = Instantiate(DestroyEffect).transform; t.position = transform.position; } - entity.Zone.Entities.Remove(entity); } })); diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 3be2d7a57..f8dc024aa 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -1414,6 +1414,12 @@ First Aetheria surfaces to publish: after a successful commit, but it no longer stores items into cargo bays directly; gameplay owns the cargo mutation and queues the typed run checkpoint. + - Done: route entity destruction through + `ActionGameManager.CommitEntityDestroyed`. `EntityInstance` observes hull + death and may spawn the local destruction effect, but gameplay owns + equipment/cargo drop decisions, zone entity removal, and the typed run + checkpoint. Dropped world pickups are still presentation/runtime objects + until the runtime object graph pass gives them typed documents. - Replace the next concrete uGUI screen with an Eve-owned surface. - Move the staged packages into the Eve repo once its worktree is clean, then import them back into Aetheria from Eve instead of carrying a local copy. @@ -1749,7 +1755,9 @@ instantiation, credits, dock assignment, current entity, and checkpoint. Docked current-ship selection follows the same rule: UI requests selection, gameplay owns `CurrentEntity`, `DockingBay.DockedShip`, and checkpoint. Loot pickup follows the same rule: collision code reports the pickup opportunity, gameplay -owns cargo storage and checkpoint. Any +owns cargo storage and checkpoint. Entity destruction follows the same rule: +instance code observes death, gameplay owns drop decisions, zone removal, and +checkpoint. Any predicate that still needs legacy DTO objects must earn that dependency by using behavior objects or simulation-only methods that typed facets do not yet expose.