Restore daemon destruction and loot

This commit is contained in:
Meta Vens 2026-07-12 05:32:03 +01:00
commit 22dae9adbe
13 changed files with 333 additions and 56 deletions

View file

@ -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<AetheriaRuntimeAudioStat>(), Array.Empty<AetheriaRuntimeCurveKey>(), "", "");
}
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");

View file

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

View file

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

View file

@ -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": {

View file

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

View file

@ -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<string, string>(entry.Ref.Metadata, StringComparer.Ordinal)
{
["presentationRole"] = "effect.feedback.entity.destroyed"
};
return entry;
}
private static AetheriaRuntimeAssetRef Sprite(string key, string resourcePath)
{
return CultMeshAssetRef(

View file

@ -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<AetheriaRuntimeZoneSnapshotCommit>()).ToList();
var zone = zones.FirstOrDefault(candidate => candidate != null && candidate.ZoneIndex == zoneIndex);
if (zone == null)
return false;
var entities = (zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>()).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<AetheriaRuntimeEntitySnapshotCommit>();

View file

@ -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<AetheriaRuntimeEntitySnapshotCommit> entities,
long frameId,
double simulationTimeSeconds,
AetheriaRuntimeCatalogSnapshot? catalog)
AetheriaRuntimeCatalogSnapshot? catalog,
AetheriaRuntimeDaemonSimulationSettings settings)
{
var survivors = new List<AetheriaRuntimePhysicalPayloadCommit>();
foreach (var payload in zone.PhysicalPayloads ?? Array.Empty<AetheriaRuntimePhysicalPayloadCommit>())
@ -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<AetheriaRuntimeShotReceiptCommit>())
.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<AetheriaRuntimeWeaponStateCommit>())
{
state.Firing = false;
state.Charging = false;
state.Charged = false;
state.LockTargetEntityIndex = -1;
}
var pickups = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>()).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<AetheriaRuntimeLoadoutItemSlotCommit>();
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<AetheriaRuntimeCargoBayLoadoutCommit>())
.Where(bay => bay != null)
.SelectMany(bay => bay.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()))
{
if (slot?.Item != null) Drop(slot.Item, "cargo", cargoIndex);
cargoIndex++;
}
zone.DroppedPickups = pickups;
target.Equipment = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
target.CargoContents = Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
target.CargoBays = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
target.DockingBays = Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>();
target.DockingBayContents = Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
target.DockingBayAssignments = Array.Empty<int>();
foreach (var entity in zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
{
if (entity == null || entity.EntityIndex == target.EntityIndex) continue;
if (entity.TargetEntityIndex == target.EntityIndex) entity.TargetEntityIndex = -1;
entity.ChildEntityIndices = (entity.ChildEntityIndices ?? Array.Empty<int>())
.Where(index => index != target.EntityIndex).ToArray();
entity.DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty<int>())
.Select(index => index == target.EntityIndex ? -1 : index).ToArray();
entity.Contacts = (entity.Contacts ?? Array.Empty<AetheriaRuntimeEntityContactCommit>())
.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)

View file

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

View file

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

View file

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

View file

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

View file

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