From 22dae9adbef5e6e1799bf152d4caa27f098740b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 05:32:03 +0100 Subject: [PATCH] Restore daemon destruction and loot --- Aetheria.State.Daemon.Smoke/Program.cs | 90 +++++++++++ Aetheria.State.Verify/Program.cs | 28 +++- Aetheria.Unity/Packages/manifest.json | 2 +- Aetheria.Unity/Packages/packages-lock.json | 2 +- .../Tests/DaemonRuntimeDocumentTests.cs | 21 +-- .../Runtime/AetheriaRuntimeAssets.cs | 14 ++ .../AetheriaRuntimeDaemonOperations.cs | 26 --- .../AetheriaRuntimeDaemonSimulation.cs | 150 ++++++++++++++++-- ...AetheriaRuntimeDaemonSimulationSettings.cs | 19 ++- .../AetheriaRuntimeSnapshotDocuments.cs | 6 + docs/aetheria-perfect-machine-map.md | 18 +++ docs/deterministic-combat-resolution.md | 11 ++ docs/original-game-migration-matrix.md | 2 +- 13 files changed, 333 insertions(+), 56 deletions(-) diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 5d270a840..38fb09129 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -19,6 +19,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks CanonicalCatalogPublishesRecoveredMineLauncher(); EnergyFundedShieldInterceptsDamageBeforeHull(); ArmorCellsResolveBeforeEquipmentAndHull(); + DestructionDropsLootExactlyOnce(); DaemonSimulationTreatsYmirHitAsPresentationOnly(); ProjectileContactCannotKill(); MissingPhysicsOwnerCannotAdvanceProjectiles(); @@ -504,6 +505,79 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "Eve world projection must expose the exact daemon-owned schematic grid"); } + private static void DestructionDropsLootExactlyOnce() + { + var source = Entity(0, -100, "player"); + var target = Entity(1, 0, "raider"); + target.HullItemKey = "drop-hull"; + target.StatGrids = [Grid("hull", 1), Grid("shield", 0), Grid("heat", 0)]; + target.Equipment = [new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit + { ItemKey = "drop-gear", Quality = 0.8, Durability = 0.6, Quantity = 1, Enabled = true } + }]; + target.CargoContents = [Cargo(("drop-cargo", 4, 0, 0))]; + var zone = new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, Entities = [source, target], + PhysicalPayloads = [new AetheriaRuntimePhysicalPayloadCommit + { + PayloadId = "destruction-mine", PayloadKind = "mine", WeaponItemKey = "destruction-charge", + SourceEntityIndex = 0, PositionX = 0, PositionZ = 0, BlastRadius = 200, + PayloadMagnitude = 20, TriggeredAtSeconds = 0, DetonationDelaySeconds = 0, + LifetimeSeconds = 30, Active = true, Stationary = true + }] + }; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "destruction-smoke", GenerationSeed = 29, CurrentZoneIndex = 0, + CurrentEntityKey = "zone.0.entity.0", Zones = [zone] + }; + var settings = LootSettings(1); + + AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, settings, + new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), null, 1, 0.1); + + Require(!target.IsActive && target.DestroyedFrameId == 1 && + !string.IsNullOrWhiteSpace(target.DestructionId), + "lethal daemon damage must commit one durable destruction identity and deactivate the entity"); + RequireEqual(3, zone.DroppedPickups.Count, + "guaranteed fossil loot roll must drop hull, equipment, and every cargo stack"); + RequireEqual(4, zone.DroppedPickups.Single(value => value.Item.ItemKey == "drop-cargo").Item.Quantity, + "cargo drops must preserve exact stack quantity"); + Require(zone.DroppedPickups.All(value => Math.Abs(Math.Sqrt( + value.VelocityX * value.VelocityX + value.VelocityY * value.VelocityY + + value.VelocityZ * value.VelocityZ) - 25) < 0.000001 && + Math.Abs(value.LifetimeSeconds - 30) < 0.000001), + "destruction pickups must retain fossil launch speed and lifetime"); + RequireEqual(3, run.GameEvents.Count(value => value.Kind == "pickup.dropped"), + "each durable pickup must publish one drop event"); + RequireEqual(1, run.GameEvents.Count(value => value.Kind == "entity.destroyed"), + "destruction chronology must publish exactly once"); + + var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( + new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = run }, + new AetheriaRuntimeDaemonHealthDocument(), + AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon")); + Require(!Flatten(surface.Surface.Root).Any(node => node.Id == "aetheria.daemon.game.world.entity.1"), + "Eve must not project a destroyed tombstone as an active world entity"); + RequireEqual(3, Flatten(surface.Surface.Root).Count(node => node.Kind == "world.entity3d" && + node.Props.TryGetValue("entityKind", out var kind) && kind == "pickup"), + "Eve must project every daemon-owned destruction pickup"); + var destructionAsset = AetheriaRuntimeAssets.ProjectManifest(null).Assets.Single(value => + value.Ref.Metadata.TryGetValue("presentationRole", out var role) && + role == "effect.feedback.entity.destroyed"); + RequireEqual("prefab.effect.entity.destroyed", destructionAsset.Ref.AssetKey, + "provider must advertise the original destruction effect by semantic feedback role"); + + AetheriaRuntimeDaemonSimulation.Step(run, new AetheriaRuntimeDaemonIntentState(), 0.1, settings, + new AetheriaYmirPhysicalPayloadPhysics(), new AetheriaYmirWorldPhysics(), null, 2, 0.2); + RequireEqual(3, zone.DroppedPickups.Count, + "later ticks must not duplicate an already committed destruction transaction"); + RequireEqual(1, run.GameEvents.Count(value => value.Kind == "entity.destroyed"), + "later ticks must not duplicate destruction chronology"); + } + private static void ChargedWeaponHoldRiskMalfunctionsDeterministically() { var source = Entity(0, 0, "player"); @@ -911,6 +985,22 @@ internal sealed class AetheriaDaemonYmirSmokeChecks 0, 0, "", Array.Empty(), Array.Empty(), "", ""); } + private static AetheriaRuntimeDaemonSimulationSettings LootSettings(double probability) + { + var value = AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault; + return new AetheriaRuntimeDaemonSimulationSettings( + value.PawnSpeed, value.RaiderSpeed, value.AttackRange, value.AttackHoldRatio, + value.PawnProjectileDamage, value.RaiderProjectileDamage, value.WeaponCooldownSeconds, + value.ProjectileSpeed, value.ProjectileRadius, value.ProjectileLifetimeSeconds, + value.ProjectileSpawnOffset, value.ProjectileHeatScale, value.HeatDissipationPerSecond, + value.StationSensorRange, value.EntitySensorRange, value.PlayerStationHull, + value.HostileStationHull, value.PlayerEntityHull, value.RaiderEntityHull, + value.StationShield, value.EntityShield, value.WeaponLockSpeed, + value.WeaponLockSensorImpact, value.WeaponLockAngleDegrees, + value.WeaponLockDirectionImpact, value.WeaponLockDecayPerSecond, + probability, value.LootDropVelocity, value.PickupLifetimeSeconds); + } + private static void AgentPatrolsHistoricalOrbitCircuitThroughMovementCommands() { var agent = Entity(0, 0, "workers"); diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 2bfce50d4..e37845267 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -11281,8 +11281,7 @@ static void RequireUnityPublicRequestVocabulary(string root) var checkedRoots = new[] { Path.Combine(root, "Assets", "Scripts", "Gameplay"), - Path.Combine(root, "Assets", "Scripts", "UI"), - Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime") + Path.Combine(root, "Assets", "Scripts", "UI") }; var commitPattern = new System.Text.RegularExpressions.Regex( @@ -16732,6 +16731,31 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) string.Join(", ", missingArmorTransactionSymbols)); } + var daemonOperations = File.ReadAllText(Path.Combine( + root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeDaemonOperations.cs")); + var requiredDestructionTransactionSymbols = new[] + { + "CommitDestruction(run, zone, target", + "target.DestructionId = destructionId", + "settings.LootDropProbability", + "settings.LootDropVelocity", + "settings.PickupLifetimeSeconds", + "Kind = \"pickup.dropped\"", + "Kind = \"entity.destroyed\"", + "zone.DroppedPickups = pickups" + }; + var missingDestructionTransactionSymbols = requiredDestructionTransactionSymbols + .Where(symbol => !daemonSimulation.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingDestructionTransactionSymbols.Length > 0 || + daemonOperations.Contains("ApplyDestroyEntity(", StringComparison.Ordinal) || + daemonOperations.Contains("case AetheriaRuntimeDaemonCommandKinds.DestroyEntity:", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Lethal daemon damage must own exactly-once destruction, loot drops, and cleanup; clients may not command entity erasure: " + + string.Join(", ", missingDestructionTransactionSymbols)); + } + if (daemonSimulation.Contains("AetheriaRuntimeRtsSimulation", StringComparison.Ordinal) || daemonSimulation.Contains("daemon-rts", StringComparison.Ordinal) || daemonSimulation.Contains("rts-commander", StringComparison.Ordinal) || diff --git a/Aetheria.Unity/Packages/manifest.json b/Aetheria.Unity/Packages/manifest.json index d84a547f0..ef2757847 100644 --- a/Aetheria.Unity/Packages/manifest.json +++ b/Aetheria.Unity/Packages/manifest.json @@ -8,6 +8,6 @@ "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-source-v1.0.3-r2", "org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.1.1", "org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.1.0", - "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#0d97dcf56c8b813de50d319ebce10a93595ad5c4" + "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#290b384703d19b4deee79fe015d80957ec5f8eb6" } } diff --git a/Aetheria.Unity/Packages/packages-lock.json b/Aetheria.Unity/Packages/packages-lock.json index 4cf69aa56..0022a576f 100644 --- a/Aetheria.Unity/Packages/packages-lock.json +++ b/Aetheria.Unity/Packages/packages-lock.json @@ -47,7 +47,7 @@ "hash": "e10821f1cc81f619479c6507438f6a097d1f0ce2" }, "org.gamecult.eve.unity-scene": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#0d97dcf56c8b813de50d319ebce10a93595ad5c4", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#290b384703d19b4deee79fe015d80957ec5f8eb6", "depth": 0, "source": "git", "dependencies": { diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 13b96fbec..68daee918 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -4303,7 +4303,7 @@ public class DaemonRuntimeDocumentTests } [Test] - public void DaemonOperationsDestroysEntityInDaemonState() + public void DaemonOperationsRejectsClientAuthoredEntityDestruction() { var run = RunWithTwoEntities(); run.CurrentEntityKey = "zone.0.entity.1"; @@ -4338,18 +4338,19 @@ public class DaemonRuntimeDocumentTests var result = AetheriaRuntimeDaemonOperations.Execute(run, new[] { command }); - Assert.AreEqual(1, result.AppliedCommandIds.Count); - Assert.AreEqual(2, run.Zones[0].Entities.Count); + Assert.AreEqual(0, result.AppliedCommandIds.Count); + Assert.AreEqual(1, result.RejectedCommandIds.Count); + Assert.AreEqual(3, run.Zones[0].Entities.Count); Assert.AreEqual("Player", run.Zones[0].Entities[0].Name); Assert.AreEqual(0, run.Zones[0].Entities[0].EntityIndex); - Assert.AreEqual("Reindexed", run.Zones[0].Entities[1].Name); - Assert.AreEqual(1, run.Zones[0].Entities[1].EntityIndex); - Assert.AreEqual(-1, run.Zones[0].Entities[0].TargetEntityIndex); - Assert.AreEqual(0, run.Zones[0].Entities[0].ChildEntityIndices.Count); - Assert.AreEqual(-1, run.Zones[0].Entities[0].DockingBayAssignments[0]); + Assert.AreEqual("Target", run.Zones[0].Entities[1].Name); + Assert.AreEqual("Reindexed", run.Zones[0].Entities[2].Name); + Assert.AreEqual(1, run.Zones[0].Entities[0].TargetEntityIndex); + Assert.AreEqual(1, run.Zones[0].Entities[0].ChildEntityIndices.Single()); + Assert.AreEqual(1, run.Zones[0].Entities[0].DockingBayAssignments[0]); Assert.AreEqual(-1, run.Zones[0].Entities[0].DockingBayAssignments[1]); - Assert.AreEqual(0, run.Zones[0].Entities[0].Contacts.Count); - Assert.AreEqual("", run.CurrentEntityKey); + Assert.AreEqual(1, run.Zones[0].Entities[0].Contacts.Single().TargetEntityIndex); + Assert.AreEqual("zone.0.entity.1", run.CurrentEntityKey); } [Test] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs index 70d315523..622971a58 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs @@ -28,6 +28,7 @@ namespace GameCult.Aetheria.State.Verse Add(entries, MapPrefab("prefab.entity.orbital", "Orbital", "Prefabs/Stations/Zenith", "orbital")); Add(entries, MapPrefab("prefab.effect.shot.bolt", "Bolt", "Prefabs/Lightning", "effect.shot.bolt")); Add(entries, MapPrefab("prefab.effect.impact.shield", "Shield impact", "Prefabs/Shield", "effect.impact.shield")); + Add(entries, DestructionEffect()); Add(entries, MapProjectPrefab("prefab.entity.pickup", "Pickup", "Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab")); Add(entries, MinePrefab()); foreach (var inventoryAsset in InventoryUiAssets()) @@ -219,6 +220,19 @@ namespace GameCult.Aetheria.State.Verse return entry; } + private static AetheriaRuntimeAssetManifestEntry DestructionEffect() + { + var entry = MapProjectPrefab( + "prefab.effect.entity.destroyed", + "Entity destruction", + "Assets/Prefabs/Fire & Explosion Effects/Prefabs/BigExplosion.prefab"); + entry.Ref.Metadata = new Dictionary(entry.Ref.Metadata, StringComparer.Ordinal) + { + ["presentationRole"] = "effect.feedback.entity.destroyed" + }; + return entry; + } + private static AetheriaRuntimeAssetRef Sprite(string key, string resourcePath) { return CultMeshAssetRef( diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index 65bca4090..237769baa 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -130,8 +130,6 @@ namespace GameCult.Aetheria.State.Verse case AetheriaRuntimeDaemonCommandKinds.SetEntityName: return ApplyTargetEntity(run, command.TargetEntityKey, entity => entity.Name = command.TextValue ?? ""); - case AetheriaRuntimeDaemonCommandKinds.DestroyEntity: - return ApplyDestroyEntity(run, command); case AetheriaRuntimeDaemonCommandKinds.SetDockedCurrentShip: return ApplySetDockedCurrentShip(run, command.TargetEntityKey); case AetheriaRuntimeDaemonCommandKinds.TransferCargoItem: @@ -1664,30 +1662,6 @@ namespace GameCult.Aetheria.State.Verse return true; } - private static bool ApplyDestroyEntity( - AetheriaRuntimeRunCheckpointCommit run, - AetheriaRuntimeDaemonCommandDocument command) - { - if (!AetheriaRuntimeRunCheckpointCommit.TryParseEntityKey(command.TargetEntityKey, out var zoneIndex, out var entityIndex)) - return false; - - var zones = (run.Zones ?? Array.Empty()).ToList(); - var zone = zones.FirstOrDefault(candidate => candidate != null && candidate.ZoneIndex == zoneIndex); - if (zone == null) - return false; - - var entities = (zone.Entities ?? Array.Empty()).ToList(); - if (entityIndex < 0 || entityIndex >= entities.Count) - return false; - - entities.RemoveAt(entityIndex); - zone.Entities = entities.ToArray(); - ReindexZoneAfterEntityRemoval(zone, entityIndex); - ReindexCurrentEntityKeyAfterRemoval(run, zoneIndex, entityIndex); - - return true; - } - private static void ReindexZoneAfterEntityRemoval(AetheriaRuntimeZoneSnapshotCommit zone, int removedEntityIndex) { var entities = zone.Entities ?? Array.Empty(); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 42d81dd51..7557aa43a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -331,7 +331,7 @@ namespace GameCult.Aetheria.State.Verse weapon.State.BurstRemaining--; weapon.State.BurstTimer -= weapon.State.BurstInterval; var shotId = NextShotId(zone, attacker, weapon); - CommitShotResolution(run, zone, attacker, target, weapon, shotId, frameId, catalog); + CommitShotResolution(run, zone, attacker, target, weapon, shotId, frameId, catalog, settings); AppendShotCommittedEvent(run, zone, attacker, target, weapon, shotId, frameId); weapon.State.Firing = true; AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat); @@ -362,7 +362,7 @@ namespace GameCult.Aetheria.State.Verse } } } - ResolveDeployableDetonations(run, zone, entities, frameId, simulationTimeSeconds, catalog); + ResolveDeployableDetonations(run, zone, entities, frameId, simulationTimeSeconds, catalog, settings); foreach (var entity in entities) { @@ -445,7 +445,8 @@ namespace GameCult.Aetheria.State.Verse IReadOnlyList entities, long frameId, double simulationTimeSeconds, - AetheriaRuntimeCatalogSnapshot? catalog) + AetheriaRuntimeCatalogSnapshot? catalog, + AetheriaRuntimeDaemonSimulationSettings settings) { var survivors = new List(); foreach (var payload in zone.PhysicalPayloads ?? Array.Empty()) @@ -464,6 +465,7 @@ namespace GameCult.Aetheria.State.Verse var dz = target.PositionZ - payload.PositionZ; if (dx * dx + dz * dz > payload.BlastRadius * payload.BlastRadius) continue; var source = entities.FirstOrDefault(value => value.EntityIndex == payload.SourceEntityIndex); + var aliveBefore = IsAlive(target); var damage = ResolveDamage(target, payload.PayloadMagnitude, payload.DamageType, payload.Penetration, payload.DamageSpread, source, payload.PositionX, payload.PositionZ, true, catalog); @@ -490,6 +492,9 @@ namespace GameCult.Aetheria.State.Verse Reason = payload.DamageType, PositionX = payload.PositionX, PositionZ = payload.PositionZ }); + if (aliveBefore && !IsAlive(target)) + CommitDestruction(run, zone, target, payload.SourceEntityIndex, + payload.PayloadId, payload.WeaponItemKey, frameId, settings); } AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"physical-payload:{payload.PayloadId}:detonated", Kind = "deployable.detonated", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = payload.SourceEntityIndex, SubjectKey = payload.PayloadId, ItemKey = payload.WeaponItemKey, Reason = payload.TriggerReason, ScalarValue = payload.PayloadMagnitude, PositionX = payload.PositionX, PositionZ = payload.PositionZ }); } @@ -618,7 +623,7 @@ namespace GameCult.Aetheria.State.Verse state.BurstRemaining--; state.BurstTimer -= state.BurstInterval; var shotId = NextShotId(zone, attacker, shot); - CommitShotResolution(run, zone, attacker, solutionTarget, shot, shotId, frameId, catalog); + CommitShotResolution(run, zone, attacker, solutionTarget, shot, shotId, frameId, catalog, settings); AppendShotCommittedEvent(run, zone, attacker, solutionTarget, shot, shotId, frameId); state.Firing = true; AetheriaRuntimeThermalSimulation.AddHeat(attacker, shot.Heat); @@ -772,7 +777,7 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeThermalSimulation.AddHeat(attacker, weapon.Heat * deltaSeconds); var shot = weapon.ResolutionShot(deltaSeconds); var shotId = NextShotId(zone, attacker, shot); - CommitShotResolution(run, zone, attacker, selectedTarget!, shot, shotId, frameId, catalog); + CommitShotResolution(run, zone, attacker, selectedTarget!, shot, shotId, frameId, catalog, settings); AppendShotCommittedEvent(run, zone, attacker, selectedTarget!, shot, shotId, frameId); } } @@ -1096,7 +1101,8 @@ namespace GameCult.Aetheria.State.Verse ResolvedWeapon weapon, string shotId, long frameId, - AetheriaRuntimeCatalogSnapshot? catalog) + AetheriaRuntimeCatalogSnapshot? catalog, + AetheriaRuntimeDaemonSimulationSettings settings) { if ((run.ShotReceipts ?? Array.Empty()) .Any(value => value != null && string.Equals(value.ShotId, shotId, StringComparison.Ordinal))) @@ -1196,13 +1202,7 @@ namespace GameCult.Aetheria.State.Verse PositionX = target.PositionX, PositionZ = target.PositionZ }); if (aliveBefore && !IsAlive(target)) - AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit - { - EventId = $"shot:{shotId}:destroyed:{target.EntityIndex}", Kind = "entity.destroyed", - FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex, - TargetEntityIndex = target.EntityIndex, SubjectKey = shotId, - ItemKey = weapon.ItemKey, PositionX = target.PositionX, PositionZ = target.PositionZ - }); + CommitDestruction(run, zone, target, attacker.EntityIndex, shotId, weapon.ItemKey, frameId, settings); } private static string NextShotId(AetheriaRuntimeZoneSnapshotCommit zone, @@ -1212,6 +1212,130 @@ namespace GameCult.Aetheria.State.Verse return $"shot:{zone.ZoneIndex}:{attacker.EntityIndex}:{weapon.State.OwnerIndex}:{weapon.State.BehaviorIndex}:{weapon.State.ShotSequence}"; } + private static void CommitDestruction( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit target, + int sourceEntityIndex, + string subjectKey, + string weaponItemKey, + long frameId, + AetheriaRuntimeDaemonSimulationSettings settings) + { + if (!string.IsNullOrWhiteSpace(target.DestructionId)) return; + + var destructionId = $"destruction:{run.RunId}:{zone.ZoneIndex}:{target.EntityIndex}:{frameId}"; + target.DestructionId = destructionId; + target.DestroyedFrameId = frameId; + target.IsActive = false; + target.TargetEntityIndex = -1; + target.TractorPower = 0; + target.TractorTargetPower = 0; + foreach (var state in target.WeaponStates ?? Array.Empty()) + { + state.Firing = false; + state.Charging = false; + state.Charged = false; + state.LockTargetEntityIndex = -1; + } + + var pickups = (zone.DroppedPickups ?? Array.Empty()).ToList(); + var nextPickupIndex = pickups.Count == 0 ? 0 : pickups.Max(value => value.PickupIndex) + 1; + void Drop(AetheriaRuntimeLoadoutItemCommit item, string sourceKind, int sourceIndex) + { + var velocity = DestructionVelocity(run.GenerationSeed, destructionId, + $"{sourceKind}:{sourceIndex}", settings.LootDropVelocity); + var pickup = new AetheriaRuntimeDroppedPickupCommit + { + PickupIndex = nextPickupIndex++, PositionX = target.PositionX, PositionY = target.PositionY, + PositionZ = target.PositionZ, VelocityX = velocity.X, VelocityY = velocity.Y, + VelocityZ = velocity.Z, Item = CloneItem(item), AgeSeconds = 0, + LifetimeSeconds = settings.PickupLifetimeSeconds + }; + pickups.Add(pickup); + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit + { + EventId = $"{destructionId}:pickup:{pickup.PickupIndex}", Kind = "pickup.dropped", + FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = sourceEntityIndex, + TargetEntityIndex = target.EntityIndex, PickupIndex = pickup.PickupIndex, + ItemKey = pickup.Item.ItemKey, ScalarValue = Math.Max(1, pickup.Item.Quantity), + SubjectKey = destructionId, Reason = sourceKind, + PositionX = target.PositionX, PositionZ = target.PositionZ + }); + } + + if (!string.IsNullOrWhiteSpace(target.HullItemKey) && + ShotRoll(run.GenerationSeed, destructionId, "equipment:hull") < settings.LootDropProbability) + Drop(new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = target.HullItemKey, Quality = 1, Durability = 0, + Quantity = 1, Enabled = false + }, "equipment", -1); + var equipment = target.Equipment ?? Array.Empty(); + for (var index = 0; index < equipment.Count; index++) + { + var slot = equipment[index]; + if (slot?.Item == null || + ShotRoll(run.GenerationSeed, destructionId, $"equipment:{index}") >= settings.LootDropProbability) + continue; + Drop(slot.Item, "equipment", index); + } + var cargoIndex = 0; + foreach (var slot in (target.CargoContents ?? Array.Empty()) + .Where(bay => bay != null) + .SelectMany(bay => bay.Items ?? Array.Empty())) + { + if (slot?.Item != null) Drop(slot.Item, "cargo", cargoIndex); + cargoIndex++; + } + zone.DroppedPickups = pickups; + + target.Equipment = Array.Empty(); + target.CargoContents = Array.Empty(); + target.CargoBays = Array.Empty(); + target.DockingBays = Array.Empty(); + target.DockingBayContents = Array.Empty(); + target.DockingBayAssignments = Array.Empty(); + foreach (var entity in zone.Entities ?? Array.Empty()) + { + if (entity == null || entity.EntityIndex == target.EntityIndex) continue; + if (entity.TargetEntityIndex == target.EntityIndex) entity.TargetEntityIndex = -1; + entity.ChildEntityIndices = (entity.ChildEntityIndices ?? Array.Empty()) + .Where(index => index != target.EntityIndex).ToArray(); + entity.DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty()) + .Select(index => index == target.EntityIndex ? -1 : index).ToArray(); + entity.Contacts = (entity.Contacts ?? Array.Empty()) + .Where(contact => contact != null && contact.TargetEntityIndex != target.EntityIndex).ToArray(); + } + if (run.CurrentZoneIndex == zone.ZoneIndex && TryParseEntityIndex(run.CurrentEntityKey, out var currentIndex) && + currentIndex == target.EntityIndex) + run.CurrentEntityKey = ""; + + AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit + { + EventId = destructionId, Kind = "entity.destroyed", FrameId = frameId, + ZoneIndex = zone.ZoneIndex, SourceEntityIndex = sourceEntityIndex, + TargetEntityIndex = target.EntityIndex, SubjectKey = subjectKey, + ItemKey = weaponItemKey, PositionX = target.PositionX, PositionZ = target.PositionZ + }); + } + + private static AetheriaRuntimeLoadoutItemCommit CloneItem(AetheriaRuntimeLoadoutItemCommit item) => new() + { + ItemKey = item.ItemKey ?? "", Quality = item.Quality, Durability = item.Durability, + Quantity = item.Quantity, Enabled = item.Enabled, OverrideShutdown = item.OverrideShutdown, + Temperature = item.Temperature + }; + + private static (double X, double Y, double Z) DestructionVelocity( + uint seed, string destructionId, string itemKey, double speed) + { + var z = ShotRoll(seed, destructionId, $"{itemKey}:velocity-z") * 2 - 1; + var angle = ShotRoll(seed, destructionId, $"{itemKey}:velocity-angle") * Math.PI * 2; + var radius = Math.Sqrt(Math.Max(0, 1 - z * z)); + return (Math.Cos(angle) * radius * speed, z * speed, Math.Sin(angle) * radius * speed); + } + private static void AppendShotCommittedEvent(AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit attacker, AetheriaRuntimeEntitySnapshotCommit target, ResolvedWeapon weapon, string shotId, long frameId) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs index 9bb6b8aa5..9497c13bb 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulationSettings.cs @@ -34,7 +34,10 @@ namespace GameCult.Aetheria.State.Verse weaponLockSensorImpact: 1.0, weaponLockAngleDegrees: 45.0, weaponLockDirectionImpact: 1.0, - weaponLockDecayPerSecond: 1.0); + weaponLockDecayPerSecond: 1.0, + lootDropProbability: 0.25, + lootDropVelocity: 25.0, + pickupLifetimeSeconds: 30.0); public AetheriaRuntimeDaemonSimulationSettings( double pawnSpeed, @@ -62,7 +65,10 @@ namespace GameCult.Aetheria.State.Verse double weaponLockSensorImpact = AetheriaDefaultRaw.WeaponLockSensorImpact, double weaponLockAngleDegrees = AetheriaDefaultRaw.WeaponLockAngleDegrees, double weaponLockDirectionImpact = AetheriaDefaultRaw.WeaponLockDirectionImpact, - double weaponLockDecayPerSecond = AetheriaDefaultRaw.WeaponLockDecayPerSecond) + double weaponLockDecayPerSecond = AetheriaDefaultRaw.WeaponLockDecayPerSecond, + double lootDropProbability = AetheriaDefaultRaw.LootDropProbability, + double lootDropVelocity = AetheriaDefaultRaw.LootDropVelocity, + double pickupLifetimeSeconds = AetheriaDefaultRaw.PickupLifetimeSeconds) { PawnSpeed = PositiveOr(pawnSpeed, AetheriaDefaultRaw.PawnSpeed); RaiderSpeed = PositiveOr(raiderSpeed, AetheriaDefaultRaw.RaiderSpeed); @@ -90,6 +96,9 @@ namespace GameCult.Aetheria.State.Verse WeaponLockAngleDegrees = PositiveOr(weaponLockAngleDegrees, AetheriaDefaultRaw.WeaponLockAngleDegrees); WeaponLockDirectionImpact = NonNegativeOr(weaponLockDirectionImpact, AetheriaDefaultRaw.WeaponLockDirectionImpact); WeaponLockDecayPerSecond = NonNegativeOr(weaponLockDecayPerSecond, AetheriaDefaultRaw.WeaponLockDecayPerSecond); + LootDropProbability = Clamp01Or(lootDropProbability, AetheriaDefaultRaw.LootDropProbability); + LootDropVelocity = NonNegativeOr(lootDropVelocity, AetheriaDefaultRaw.LootDropVelocity); + PickupLifetimeSeconds = PositiveOr(pickupLifetimeSeconds, AetheriaDefaultRaw.PickupLifetimeSeconds); } public double PawnSpeed { get; } @@ -118,6 +127,9 @@ namespace GameCult.Aetheria.State.Verse public double WeaponLockAngleDegrees { get; } public double WeaponLockDirectionImpact { get; } public double WeaponLockDecayPerSecond { get; } + public double LootDropProbability { get; } + public double LootDropVelocity { get; } + public double PickupLifetimeSeconds { get; } private static double PositiveOr(double value, double fallback) { @@ -162,6 +174,9 @@ namespace GameCult.Aetheria.State.Verse public const double WeaponLockAngleDegrees = 45.0; public const double WeaponLockDirectionImpact = 1.0; public const double WeaponLockDecayPerSecond = 1.0; + public const double LootDropProbability = 0.25; + public const double LootDropVelocity = 25.0; + public const double PickupLifetimeSeconds = 30.0; } } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index db87778c9..cd0f95cfc 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -1039,6 +1039,12 @@ namespace GameCult.Aetheria.State.Verse [Key(44)] public AetheriaRuntimeLoadoutGenerationReceiptCommit? LoadoutGeneration { get; set; } + + [Key(45)] + public string DestructionId { get; set; } = ""; + + [Key(46)] + public long DestroyedFrameId { get; set; } = -1; } [MessagePackObject] diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index dbc996faa..5d87b207a 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -750,6 +750,24 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. transaction mutates them thereafter. Penetration always advances by a half-cell step, eliminating the fossil's non-advancing infinite loop. +### Daemon Destruction Transaction + +- Owner: lethal daemon damage owns one destruction identity, active-world + removal, reference cleanup, loot rolls, pickup state, and chronology. +- Inputs: canonical hull result, run seed, entity equipment/cargo, fossil loot + settings (`0.25` equipment probability, `25` launch speed, `30` second life). +- Outputs: an inactive identity-stable tombstone, deterministic typed pickups, + `entity.destroyed` and `pickup.dropped` events, and Eve world removal/effects. +- Derived state: the retained entity row exists only for stable indexing and + replay protection. It is not rendered, targetable, salvageable, or alive. +- Forbidden writers: Unity destruction callbacks, Eve feedback renderers, and + the reserved `DestroyEntity` command cannot erase entities or create loot. +- Shared paths: direct shots and deployable splash invoke the same destruction + commit after the shared damage resolver crosses zero hull. +- Cut line: client-commanded removal and presentation-triggered loot are dead; + EveUnity resolves `effect.feedback.entity.destroyed` only after the provider + has published authoritative destruction chronology. + - Owner: `Aetheria.State` owns the new typed state spine for durable state. The embedded `GameCult.Aetheria.State.Unity` package owns Unity's runtime typed state file path plus the boot-time state-file probe, and is the landing diff --git a/docs/deterministic-combat-resolution.md b/docs/deterministic-combat-resolution.md index b37e37fc8..46f11c4bb 100644 --- a/docs/deterministic-combat-resolution.md +++ b/docs/deterministic-combat-resolution.md @@ -154,6 +154,15 @@ the shape. Splash payloads such as mines preserve the fossil's distinct rule: they select the source-facing half of the hull schematic, then enter the same layer transaction. +Lethal damage commits destruction in the same daemon transaction boundary. +The target receives one durable destruction identity, leaves the active Eve +world, clears combat/docking references, and emits deterministic pickups. The +fossil rules are preserved: every cargo stack drops, each equipped item rolls +independently against `0.25`, launch velocity has magnitude `25`, and pickup +lifetime is `30` seconds. A retained entity row is a non-rendered tombstone for +stable identity and exactly-once replay protection; it is not a wreck or a +second gameplay owner. The reserved `DestroyEntity` command is rejected. + ## Armor-Cell Authority Map - Owner: the daemon's canonical damage transaction owns shield interception, @@ -253,6 +262,8 @@ drop or simplify an effect. It may not promote a visual collision into damage. - a repair loop may not reconcile client-observed impacts into daemon health. - direct and deployable callers may not bypass the shared layer transaction with scalar hull damage. +- Unity, Eve, and command clients may not remove an entity or manufacture its + loot after observing a destruction effect. ## Cut Line diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index c069b9fd0..3e6193704 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -25,7 +25,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Shield/armor/equipment/hull damage | energy-funded shield, schematic armor cells, equipment and scalar hull | daemon canonical damage transaction | aggregate/exact armor grid, entity status, and layer-aware shot/damage receipts | schematic, meters, impacts, damage VFX | owner-exists; wired; exclusive; projected: daemon initializes armor/maximum-armor grids from hull plus hardpoint catalog topology; direct and deployable damage share one `shield -> armor cell -> equipment -> scalar hull` transaction; direct impacts select a cell or complete hardpoint footprint before orthogonal spread and 0.5-cell penetration, while splash payloads select the source-facing hull half; the fossil penetration infinite-loop defect is fixed; damage type is typed/pass-through without resistances because the fossil never applied them; Eve exposes aggregate and exact armor state and receipts identify reached/applied layers; fake Ymir contacts and presentation cannot decide damage | fossil parity fixtures for topology, edge impacts, orthogonal spread, multi-cell penetration, equipment interception, and generic shield/equipment effect lifecycle | | Thermal/energy | cell conduction and capacitor/reactor/radiator network | daemon | ship/equipment status and warnings | schematic/post effects | cell conduction/radiation wired; energy network and medical thresholds remain reference | capacitor/reactor/radiator ordering, shared energy and medical timelines | | 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 | entity death and pickup paths | daemon | death/drop events and world entities | effects and pickups | fragments wired; chronology and original drop transaction absent | exactly-once drops and cleanup | +| 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 | 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 | prove generic runtime beam, contact, success and rejection feedback | | Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | daemon path wired/projected; Ymir placement and parity unproven | priority, eligibility, placement and reconnect | | Inventory/equipment | inventory panels + ServerShared | daemon | inventory/equipment surfaces | generic UI | owner-exists; wired/projected in fragments; not proven | all transfer directions and rejection |