--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_MountedAmmoTransfer.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_MountedAmmoTransfer.c @@ -231,6 +231,9 @@ m_Submitted = true; bool accepted = m_Carried.TrySwapItemStorages(m_Incoming, m_Loaded, m_Callback); if (!accepted && m_Acknowledgement != 1) return Fail("native swap refused; exact committed identity will not be retried"); + // Native inventory callbacks can revoke this operation before returning. + if (m_State != ERR_MountedAmmoTransferState.PENDING) return m_State; + if (!Context()) return Fail("original courier authority changed during native submission"); } if (m_Acknowledgement < 0) return Fail("native swap failure; partial or unproved movement remains quarantined"); if (m_Acknowledgement == 1 && SwappedProof()) --- a/tests/test_mounted_ammo_transfer.py +++ b/tests/test_mounted_ammo_transfer.py @@ -108,6 +108,17 @@ void Swap(){fromSlot.attached=&loaded;toSlot.attached=&incoming;oldItem.slot=&fromSlot;newItem.slot=&toSlot;muzzle.mag=&newMag;carried.items={&loaded};} }; int main(){ + // Inline completion must not resurrect a cancelled transaction or stale authority. + for(int loss=0;loss<4;loss++){Fixture f;auto t=f.Job(); + f.carried.ack=1;f.carried.effect=[&](){if(loss==0)t.Stop();if(loss==1)f.task.Stopped=true;if(loss==2)f.mode.active=false;if(loss==3)f.target.Released=true;f.Swap();}; + assert(t.Tick(0)==FAILED&&t.Submitted()&&!t.Unresolved()); + assert(t.Tick(1)==FAILED&&f.carried.calls==1&&f.spent.Count()==1&&!f.reservation.ReleaseUnsubmitted());} + // Cancellation with acknowledgement but no custody keeps quarantine; later exact + // movement settles uncertainty without success, another submission or a refund. + {Fixture f;auto t=f.Job();f.carried.ack=1;f.carried.effect=[&](){t.Stop();}; + assert(t.Tick(0)==FAILED&&t.Unresolved());f.Swap();assert(!t.Unresolved()); + assert(t.Tick(1)==FAILED&&f.carried.calls==1&&f.spent.Count()==1&&!f.reservation.ReleaseUnsubmitted());} + {Fixture f;auto t=f.Job();assert(t.Tick(0)==PENDING&&t.Submitted()&&t.Unresolved());assert(f.carried.calls==1); f.carried.last->OnComplete();assert(t.Tick(1)==PENDING&&t.Unresolved());f.Swap();assert(t.Tick(2)==SUCCEEDED&&!t.Unresolved()); assert(t.Tick(9000)==SUCCEEDED&&f.carried.calls==1&&f.spent.Count()==1&&f.oldMag.rounds==25&&f.newMag.rounds==100);} --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_MountedAmmoPickup.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_MountedAmmoPickup.c @@ -220,6 +220,9 @@ m_Submitted = true; bool accepted = m_TruckInventory.TryMoveItemToStorage(m_Incoming, m_Destination, -1, m_Callback); if (!accepted && m_Acknowledgement != 1) return Fail("native pickup refused; exact stock remains spent"); + // Native inventory callbacks can revoke this operation before returning. + if (m_State != ERR_MountedAmmoTransferState.PENDING) return m_State; + if (!Context()) return Fail("original courier authority changed during native submission"); } if (m_Acknowledgement < 0) return Fail("native pickup failed; unproved movement remains quarantined"); if (m_Acknowledgement == 1 && PhysicalProof()) --- a/tests/test_mounted_ammo_pickup.py +++ b/tests/test_mounted_ammo_pickup.py @@ -113,6 +113,17 @@ void Move(){fromSlot.attached=nullptr;carrySlot.attached=&incoming;newItem.slot=&carrySlot;truckInventory.items.clear();carried.items={&incoming};} }; int main(){ + // Inline completion must not resurrect a cancelled transaction or stale authority. + for(int loss=0;loss<4;loss++){Fixture f;auto t=f.Job(); + f.truckInventory.ack=1;f.truckInventory.effect=[&](){if(loss==0)t.Stop();if(loss==1)f.task.Stopped=true;if(loss==2)f.mode.active=false;if(loss==3)f.target.Released=true;f.Move();}; + assert(t.Tick(0)==FAILED&&t.Submitted()&&!t.Unresolved()); + assert(t.Tick(1)==FAILED&&f.truckInventory.calls==1&&f.spent.Count()==1&&!f.reservation.ReleaseUnsubmitted());} + // Cancellation with acknowledgement but no custody keeps quarantine; later exact + // movement settles uncertainty without success, another submission or a refund. + {Fixture f;auto t=f.Job();f.truckInventory.ack=1;f.truckInventory.effect=[&](){t.Stop();}; + assert(t.Tick(0)==FAILED&&t.Unresolved());f.Move();assert(!t.Unresolved()); + assert(t.Tick(1)==FAILED&&f.truckInventory.calls==1&&f.spent.Count()==1&&!f.reservation.ReleaseUnsubmitted());} + // No personal firearm exists in the fixture: compatibility is the pinned mounted muzzle. {Fixture f;auto t=f.Job();assert(t.Tick(0)==PENDING&&t.Submitted()&&t.Unresolved());assert(f.truckInventory.calls==1&&f.reservation.CommittedFor(&f.incoming)); f.truckInventory.last->OnComplete();assert(t.Tick(1)==PENDING&&t.Unresolved());f.Move();assert(t.Tick(2)==SUCCEEDED&&!t.Unresolved());