diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index a1ebb51c7..8e1fb84f2 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -2954,15 +2954,39 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RequireEqual(1, zone.DroppedPickups.Count, "nearby pickup without a Ymir contact fact must remain in the world"); RequireEqual(0, CargoQuantity(ship, salvage.ItemKey), "proximity must not mutate cargo"); + var forgedContact = new AetheriaRuntimeWorldBeginContact + { + FactId = "ymir-fact-forged-identity", + EntityAIndex = 0, + EntityAId = "another-entity", + PickupIndex = 7, + NormalX = 1 + }; + var rejectedForgedIdentity = false; + try + { + AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-forged-identity.cc"), run, + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(forgedContact), FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, BuildPublications = false }); + } + catch (InvalidOperationException) + { + rejectedForgedIdentity = true; + } + Require(rejectedForgedIdentity, "a Ymir contact whose stable identity disagrees with the live entity must fail closed"); + RequireEqual(1, zone.DroppedPickups.Count, "forged contact identity must not remove the pickup"); + RequireEqual(0, CargoQuantity(ship, salvage.ItemKey), "forged contact identity must not mutate cargo"); + RequireEqual(0, run.PickupContactReceipts.Count, "forged contact identity must not mint a consumption receipt"); + var contact = new AetheriaRuntimeWorldBeginContact { FactId = "ymir-fact-pickup-7", EntityAIndex = 0, + EntityAId = ship.EntityId, PickupIndex = 7, NormalX = 1 }; AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-dedup.cc"), run, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(contact, contact), FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(contact, contact), FrameId = 3, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.3, Catalog = catalog, BuildPublications = false }); RequireEqual(0, zone.DroppedPickups.Count, "one Ymir contact must consume the pickup"); RequireEqual(1, CargoQuantity(ship, salvage.ItemKey), "duplicate contact facts must commit cargo exactly once"); RequireEqual(1, run.GameEvents.Count(value => value.Kind == "pickup.collected" && value.PickupIndex == 7), @@ -2972,7 +2996,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks zone.DroppedPickups = [new AetheriaRuntimeDroppedPickupCommit { PickupIndex = 8, PositionX = 10, Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = salvage.ItemKey, Quantity = 1 }, LifetimeSeconds = 30 }]; AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-expiry-smoke.cc"), run, - new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(), FrameId = 3, FixedDeltaSeconds = 30, SimulationTimeSeconds = 30, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(), FrameId = 4, FixedDeltaSeconds = 30, SimulationTimeSeconds = 30, BuildPublications = false }); RequireEqual(0, zone.DroppedPickups.Count, "pickup must expire after the fossil thirty-second lifetime"); Require(run.GameEvents.Any(value => value.Kind == "pickup.expired" && value.PickupIndex == 8), "daemon lifetime owner must emit authoritative pickup expiry event"); diff --git a/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs b/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs index 0dc62840a..4b2798143 100644 --- a/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs +++ b/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs @@ -92,8 +92,9 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi var key = new WorldKey(runId, zone.ZoneIndex); if (_sessions.TryGetValue(key, out var existing) && - existing.LastFrameId == frameId && existing.LastSimulationStepIndex == simulationStepIndex) - return existing.LastResult!; + existing.LastFrameId == frameId && existing.LastSimulationStepIndex == simulationStepIndex && + existing.LastResult != null) + return existing.LastResult; if (existing != null && (frameId < existing.LastFrameId || (frameId == existing.LastFrameId && simulationStepIndex < existing.LastSimulationStepIndex))) @@ -137,8 +138,9 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDi throw new InvalidOperationException( $"Payload phase requires the retained world phase for run '{runId}' zone {zone.ZoneIndex} " + $"at frame/step {frameId}/{simulationStepIndex}."); - if (state.LastPayloadFrameId == frameId && state.LastPayloadSimulationStepIndex == simulationStepIndex) - return state.LastPayloadResult!; + if (state.LastPayloadFrameId == frameId && state.LastPayloadSimulationStepIndex == simulationStepIndex && + state.LastPayloadResult != null) + return state.LastPayloadResult; if (deltaSeconds <= 0) throw new ArgumentOutOfRangeException(nameof(deltaSeconds)); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index c91169c9a..284483883 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -181,9 +181,13 @@ namespace GameCult.Aetheria.State.Verse !string.IsNullOrWhiteSpace(value.FactId))) { var entityIndex = contact.EntityAIndex >= 0 ? contact.EntityAIndex : contact.EntityBIndex; + var contactEntityId = contact.EntityAIndex >= 0 ? contact.EntityAId : contact.EntityBId; entitiesByIndex.TryGetValue(entityIndex, out var entity); - var entityId = entity?.EntityId ?? - (contact.EntityAIndex >= 0 ? contact.EntityAId : contact.EntityBId); + if (entity != null && !string.Equals(contactEntityId, entity.EntityId, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Ymir fact '{contact.FactId}' entity identity '{contactEntityId}' does not match " + + $"live entity {entityIndex} identity '{entity.EntityId}'."); + var entityId = entity?.EntityId ?? contactEntityId; var priorReceipt = AetheriaRuntimePickupContactReceipts.Find(run, contact.FactId); if (priorReceipt != null) {